source dump of claude code
at main 406 lines 12 kB view raw
1import { feature } from 'bun:bundle' 2import { z } from 'zod/v4' 3import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4import { buildTool, type ToolDef } from '../../Tool.js' 5import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 6import { 7 executeTaskCompletedHooks, 8 getTaskCompletedHookMessage, 9} from '../../utils/hooks.js' 10import { lazySchema } from '../../utils/lazySchema.js' 11import { 12 blockTask, 13 deleteTask, 14 getTask, 15 getTaskListId, 16 isTodoV2Enabled, 17 listTasks, 18 type TaskStatus, 19 TaskStatusSchema, 20 updateTask, 21} from '../../utils/tasks.js' 22import { 23 getAgentId, 24 getAgentName, 25 getTeammateColor, 26 getTeamName, 27} from '../../utils/teammate.js' 28import { writeToMailbox } from '../../utils/teammateMailbox.js' 29import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js' 30import { TASK_UPDATE_TOOL_NAME } from './constants.js' 31import { DESCRIPTION, PROMPT } from './prompt.js' 32 33const inputSchema = lazySchema(() => { 34 // Extended status schema that includes 'deleted' as a special action 35 const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted')) 36 37 return z.strictObject({ 38 taskId: z.string().describe('The ID of the task to update'), 39 subject: z.string().optional().describe('New subject for the task'), 40 description: z.string().optional().describe('New description for the task'), 41 activeForm: z 42 .string() 43 .optional() 44 .describe( 45 'Present continuous form shown in spinner when in_progress (e.g., "Running tests")', 46 ), 47 status: TaskUpdateStatusSchema.optional().describe( 48 'New status for the task', 49 ), 50 addBlocks: z 51 .array(z.string()) 52 .optional() 53 .describe('Task IDs that this task blocks'), 54 addBlockedBy: z 55 .array(z.string()) 56 .optional() 57 .describe('Task IDs that block this task'), 58 owner: z.string().optional().describe('New owner for the task'), 59 metadata: z 60 .record(z.string(), z.unknown()) 61 .optional() 62 .describe( 63 'Metadata keys to merge into the task. Set a key to null to delete it.', 64 ), 65 }) 66}) 67type InputSchema = ReturnType<typeof inputSchema> 68 69const outputSchema = lazySchema(() => 70 z.object({ 71 success: z.boolean(), 72 taskId: z.string(), 73 updatedFields: z.array(z.string()), 74 error: z.string().optional(), 75 statusChange: z 76 .object({ 77 from: z.string(), 78 to: z.string(), 79 }) 80 .optional(), 81 verificationNudgeNeeded: z.boolean().optional(), 82 }), 83) 84type OutputSchema = ReturnType<typeof outputSchema> 85 86export type Output = z.infer<OutputSchema> 87 88export const TaskUpdateTool = buildTool({ 89 name: TASK_UPDATE_TOOL_NAME, 90 searchHint: 'update a task', 91 maxResultSizeChars: 100_000, 92 async description() { 93 return DESCRIPTION 94 }, 95 async prompt() { 96 return PROMPT 97 }, 98 get inputSchema(): InputSchema { 99 return inputSchema() 100 }, 101 get outputSchema(): OutputSchema { 102 return outputSchema() 103 }, 104 userFacingName() { 105 return 'TaskUpdate' 106 }, 107 shouldDefer: true, 108 isEnabled() { 109 return isTodoV2Enabled() 110 }, 111 isConcurrencySafe() { 112 return true 113 }, 114 toAutoClassifierInput(input) { 115 const parts = [input.taskId] 116 if (input.status) parts.push(input.status) 117 if (input.subject) parts.push(input.subject) 118 return parts.join(' ') 119 }, 120 renderToolUseMessage() { 121 return null 122 }, 123 async call( 124 { 125 taskId, 126 subject, 127 description, 128 activeForm, 129 status, 130 owner, 131 addBlocks, 132 addBlockedBy, 133 metadata, 134 }, 135 context, 136 ) { 137 const taskListId = getTaskListId() 138 139 // Auto-expand task list when updating tasks 140 context.setAppState(prev => { 141 if (prev.expandedView === 'tasks') return prev 142 return { ...prev, expandedView: 'tasks' as const } 143 }) 144 145 // Check if task exists 146 const existingTask = await getTask(taskListId, taskId) 147 if (!existingTask) { 148 return { 149 data: { 150 success: false, 151 taskId, 152 updatedFields: [], 153 error: 'Task not found', 154 }, 155 } 156 } 157 158 const updatedFields: string[] = [] 159 160 // Update basic fields if provided and different from current value 161 const updates: { 162 subject?: string 163 description?: string 164 activeForm?: string 165 status?: TaskStatus 166 owner?: string 167 metadata?: Record<string, unknown> 168 } = {} 169 if (subject !== undefined && subject !== existingTask.subject) { 170 updates.subject = subject 171 updatedFields.push('subject') 172 } 173 if (description !== undefined && description !== existingTask.description) { 174 updates.description = description 175 updatedFields.push('description') 176 } 177 if (activeForm !== undefined && activeForm !== existingTask.activeForm) { 178 updates.activeForm = activeForm 179 updatedFields.push('activeForm') 180 } 181 if (owner !== undefined && owner !== existingTask.owner) { 182 updates.owner = owner 183 updatedFields.push('owner') 184 } 185 // Auto-set owner when a teammate marks a task as in_progress without 186 // explicitly providing an owner. This ensures the task list can match 187 // todo items to teammates for showing activity status. 188 if ( 189 isAgentSwarmsEnabled() && 190 status === 'in_progress' && 191 owner === undefined && 192 !existingTask.owner 193 ) { 194 const agentName = getAgentName() 195 if (agentName) { 196 updates.owner = agentName 197 updatedFields.push('owner') 198 } 199 } 200 if (metadata !== undefined) { 201 const merged = { ...(existingTask.metadata ?? {}) } 202 for (const [key, value] of Object.entries(metadata)) { 203 if (value === null) { 204 delete merged[key] 205 } else { 206 merged[key] = value 207 } 208 } 209 updates.metadata = merged 210 updatedFields.push('metadata') 211 } 212 if (status !== undefined) { 213 // Handle deletion - delete the task file and return early 214 if (status === 'deleted') { 215 const deleted = await deleteTask(taskListId, taskId) 216 return { 217 data: { 218 success: deleted, 219 taskId, 220 updatedFields: deleted ? ['deleted'] : [], 221 error: deleted ? undefined : 'Failed to delete task', 222 statusChange: deleted 223 ? { from: existingTask.status, to: 'deleted' } 224 : undefined, 225 }, 226 } 227 } 228 229 // For regular status updates, validate and apply if different 230 if (status !== existingTask.status) { 231 // Run TaskCompleted hooks when marking a task as completed 232 if (status === 'completed') { 233 const blockingErrors: string[] = [] 234 235 const generator = executeTaskCompletedHooks( 236 taskId, 237 existingTask.subject, 238 existingTask.description, 239 getAgentName(), 240 getTeamName(), 241 undefined, 242 context?.abortController?.signal, 243 undefined, 244 context, 245 ) 246 247 for await (const result of generator) { 248 if (result.blockingError) { 249 blockingErrors.push( 250 getTaskCompletedHookMessage(result.blockingError), 251 ) 252 } 253 } 254 255 if (blockingErrors.length > 0) { 256 return { 257 data: { 258 success: false, 259 taskId, 260 updatedFields: [], 261 error: blockingErrors.join('\n'), 262 }, 263 } 264 } 265 } 266 267 updates.status = status 268 updatedFields.push('status') 269 } 270 } 271 272 if (Object.keys(updates).length > 0) { 273 await updateTask(taskListId, taskId, updates) 274 } 275 276 // Notify new owner via mailbox when ownership changes 277 if (updates.owner && isAgentSwarmsEnabled()) { 278 const senderName = getAgentName() || 'team-lead' 279 const senderColor = getTeammateColor() 280 const assignmentMessage = JSON.stringify({ 281 type: 'task_assignment', 282 taskId, 283 subject: existingTask.subject, 284 description: existingTask.description, 285 assignedBy: senderName, 286 timestamp: new Date().toISOString(), 287 }) 288 await writeToMailbox( 289 updates.owner, 290 { 291 from: senderName, 292 text: assignmentMessage, 293 timestamp: new Date().toISOString(), 294 color: senderColor, 295 }, 296 taskListId, 297 ) 298 } 299 300 // Add blocks if provided and not already present 301 if (addBlocks && addBlocks.length > 0) { 302 const newBlocks = addBlocks.filter( 303 id => !existingTask.blocks.includes(id), 304 ) 305 for (const blockId of newBlocks) { 306 await blockTask(taskListId, taskId, blockId) 307 } 308 if (newBlocks.length > 0) { 309 updatedFields.push('blocks') 310 } 311 } 312 313 // Add blockedBy if provided and not already present (reverse: the blocker blocks this task) 314 if (addBlockedBy && addBlockedBy.length > 0) { 315 const newBlockedBy = addBlockedBy.filter( 316 id => !existingTask.blockedBy.includes(id), 317 ) 318 for (const blockerId of newBlockedBy) { 319 await blockTask(taskListId, blockerId, taskId) 320 } 321 if (newBlockedBy.length > 0) { 322 updatedFields.push('blockedBy') 323 } 324 } 325 326 // Structural verification nudge: if the main-thread agent just closed 327 // out a 3+ task list and none of those tasks was a verification step, 328 // append a reminder to the tool result. Fires at the loop-exit moment 329 // where skips happen ("when the last task closed, the loop exited"). 330 // Mirrors the TodoWriteTool nudge for V1 sessions; this covers V2 331 // (interactive CLI). TaskUpdateToolOutput is @internal so this field 332 // does not touch the public SDK surface. 333 let verificationNudgeNeeded = false 334 if ( 335 feature('VERIFICATION_AGENT') && 336 getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) && 337 !context.agentId && 338 updates.status === 'completed' 339 ) { 340 const allTasks = await listTasks(taskListId) 341 const allDone = allTasks.every(t => t.status === 'completed') 342 if ( 343 allDone && 344 allTasks.length >= 3 && 345 !allTasks.some(t => /verif/i.test(t.subject)) 346 ) { 347 verificationNudgeNeeded = true 348 } 349 } 350 351 return { 352 data: { 353 success: true, 354 taskId, 355 updatedFields, 356 statusChange: 357 updates.status !== undefined 358 ? { from: existingTask.status, to: updates.status } 359 : undefined, 360 verificationNudgeNeeded, 361 }, 362 } 363 }, 364 mapToolResultToToolResultBlockParam(content, toolUseID) { 365 const { 366 success, 367 taskId, 368 updatedFields, 369 error, 370 statusChange, 371 verificationNudgeNeeded, 372 } = content as Output 373 if (!success) { 374 // Return as non-error so it doesn't trigger sibling tool cancellation 375 // in StreamingToolExecutor. "Task not found" is a benign condition 376 // (e.g., task list already cleaned up) that the model can handle. 377 return { 378 tool_use_id: toolUseID, 379 type: 'tool_result', 380 content: error || `Task #${taskId} not found`, 381 } 382 } 383 384 let resultContent = `Updated task #${taskId} ${updatedFields.join(', ')}` 385 386 // Add reminder for teammates when they complete a task (supports in-process teammates) 387 if ( 388 statusChange?.to === 'completed' && 389 getAgentId() && 390 isAgentSwarmsEnabled() 391 ) { 392 resultContent += 393 '\n\nTask completed. Call TaskList now to find your next available task or see if your work unblocked others.' 394 } 395 396 if (verificationNudgeNeeded) { 397 resultContent += `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.` 398 } 399 400 return { 401 tool_use_id: toolUseID, 402 type: 'tool_result', 403 content: resultContent, 404 } 405 }, 406} satisfies ToolDef<InputSchema, Output>)