basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7/**8 * task_update tool — update an existing task's fields.9 */10 11import type {12 ToolCallConfirmationDetails,13 ToolInfoConfirmationDetails,14 ToolInvocation,15 ToolResult,16} from './tools.js';17import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';18import { ToolNames, ToolDisplayNames } from './tool-names.js';19import type { Config } from '../config/config.js';20import type { PermissionDecision } from '../permissions/types.js';21import {22 getAgentName,23 isTeammate,24 resolveActiveTeamName,25} from '../agents/team/identity.js';26import {27 getPlanRequiredTeammatePreApprovalMessage,28 isPlanRequiredTeammatePreApprovalAllowedTool,29 isPlanRequiredTeammateAwaitingApproval,30} from '../agents/runtime/subagent-plan-tool-policy.js';31import {32 updateTask,33 deleteTask,34 assertValidTaskId,35 getTask,36 listTasks,37 TaskOwnershipError,38 RECIPROCAL_CALLER,39} from '../agents/team/tasks.js';40import type { SwarmTask } from '../agents/team/types.js';41import { truncateForConfirmation } from './task-create.js';42 43export interface TaskUpdateParams {44 taskId: string;45 status?: 'pending' | 'in_progress' | 'completed' | 'deleted';46 owner?: string;47 subject?: string;48 description?: string;49 activeForm?: string;50 metadata?: Record<string, unknown>;51 addBlocks?: string[];52 addBlockedBy?: string[];53}54 55/**56 * Detect whether adding the given edges to task `taskId` closes a57 * dependency cycle. Builds the adjacency from both `blocks` and58 * `blockedBy` (mirrored on disk, but a half-mirrored write window59 * must not hide an edge) plus the proposed edges, then walks the60 * "blocks" direction from `taskId`. Any new cycle necessarily passes61 * through `taskId`, so re-reaching it proves the cycle; the returned62 * path starts and ends at `taskId` for the error message.63 */64async function findDependencyCycle(65 teamName: string,66 taskId: string,67 addBlocks: string[],68 addBlockedBy: string[],69): Promise<string[] | null> {70 const tasks = await listTasks(teamName);71 const adjacency = new Map<string, Set<string>>();72 const edge = (from: string, to: string) => {73 let set = adjacency.get(from);74 if (!set) {75 set = new Set();76 adjacency.set(from, set);77 }78 set.add(to);79 };80 for (const task of tasks as SwarmTask[]) {81 for (const id of task.blocks) edge(task.id, id);82 for (const id of task.blockedBy) edge(id, task.id);83 }84 for (const id of addBlocks) edge(taskId, id);85 for (const id of addBlockedBy) edge(id, taskId);86 87 // Iterative DFS from taskId along "blocks" edges.88 const path: string[] = [];89 const visited = new Set<string>();90 const walk = (node: string): string[] | null => {91 path.push(node);92 for (const next of adjacency.get(node) ?? []) {93 if (next === taskId) return [...path, taskId];94 if (visited.has(next)) continue;95 visited.add(next);96 const found = walk(next);97 if (found) return found;98 }99 path.pop();100 return null;101 };102 visited.add(taskId);103 return walk(taskId);104}105 106class TaskUpdateInvocation extends BaseToolInvocation<107 TaskUpdateParams,108 ToolResult109> {110 constructor(111 private config: Config,112 params: TaskUpdateParams,113 ) {114 super(params);115 }116 117 getDescription(): string {118 const parts: string[] = [`Task #${this.params.taskId}`];119 if (this.params.status) {120 parts.push(`→ ${this.params.status}`);121 }122 if (this.params.owner) {123 parts.push(`owner: ${this.params.owner}`);124 }125 return parts.join(' ');126 }127 128 /**129 * Mutating a task's `subject`/`description` rewrites the prompt an idle130 * teammate will auto-claim and execute with full tool access — the same131 * privileged-sink shape as `send_message` and `task_create`. The base132 * default `'allow'` short-circuits the classifier in AUTO mode, so133 * override to `'ask'` to keep that injection path under the classifier /134 * human-in-the-loop.135 */136 override async getDefaultPermission(): Promise<PermissionDecision> {137 return 'ask';138 }139 140 /**141 * Surface the rewritten instruction text at approval time: an updated142 * `description` is what a claiming teammate will execute, so the143 * dialog must show it — getDescription()'s one-liner only carries144 * status/owner. See task-create.ts for the same rationale.145 */146 override getConfirmationDetails(147 _abortSignal: AbortSignal,148 ): Promise<ToolCallConfirmationDetails> {149 const lines = [this.getDescription()];150 if (this.params.subject !== undefined) {151 lines.push(`subject: ${this.params.subject}`);152 }153 if (this.params.addBlocks?.length) {154 lines.push(`blocks: ${this.params.addBlocks.join(', ')}`);155 }156 if (this.params.addBlockedBy?.length) {157 lines.push(`blocked by: ${this.params.addBlockedBy.join(', ')}`);158 }159 if (this.params.description !== undefined) {160 lines.push('', truncateForConfirmation(this.params.description));161 }162 const details: ToolInfoConfirmationDetails = {163 type: 'info',164 title: 'Confirm TaskUpdate',165 prompt: lines.join('\n'),166 onConfirm: async () => {167 // No-op: persistence is handled by coreToolScheduler via PM rules168 },169 };170 return Promise.resolve(details);171 }172 173 async execute(): Promise<ToolResult> {174 const awaitingPlanApproval = isPlanRequiredTeammateAwaitingApproval(175 this.config,176 );177 if (178 awaitingPlanApproval &&179 !isPlanRequiredTeammatePreApprovalAllowedTool(180 ToolNames.TASK_UPDATE,181 this.params,182 )183 ) {184 const msg = getPlanRequiredTeammatePreApprovalMessage(185 ToolNames.TASK_UPDATE,186 );187 return {188 llmContent: msg,189 returnDisplay: msg,190 error: { message: msg },191 };192 }193 194 const teamName = resolveActiveTeamName(195 this.config.getTeamContext()?.teamName,196 );197 if (!teamName) {198 const msg = 'No active team. Create a team first.';199 return {200 llmContent: msg,201 returnDisplay: msg,202 error: { message: msg },203 };204 }205 206 const { taskId } = this.params;207 208 // Validate every referenced ID up-front so an invalid id in209 // addBlocks / addBlockedBy rejects the whole call before we210 // mutate the primary task. Without this, a half-mirrored211 // dependency graph would be persisted (the primary update212 // succeeds, then the reciprocal updateTask throws on the bad213 // id) — exactly what the comment below the reciprocal block214 // says must not happen.215 try {216 assertValidTaskId(taskId);217 for (const id of this.params.addBlocks ?? []) {218 assertValidTaskId(id);219 }220 for (const id of this.params.addBlockedBy ?? []) {221 assertValidTaskId(id);222 }223 } catch (err) {224 const msg = err instanceof Error ? err.message : String(err);225 return {226 llmContent: msg,227 returnDisplay: msg,228 error: { message: msg },229 };230 }231 232 if (awaitingPlanApproval) {233 const existing = await getTask(teamName, taskId);234 if (existing && (existing.status !== 'pending' || existing.owner)) {235 const msg =236 'task_update can only claim an unowned pending task while this ' +237 'plan-required teammate is waiting for leader approval.';238 return {239 llmContent: msg,240 returnDisplay: msg,241 error: { message: msg },242 };243 }244 }245 246 // Ownership guard for non-leader callers is now enforced inside247 // `updateTask` under the per-task lock. Doing it pre-lock used to248 // race: two teammates could both observe an unowned task and pass,249 // then the second writer would silently overwrite the first one's250 // claim. We compute the caller name here and pass it through so251 // the in-lock check has the identity it needs.252 const teammateCallerName = isTeammate() ? getAgentName() : undefined;253 254 // status: 'deleted' → delete the task file.255 if (this.params.status === 'deleted') {256 let ok: boolean;257 try {258 ok = await deleteTask(259 teamName,260 taskId,261 teammateCallerName !== undefined262 ? { callerName: teammateCallerName }263 : undefined,264 );265 } catch (err) {266 if (err instanceof TaskOwnershipError) {267 return {268 llmContent: err.message,269 returnDisplay: err.message,270 error: { message: err.message },271 };272 }273 throw err;274 }275 if (!ok) {276 const msg = `Task #${taskId} not found.`;277 return {278 llmContent: msg,279 returnDisplay: msg,280 error: { message: msg },281 };282 }283 const msg = `Task #${taskId} deleted.`;284 return { llmContent: msg, returnDisplay: msg };285 }286 287 // Reject self-edges. They pass the existence check below (the288 // task plainly exists) and the reciprocal loops skip them, but289 // the primary `updateTask` would merge `taskId` into its own290 // `blockedBy` — and `tryAutoClaimTask` skips any task with a291 // non-empty `blockedBy`, so the task silently becomes292 // unclaimable forever (it can never complete to unblock itself).293 if (294 this.params.addBlocks?.includes(taskId) ||295 this.params.addBlockedBy?.includes(taskId)296 ) {297 const msg =298 `Cannot update task #${taskId}: a task cannot block ` +299 `or be blocked by itself.`;300 return {301 llmContent: msg,302 returnDisplay: msg,303 error: { message: msg },304 };305 }306 307 // Reject dependency edges that point at tasks which don't308 // exist yet. Without this the primary `updateTask` happily309 // persists the bad id into `blocks` / `blockedBy`, while the310 // reciprocal `updateTask` returns undefined silently — so311 // the tool reports success but the task is now permanently312 // blocked by a phantom id and auto-claim will never unblock313 // it.314 const referencedIds = new Set<string>();315 for (const id of this.params.addBlocks ?? []) {316 if (id !== taskId) referencedIds.add(id);317 }318 for (const id of this.params.addBlockedBy ?? []) {319 if (id !== taskId) referencedIds.add(id);320 }321 if (referencedIds.size > 0) {322 const missing: string[] = [];323 await Promise.all(324 Array.from(referencedIds).map(async (id) => {325 const t = await getTask(teamName, id);326 if (!t) missing.push(id);327 }),328 );329 if (missing.length > 0) {330 const ids = missing331 .sort()332 .map((id) => `#${id}`)333 .join(', ');334 const msg =335 `Cannot update task #${taskId}: ` +336 `referenced task${missing.length === 1 ? '' : 's'} ` +337 `${ids} not found.`;338 return {339 llmContent: msg,340 returnDisplay: msg,341 error: { message: msg },342 };343 }344 345 // Reject edges that would close a dependency cycle. Every task346 // on a cycle has a non-empty `blockedBy` that no completion can347 // ever clear, so auto-claim skips the whole ring forever with no348 // error surfaced anywhere. Best-effort (a concurrent task_update349 // could race in a conflicting edge between this check and the350 // write), but it catches the realistic case: one agent wiring up351 // a graph one call at a time.352 const cycle = await findDependencyCycle(353 teamName,354 taskId,355 this.params.addBlocks ?? [],356 this.params.addBlockedBy ?? [],357 );358 if (cycle) {359 const msg =360 `Cannot update task #${taskId}: this would create a ` +361 `dependency cycle (${cycle.map((id) => `#${id}`).join(' → ')}).`;362 return {363 llmContent: msg,364 returnDisplay: msg,365 error: { message: msg },366 };367 }368 }369 370 // Auto-assign owner on in_progress if caller doesn't371 // specify one. In the leader context getAgentName() is372 // undefined, so require an explicit owner to avoid373 // orphaning the task.374 const autoOwner =375 this.params.status === 'in_progress' && this.params.owner === undefined376 ? getAgentName()377 : undefined;378 379 if (380 this.params.status === 'in_progress' &&381 !this.params.owner &&382 !autoOwner383 ) {384 const msg =385 `Cannot move task #${taskId} to in_progress without ` +386 `an owner. Specify the "owner" parameter.`;387 return {388 llmContent: msg,389 returnDisplay: msg,390 error: { message: msg },391 };392 }393 394 let task;395 try {396 task = await updateTask(397 teamName,398 taskId,399 {400 status: this.params.status,401 owner: this.params.owner ?? autoOwner,402 subject: this.params.subject,403 description: this.params.description,404 activeForm: this.params.activeForm,405 metadata: this.params.metadata,406 addBlocks: this.params.addBlocks,407 addBlockedBy: this.params.addBlockedBy,408 },409 teammateCallerName !== undefined410 ? { callerName: teammateCallerName }411 : undefined,412 );413 } catch (err) {414 if (err instanceof TaskOwnershipError) {415 return {416 llmContent: err.message,417 returnDisplay: err.message,418 error: { message: err.message },419 };420 }421 throw err;422 }423 424 if (!task) {425 const msg = `Task #${taskId} not found.`;426 return {427 llmContent: msg,428 returnDisplay: msg,429 error: { message: msg },430 };431 }432 433 // Mirror dependency edges so auto-claim and completion-unblock434 // see a consistent graph: A.blocks=[B] implies B.blockedBy=[A]435 // and vice versa. Updating only one side leaves dependents either436 // permanently blocked or runnable too early.437 //438 // Exception: when this same call also completes the task, do NOT439 // mirror addBlocks into the dependents' blockedBy. A completed task440 // can't block anything, and the primary updateTask's441 // completion-unblock already ran (before this reciprocal), so it442 // couldn't clear an edge that didn't exist yet — adding it here443 // would leave the dependent permanently blocked by an already-444 // completed task (verified repro: task_update({status:'completed',445 // addBlocks:['X']}) left X blockedBy the just-completed task).446 // The reciprocal mirror must bypass the ownership guard (a teammate447 // editing its own task's edges has to touch the neighbor it points448 // at, which it may not own). Pass the RECIPROCAL_CALLER sentinel449 // rather than an empty callerName so the intentional bypass is450 // greppable in logs; it can never collide with a real teammate451 // identity (agent names are sanitized to [a-z0-9-]).452 const reciprocalUpdates: Array<Promise<unknown>> = [];453 if (this.params.addBlocks?.length && this.params.status !== 'completed') {454 for (const blockedId of this.params.addBlocks) {455 if (blockedId === taskId) continue;456 reciprocalUpdates.push(457 updateTask(458 teamName,459 blockedId,460 { addBlockedBy: [taskId] },461 { callerName: RECIPROCAL_CALLER },462 ),463 );464 }465 }466 if (this.params.addBlockedBy?.length) {467 for (const blockerId of this.params.addBlockedBy) {468 if (blockerId === taskId) continue;469 reciprocalUpdates.push(470 updateTask(471 teamName,472 blockerId,473 { addBlocks: [taskId] },474 { callerName: RECIPROCAL_CALLER },475 ),476 );477 }478 }479 if (reciprocalUpdates.length > 0) {480 await Promise.all(reciprocalUpdates);481 }482 483 const llmContent =484 `Task #${taskId} updated (status: ${task.status}` +485 (task.owner ? `, owner: ${task.owner}` : '') +486 ').';487 return { llmContent, returnDisplay: llmContent };488 }489}490 491export class TaskUpdateTool extends BaseDeclarativeTool<492 TaskUpdateParams,493 ToolResult494> {495 static readonly Name = ToolNames.TASK_UPDATE;496 497 constructor(private config: Config) {498 super(499 TaskUpdateTool.Name,500 ToolDisplayNames.TASK_UPDATE,501 'Update an existing task. Can change status, owner, ' +502 'subject, description, and blocking relationships. ' +503 'Set status to "deleted" to remove a task. ' +504 'Setting status to "in_progress" auto-assigns you ' +505 'as owner if no owner is set.',506 Kind.Other,507 {508 type: 'object',509 properties: {510 taskId: {511 type: 'string',512 description: 'ID of the task to update.',513 },514 status: {515 type: 'string',516 enum: ['pending', 'in_progress', 'completed', 'deleted'],517 description: 'New task status.',518 },519 owner: {520 type: 'string',521 description:522 'New owner agent name. ' + 'Set to empty string to unassign.',523 },524 subject: {525 type: 'string',526 maxLength: 200,527 description: 'Updated task title.',528 },529 description: {530 type: 'string',531 maxLength: 10000,532 description: 'Updated task description.',533 },534 activeForm: {535 type: 'string',536 maxLength: 200,537 description: 'Present tense label for UI.',538 },539 metadata: {540 type: 'object',541 description:542 'Metadata to merge. Set a key to null ' + 'to delete it.',543 },544 addBlocks: {545 type: 'array',546 items: { type: 'string' },547 description: 'Task IDs that this task blocks.',548 },549 addBlockedBy: {550 type: 'array',551 items: { type: 'string' },552 description: 'Task IDs that block this task.',553 },554 },555 required: ['taskId'],556 additionalProperties: false,557 },558 );559 }560 561 protected createInvocation(562 params: TaskUpdateParams,563 ): ToolInvocation<TaskUpdateParams, ToolResult> {564 return new TaskUpdateInvocation(this.config, params);565 }566 567 /**568 * Forward the mutating fields to the classifier. Without this the569 * base `''` sentinel projects to `task_update({})` and the AUTO570 * classifier rules on an empty call — the rewritten instruction571 * text and ownership/edge changes that `'ask'` exists to inspect572 * would be invisible to it. See task-create.ts / send-message.ts.573 */574 override toAutoClassifierInput(575 params: TaskUpdateParams,576 ): Record<string, unknown> {577 return {578 taskId: params.taskId,579 status: params.status,580 owner: params.owner,581 subject: params.subject,582 description: params.description,583 addBlocks: params.addBlocks,584 addBlockedBy: params.addBlockedBy,585 };586 }587}588 