| import { feature } from 'bun:bundle' |
| import { writeFile } from 'fs/promises' |
| import { z } from 'zod/v4' |
| import { |
| getAllowedChannels, |
| hasExitedPlanModeInSession, |
| setHasExitedPlanMode, |
| setNeedsAutoModeExitAttachment, |
| setNeedsPlanModeExitAttachment, |
| } from '../../bootstrap/state.js' |
| import { logEvent } from '../../services/analytics/index.js' |
| import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' |
| import { |
| buildTool, |
| type Tool, |
| type ToolDef, |
| toolMatchesName, |
| } from '../../Tool.js' |
| import { formatAgentId, generateRequestId } from '../../utils/agentId.js' |
| import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' |
| import { logForDebugging } from '../../utils/debug.js' |
| import { |
| findInProcessTeammateTaskId, |
| setAwaitingPlanApproval, |
| } from '../../utils/inProcessTeammateHelpers.js' |
| import { lazySchema } from '../../utils/lazySchema.js' |
| import { logError } from '../../utils/log.js' |
| import { |
| getPlan, |
| getPlanFilePath, |
| persistFileSnapshotIfRemote, |
| } from '../../utils/plans.js' |
| import { jsonStringify } from '../../utils/slowOperations.js' |
| import { |
| getAgentName, |
| getTeamName, |
| isPlanModeRequired, |
| isTeammate, |
| } from '../../utils/teammate.js' |
| import { writeToMailbox } from '../../utils/teammateMailbox.js' |
| import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' |
| import { TEAM_CREATE_TOOL_NAME } from '../TeamCreateTool/constants.js' |
| import { EXIT_PLAN_MODE_V2_TOOL_NAME } from './constants.js' |
| import { EXIT_PLAN_MODE_V2_TOOL_PROMPT } from './prompt.js' |
| import { |
| renderToolResultMessage, |
| renderToolUseMessage, |
| renderToolUseRejectedMessage, |
| } from './UI.js' |
|
|
| |
| const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') |
| ? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js')) |
| : null |
| const permissionSetupModule = feature('TRANSCRIPT_CLASSIFIER') |
| ? (require('../../utils/permissions/permissionSetup.js') as typeof import('../../utils/permissions/permissionSetup.js')) |
| : null |
| |
|
|
| |
| |
| |
| |
| const allowedPromptSchema = lazySchema(() => |
| z.object({ |
| tool: z.enum(['Bash']).describe('The tool this prompt applies to'), |
| prompt: z |
| .string() |
| .describe( |
| 'Semantic description of the action, e.g. "run tests", "install dependencies"', |
| ), |
| }), |
| ) |
|
|
| export type AllowedPrompt = z.infer<ReturnType<typeof allowedPromptSchema>> |
|
|
| const inputSchema = lazySchema(() => |
| z |
| .strictObject({ |
| |
| allowedPrompts: z |
| .array(allowedPromptSchema()) |
| .optional() |
| .describe( |
| 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', |
| ), |
| }) |
| .passthrough(), |
| ) |
| type InputSchema = ReturnType<typeof inputSchema> |
|
|
| |
| |
| |
| |
| |
| export const _sdkInputSchema = lazySchema(() => |
| inputSchema().extend({ |
| plan: z |
| .string() |
| .optional() |
| .describe('The plan content (injected by normalizeToolInput from disk)'), |
| planFilePath: z |
| .string() |
| .optional() |
| .describe('The plan file path (injected by normalizeToolInput)'), |
| }), |
| ) |
|
|
| export const outputSchema = lazySchema(() => |
| z.object({ |
| plan: z |
| .string() |
| .nullable() |
| .describe('The plan that was presented to the user'), |
| isAgent: z.boolean(), |
| filePath: z |
| .string() |
| .optional() |
| .describe('The file path where the plan was saved'), |
| hasTaskTool: z |
| .boolean() |
| .optional() |
| .describe('Whether the Agent tool is available in the current context'), |
| planWasEdited: z |
| .boolean() |
| .optional() |
| .describe( |
| 'True when the user edited the plan (CCR web UI or Ctrl+G); determines whether the plan is echoed back in tool_result', |
| ), |
| awaitingLeaderApproval: z |
| .boolean() |
| .optional() |
| .describe( |
| 'When true, the teammate has sent a plan approval request to the team leader', |
| ), |
| requestId: z |
| .string() |
| .optional() |
| .describe('Unique identifier for the plan approval request'), |
| }), |
| ) |
| type OutputSchema = ReturnType<typeof outputSchema> |
|
|
| export type Output = z.infer<OutputSchema> |
|
|
| export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = buildTool({ |
| name: EXIT_PLAN_MODE_V2_TOOL_NAME, |
| searchHint: 'present plan for approval and start coding (plan mode only)', |
| maxResultSizeChars: 100_000, |
| async description() { |
| return 'Prompts the user to exit plan mode and start coding' |
| }, |
| async prompt() { |
| return EXIT_PLAN_MODE_V2_TOOL_PROMPT |
| }, |
| get inputSchema(): InputSchema { |
| return inputSchema() |
| }, |
| get outputSchema(): OutputSchema { |
| return outputSchema() |
| }, |
| userFacingName() { |
| return '' |
| }, |
| shouldDefer: true, |
| isEnabled() { |
| |
| |
| |
| if ( |
| (feature('KAIROS') || feature('KAIROS_CHANNELS')) && |
| getAllowedChannels().length > 0 |
| ) { |
| return false |
| } |
| return true |
| }, |
| isConcurrencySafe() { |
| return true |
| }, |
| isReadOnly() { |
| return false |
| }, |
| requiresUserInteraction() { |
| |
| |
| |
| if (isTeammate()) { |
| return false |
| } |
| |
| return true |
| }, |
| async validateInput(_input, { getAppState, options }) { |
| |
| |
| if (isTeammate()) { |
| return { result: true } |
| } |
| |
| |
| |
| const mode = getAppState().toolPermissionContext.mode |
| if (mode !== 'plan') { |
| logEvent('tengu_exit_plan_mode_called_outside_plan', { |
| model: |
| options.mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| hasExitedPlanModeInSession: hasExitedPlanModeInSession(), |
| }) |
| return { |
| result: false, |
| message: |
| 'You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.', |
| errorCode: 1, |
| } |
| } |
| return { result: true } |
| }, |
| async checkPermissions(input, context) { |
| |
| |
| |
| |
| if (isTeammate()) { |
| return { |
| behavior: 'allow' as const, |
| updatedInput: input, |
| } |
| } |
|
|
| |
| return { |
| behavior: 'ask' as const, |
| message: 'Exit plan mode?', |
| updatedInput: input, |
| } |
| }, |
| renderToolUseMessage, |
| renderToolResultMessage, |
| renderToolUseRejectedMessage, |
| async call(input, context) { |
| const isAgent = !!context.agentId |
|
|
| const filePath = getPlanFilePath(context.agentId) |
| |
| |
| |
| |
| const inputPlan = |
| 'plan' in input && typeof input.plan === 'string' ? input.plan : undefined |
| const plan = inputPlan ?? getPlan(context.agentId) |
|
|
| |
| |
| |
| if (inputPlan !== undefined && filePath) { |
| await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e)) |
| void persistFileSnapshotIfRemote() |
| } |
|
|
| |
| if (isTeammate() && isPlanModeRequired()) { |
| |
| if (!plan) { |
| throw new Error( |
| `No plan file found at ${filePath}. Please write your plan to this file before calling ExitPlanMode.`, |
| ) |
| } |
| const agentName = getAgentName() || 'unknown' |
| const teamName = getTeamName() |
| const requestId = generateRequestId( |
| 'plan_approval', |
| formatAgentId(agentName, teamName || 'default'), |
| ) |
|
|
| const approvalRequest = { |
| type: 'plan_approval_request', |
| from: agentName, |
| timestamp: new Date().toISOString(), |
| planFilePath: filePath, |
| planContent: plan, |
| requestId, |
| } |
|
|
| await writeToMailbox( |
| 'team-lead', |
| { |
| from: agentName, |
| text: jsonStringify(approvalRequest), |
| timestamp: new Date().toISOString(), |
| }, |
| teamName, |
| ) |
|
|
| |
| const appState = context.getAppState() |
| const agentTaskId = findInProcessTeammateTaskId(agentName, appState) |
| if (agentTaskId) { |
| setAwaitingPlanApproval(agentTaskId, context.setAppState, true) |
| } |
|
|
| return { |
| data: { |
| plan, |
| isAgent: true, |
| filePath, |
| awaitingLeaderApproval: true, |
| requestId, |
| }, |
| } |
| } |
|
|
| |
| |
|
|
| |
| |
| |
| const appState = context.getAppState() |
| |
| |
| |
| |
| |
| let gateFallbackNotification: string | null = null |
| if (feature('TRANSCRIPT_CLASSIFIER')) { |
| const prePlanRaw = appState.toolPermissionContext.prePlanMode ?? 'default' |
| if ( |
| prePlanRaw === 'auto' && |
| !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) |
| ) { |
| const reason = |
| permissionSetupModule?.getAutoModeUnavailableReason() ?? |
| 'circuit-breaker' |
| gateFallbackNotification = |
| permissionSetupModule?.getAutoModeUnavailableNotification(reason) ?? |
| 'auto mode unavailable' |
| logForDebugging( |
| `[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=${prePlanRaw} ` + |
| `but gate is off (reason=${reason}) — falling back to default on plan exit`, |
| { level: 'warn' }, |
| ) |
| } |
| } |
| if (gateFallbackNotification) { |
| context.addNotification?.({ |
| key: 'auto-mode-gate-plan-exit-fallback', |
| text: `plan exit → default · ${gateFallbackNotification}`, |
| priority: 'immediate', |
| color: 'warning', |
| timeoutMs: 10000, |
| }) |
| } |
|
|
| context.setAppState(prev => { |
| if (prev.toolPermissionContext.mode !== 'plan') return prev |
| setHasExitedPlanMode(true) |
| setNeedsPlanModeExitAttachment(true) |
| let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default' |
| if (feature('TRANSCRIPT_CLASSIFIER')) { |
| if ( |
| restoreMode === 'auto' && |
| !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) |
| ) { |
| restoreMode = 'default' |
| } |
| const finalRestoringAuto = restoreMode === 'auto' |
| |
| |
| |
| const autoWasUsedDuringPlan = |
| autoModeStateModule?.isAutoModeActive() ?? false |
| autoModeStateModule?.setAutoModeActive(finalRestoringAuto) |
| if (autoWasUsedDuringPlan && !finalRestoringAuto) { |
| setNeedsAutoModeExitAttachment(true) |
| } |
| } |
| |
| |
| |
| const restoringToAuto = restoreMode === 'auto' |
| let baseContext = prev.toolPermissionContext |
| if (restoringToAuto) { |
| baseContext = |
| permissionSetupModule?.stripDangerousPermissionsForAutoMode( |
| baseContext, |
| ) ?? baseContext |
| } else if (prev.toolPermissionContext.strippedDangerousRules) { |
| baseContext = |
| permissionSetupModule?.restoreDangerousPermissions(baseContext) ?? |
| baseContext |
| } |
| return { |
| ...prev, |
| toolPermissionContext: { |
| ...baseContext, |
| mode: restoreMode, |
| prePlanMode: undefined, |
| }, |
| } |
| }) |
|
|
| const hasTaskTool = |
| isAgentSwarmsEnabled() && |
| context.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) |
|
|
| return { |
| data: { |
| plan, |
| isAgent, |
| filePath, |
| hasTaskTool: hasTaskTool || undefined, |
| planWasEdited: inputPlan !== undefined || undefined, |
| }, |
| } |
| }, |
| mapToolResultToToolResultBlockParam( |
| { |
| isAgent, |
| plan, |
| filePath, |
| hasTaskTool, |
| planWasEdited, |
| awaitingLeaderApproval, |
| requestId, |
| }, |
| toolUseID, |
| ) { |
| |
| if (awaitingLeaderApproval) { |
| return { |
| type: 'tool_result', |
| content: `Your plan has been submitted to the team lead for approval. |
| |
| Plan file: ${filePath} |
| |
| **What happens next:** |
| 1. Wait for the team lead to review your plan |
| 2. You will receive a message in your inbox with approval/rejection |
| 3. If approved, you can proceed with implementation |
| 4. If rejected, refine your plan based on the feedback |
| |
| **Important:** Do NOT proceed until you receive approval. Check your inbox for response. |
| |
| Request ID: ${requestId}`, |
| tool_use_id: toolUseID, |
| } |
| } |
|
|
| if (isAgent) { |
| return { |
| type: 'tool_result', |
| content: |
| 'User has approved the plan. There is nothing else needed from you now. Please respond with "ok"', |
| tool_use_id: toolUseID, |
| } |
| } |
|
|
| |
| if (!plan || plan.trim() === '') { |
| return { |
| type: 'tool_result', |
| content: 'User has approved exiting plan mode. You can now proceed.', |
| tool_use_id: toolUseID, |
| } |
| } |
|
|
| const teamHint = hasTaskTool |
| ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` |
| : '' |
|
|
| |
| |
| |
| const planLabel = planWasEdited |
| ? 'Approved Plan (edited by user)' |
| : 'Approved Plan' |
|
|
| return { |
| type: 'tool_result', |
| content: `User has approved your plan. You can now start coding. Start with updating your todo list if applicable |
| |
| Your plan has been saved to: ${filePath} |
| You can refer back to it if needed during implementation.${teamHint} |
| |
| ## ${planLabel}: |
| ${plan}`, |
| tool_use_id: toolUseID, |
| } |
| }, |
| } satisfies ToolDef<InputSchema, Output>) |
|
|