/ tools / TaskUpdateTool / TaskUpdateTool.ts
TaskUpdateTool.ts
  1  import { feature } from 'bun:bundle'
  2  import { z } from 'zod/v4'
  3  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  4  import { buildTool, type ToolDef } from '../../Tool.js'
  5  import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
  6  import {
  7    executeTaskCompletedHooks,
  8    getTaskCompletedHookMessage,
  9  } from '../../utils/hooks.js'
 10  import { lazySchema } from '../../utils/lazySchema.js'
 11  import {
 12    blockTask,
 13    deleteTask,
 14    getTask,
 15    getTaskListId,
 16    isTodoV2Enabled,
 17    listTasks,
 18    type TaskStatus,
 19    TaskStatusSchema,
 20    updateTask,
 21  } from '../../utils/tasks.js'
 22  import {
 23    getAgentId,
 24    getAgentName,
 25    getTeammateColor,
 26    getTeamName,
 27  } from '../../utils/teammate.js'
 28  import { writeToMailbox } from '../../utils/teammateMailbox.js'
 29  import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
 30  import { TASK_UPDATE_TOOL_NAME } from './constants.js'
 31  import { DESCRIPTION, PROMPT } from './prompt.js'
 32  
 33  const 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  })
 67  type InputSchema = ReturnType<typeof inputSchema>
 68  
 69  const 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  )
 84  type OutputSchema = ReturnType<typeof outputSchema>
 85  
 86  export type Output = z.infer<OutputSchema>
 87  
 88  export 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>)