407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { z } from 'zod/v4'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
|
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
|
import {
|
|
executeTaskCompletedHooks,
|
|
getTaskCompletedHookMessage,
|
|
} from '../../utils/hooks.js'
|
|
import { lazySchema } from '../../utils/lazySchema.js'
|
|
import {
|
|
blockTask,
|
|
deleteTask,
|
|
getTask,
|
|
getTaskListId,
|
|
isTodoV2Enabled,
|
|
listTasks,
|
|
type TaskStatus,
|
|
TaskStatusSchema,
|
|
updateTask,
|
|
} from '../../utils/tasks.js'
|
|
import {
|
|
getAgentId,
|
|
getAgentName,
|
|
getTeammateColor,
|
|
getTeamName,
|
|
} from '../../utils/teammate.js'
|
|
import { writeToMailbox } from '../../utils/teammateMailbox.js'
|
|
import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
|
|
import { TASK_UPDATE_TOOL_NAME } from './constants.js'
|
|
import { DESCRIPTION, PROMPT } from './prompt.js'
|
|
|
|
const inputSchema = lazySchema(() => {
|
|
// Extended status schema that includes 'deleted' as a special action
|
|
const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted'))
|
|
|
|
return z.strictObject({
|
|
taskId: z.string().describe('The ID of the task to update'),
|
|
subject: z.string().optional().describe('New subject for the task'),
|
|
description: z.string().optional().describe('New description for the task'),
|
|
activeForm: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
|
|
),
|
|
status: TaskUpdateStatusSchema.optional().describe(
|
|
'New status for the task',
|
|
),
|
|
addBlocks: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe('Task IDs that this task blocks'),
|
|
addBlockedBy: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe('Task IDs that block this task'),
|
|
owner: z.string().optional().describe('New owner for the task'),
|
|
metadata: z
|
|
.record(z.string(), z.unknown())
|
|
.optional()
|
|
.describe(
|
|
'Metadata keys to merge into the task. Set a key to null to delete it.',
|
|
),
|
|
})
|
|
})
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
const outputSchema = lazySchema(() =>
|
|
z.object({
|
|
success: z.boolean(),
|
|
taskId: z.string(),
|
|
updatedFields: z.array(z.string()),
|
|
error: z.string().optional(),
|
|
statusChange: z
|
|
.object({
|
|
from: z.string(),
|
|
to: z.string(),
|
|
})
|
|
.optional(),
|
|
verificationNudgeNeeded: z.boolean().optional(),
|
|
}),
|
|
)
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
export const TaskUpdateTool = buildTool({
|
|
name: TASK_UPDATE_TOOL_NAME,
|
|
searchHint: 'update a task',
|
|
maxResultSizeChars: 100_000,
|
|
async description() {
|
|
return DESCRIPTION
|
|
},
|
|
async prompt() {
|
|
return PROMPT
|
|
},
|
|
get inputSchema(): InputSchema {
|
|
return inputSchema()
|
|
},
|
|
get outputSchema(): OutputSchema {
|
|
return outputSchema()
|
|
},
|
|
userFacingName() {
|
|
return 'TaskUpdate'
|
|
},
|
|
shouldDefer: true,
|
|
isEnabled() {
|
|
return isTodoV2Enabled()
|
|
},
|
|
isConcurrencySafe() {
|
|
return true
|
|
},
|
|
toAutoClassifierInput(input) {
|
|
const parts = [input.taskId]
|
|
if (input.status) parts.push(input.status)
|
|
if (input.subject) parts.push(input.subject)
|
|
return parts.join(' ')
|
|
},
|
|
renderToolUseMessage() {
|
|
return null
|
|
},
|
|
async call(
|
|
{
|
|
taskId,
|
|
subject,
|
|
description,
|
|
activeForm,
|
|
status,
|
|
owner,
|
|
addBlocks,
|
|
addBlockedBy,
|
|
metadata,
|
|
},
|
|
context,
|
|
) {
|
|
const taskListId = getTaskListId()
|
|
|
|
// Auto-expand task list when updating tasks
|
|
context.setAppState(prev => {
|
|
if (prev.expandedView === 'tasks') return prev
|
|
return { ...prev, expandedView: 'tasks' as const }
|
|
})
|
|
|
|
// Check if task exists
|
|
const existingTask = await getTask(taskListId, taskId)
|
|
if (!existingTask) {
|
|
return {
|
|
data: {
|
|
success: false,
|
|
taskId,
|
|
updatedFields: [],
|
|
error: 'Task not found',
|
|
},
|
|
}
|
|
}
|
|
|
|
const updatedFields: string[] = []
|
|
|
|
// Update basic fields if provided and different from current value
|
|
const updates: {
|
|
subject?: string
|
|
description?: string
|
|
activeForm?: string
|
|
status?: TaskStatus
|
|
owner?: string
|
|
metadata?: Record<string, unknown>
|
|
} = {}
|
|
if (subject !== undefined && subject !== existingTask.subject) {
|
|
updates.subject = subject
|
|
updatedFields.push('subject')
|
|
}
|
|
if (description !== undefined && description !== existingTask.description) {
|
|
updates.description = description
|
|
updatedFields.push('description')
|
|
}
|
|
if (activeForm !== undefined && activeForm !== existingTask.activeForm) {
|
|
updates.activeForm = activeForm
|
|
updatedFields.push('activeForm')
|
|
}
|
|
if (owner !== undefined && owner !== existingTask.owner) {
|
|
updates.owner = owner
|
|
updatedFields.push('owner')
|
|
}
|
|
// Auto-set owner when a teammate marks a task as in_progress without
|
|
// explicitly providing an owner. This ensures the task list can match
|
|
// todo items to teammates for showing activity status.
|
|
if (
|
|
isAgentSwarmsEnabled() &&
|
|
status === 'in_progress' &&
|
|
owner === undefined &&
|
|
!existingTask.owner
|
|
) {
|
|
const agentName = getAgentName()
|
|
if (agentName) {
|
|
updates.owner = agentName
|
|
updatedFields.push('owner')
|
|
}
|
|
}
|
|
if (metadata !== undefined) {
|
|
const merged = { ...(existingTask.metadata ?? {}) }
|
|
for (const [key, value] of Object.entries(metadata)) {
|
|
if (value === null) {
|
|
delete merged[key]
|
|
} else {
|
|
merged[key] = value
|
|
}
|
|
}
|
|
updates.metadata = merged
|
|
updatedFields.push('metadata')
|
|
}
|
|
if (status !== undefined) {
|
|
// Handle deletion - delete the task file and return early
|
|
if (status === 'deleted') {
|
|
const deleted = await deleteTask(taskListId, taskId)
|
|
return {
|
|
data: {
|
|
success: deleted,
|
|
taskId,
|
|
updatedFields: deleted ? ['deleted'] : [],
|
|
error: deleted ? undefined : 'Failed to delete task',
|
|
statusChange: deleted
|
|
? { from: existingTask.status, to: 'deleted' }
|
|
: undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
// For regular status updates, validate and apply if different
|
|
if (status !== existingTask.status) {
|
|
// Run TaskCompleted hooks when marking a task as completed
|
|
if (status === 'completed') {
|
|
const blockingErrors: string[] = []
|
|
|
|
const generator = executeTaskCompletedHooks(
|
|
taskId,
|
|
existingTask.subject,
|
|
existingTask.description,
|
|
getAgentName(),
|
|
getTeamName(),
|
|
undefined,
|
|
context?.abortController?.signal,
|
|
undefined,
|
|
context,
|
|
)
|
|
|
|
for await (const result of generator) {
|
|
if (result.blockingError) {
|
|
blockingErrors.push(
|
|
getTaskCompletedHookMessage(result.blockingError),
|
|
)
|
|
}
|
|
}
|
|
|
|
if (blockingErrors.length > 0) {
|
|
return {
|
|
data: {
|
|
success: false,
|
|
taskId,
|
|
updatedFields: [],
|
|
error: blockingErrors.join('\n'),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
updates.status = status
|
|
updatedFields.push('status')
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await updateTask(taskListId, taskId, updates)
|
|
}
|
|
|
|
// Notify new owner via mailbox when ownership changes
|
|
if (updates.owner && isAgentSwarmsEnabled()) {
|
|
const senderName = getAgentName() || 'team-lead'
|
|
const senderColor = getTeammateColor()
|
|
const assignmentMessage = JSON.stringify({
|
|
type: 'task_assignment',
|
|
taskId,
|
|
subject: existingTask.subject,
|
|
description: existingTask.description,
|
|
assignedBy: senderName,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
await writeToMailbox(
|
|
updates.owner,
|
|
{
|
|
from: senderName,
|
|
text: assignmentMessage,
|
|
timestamp: new Date().toISOString(),
|
|
color: senderColor,
|
|
},
|
|
taskListId,
|
|
)
|
|
}
|
|
|
|
// Add blocks if provided and not already present
|
|
if (addBlocks && addBlocks.length > 0) {
|
|
const newBlocks = addBlocks.filter(
|
|
id => !existingTask.blocks.includes(id),
|
|
)
|
|
for (const blockId of newBlocks) {
|
|
await blockTask(taskListId, taskId, blockId)
|
|
}
|
|
if (newBlocks.length > 0) {
|
|
updatedFields.push('blocks')
|
|
}
|
|
}
|
|
|
|
// Add blockedBy if provided and not already present (reverse: the blocker blocks this task)
|
|
if (addBlockedBy && addBlockedBy.length > 0) {
|
|
const newBlockedBy = addBlockedBy.filter(
|
|
id => !existingTask.blockedBy.includes(id),
|
|
)
|
|
for (const blockerId of newBlockedBy) {
|
|
await blockTask(taskListId, blockerId, taskId)
|
|
}
|
|
if (newBlockedBy.length > 0) {
|
|
updatedFields.push('blockedBy')
|
|
}
|
|
}
|
|
|
|
// Structural verification nudge: if the main-thread agent just closed
|
|
// out a 3+ task list and none of those tasks was a verification step,
|
|
// append a reminder to the tool result. Fires at the loop-exit moment
|
|
// where skips happen ("when the last task closed, the loop exited").
|
|
// Mirrors the TodoWriteTool nudge for V1 sessions; this covers V2
|
|
// (interactive CLI). TaskUpdateToolOutput is @internal so this field
|
|
// does not touch the public SDK surface.
|
|
let verificationNudgeNeeded = false
|
|
if (
|
|
feature('VERIFICATION_AGENT') &&
|
|
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
|
|
!context.agentId &&
|
|
updates.status === 'completed'
|
|
) {
|
|
const allTasks = await listTasks(taskListId)
|
|
const allDone = allTasks.every(t => t.status === 'completed')
|
|
if (
|
|
allDone &&
|
|
allTasks.length >= 3 &&
|
|
!allTasks.some(t => /verif/i.test(t.subject))
|
|
) {
|
|
verificationNudgeNeeded = true
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
success: true,
|
|
taskId,
|
|
updatedFields,
|
|
statusChange:
|
|
updates.status !== undefined
|
|
? { from: existingTask.status, to: updates.status }
|
|
: undefined,
|
|
verificationNudgeNeeded,
|
|
},
|
|
}
|
|
},
|
|
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
|
const {
|
|
success,
|
|
taskId,
|
|
updatedFields,
|
|
error,
|
|
statusChange,
|
|
verificationNudgeNeeded,
|
|
} = content as Output
|
|
if (!success) {
|
|
// Return as non-error so it doesn't trigger sibling tool cancellation
|
|
// in StreamingToolExecutor. "Task not found" is a benign condition
|
|
// (e.g., task list already cleaned up) that the model can handle.
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: error || `Task #${taskId} not found`,
|
|
}
|
|
}
|
|
|
|
let resultContent = `Updated task #${taskId} ${updatedFields.join(', ')}`
|
|
|
|
// Add reminder for teammates when they complete a task (supports in-process teammates)
|
|
if (
|
|
statusChange?.to === 'completed' &&
|
|
getAgentId() &&
|
|
isAgentSwarmsEnabled()
|
|
) {
|
|
resultContent +=
|
|
'\n\nTask completed. Call TaskList now to find your next available task or see if your work unblocked others.'
|
|
}
|
|
|
|
if (verificationNudgeNeeded) {
|
|
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.`
|
|
}
|
|
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: resultContent,
|
|
}
|
|
},
|
|
} satisfies ToolDef<InputSchema, Output>)
|