CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
task-update.ts588 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace