diff --git a/packages/agent-tools/src/copilot/index.ts b/packages/agent-tools/src/copilot/index.ts index 544c3c25c..7a2be82b1 100644 --- a/packages/agent-tools/src/copilot/index.ts +++ b/packages/agent-tools/src/copilot/index.ts @@ -22,6 +22,74 @@ interface CopilotToolParams { reflyService: ReflyService; } +// ============================================================================ +// Canvas Drift Detection +// ============================================================================ + +interface CanvasDrift { + hasDrift: boolean; + planTaskCount: number; + canvasWorkflowNodeCount: number; + missingFromCanvas: Array<{ taskId: string; title: string }>; + addedOnCanvas: Array<{ nodeId: string; title: string; taskId?: string }>; + summary: string; +} + +function computeCanvasDrift(plan: WorkflowPlanRecord, canvasNodes: CanvasNode[]): CanvasDrift { + const planTaskIds = new Set((plan.tasks ?? []).map((t) => t.id)); + + const canvasTaskIds = new Map(); + const canvasWorkflowNodes: CanvasNode[] = []; + + for (const node of canvasNodes) { + if (node.type === 'skillResponse') { + canvasWorkflowNodes.push(node); + const taskId = (node.data?.metadata as Record)?.taskId as string | undefined; + if (taskId) { + canvasTaskIds.set(taskId, node); + } + } + } + + const missingFromCanvas = (plan.tasks ?? []) + .filter((t) => !canvasTaskIds.has(t.id)) + .map((t) => ({ taskId: t.id, title: t.title })); + + const addedOnCanvas = canvasWorkflowNodes + .filter((n) => { + const taskId = (n.data?.metadata as Record)?.taskId as string | undefined; + return !taskId || !planTaskIds.has(taskId); + }) + .map((n) => ({ + nodeId: n.id, + title: n.data?.title ?? '', + taskId: (n.data?.metadata as Record)?.taskId as string | undefined, + })); + + const hasDrift = missingFromCanvas.length > 0 || addedOnCanvas.length > 0; + + let summary = 'Plan and canvas are in sync.'; + if (hasDrift) { + const parts: string[] = []; + if (missingFromCanvas.length > 0) { + parts.push(`${missingFromCanvas.length} plan task(s) removed from canvas`); + } + if (addedOnCanvas.length > 0) { + parts.push(`${addedOnCanvas.length} node(s) added to canvas outside plan`); + } + summary = `Drift detected: ${parts.join('; ')}.`; + } + + return { + hasDrift, + planTaskCount: plan.tasks?.length ?? 0, + canvasWorkflowNodeCount: canvasWorkflowNodes.length, + missingFromCanvas, + addedOnCanvas, + summary, + }; +} + export class GenerateWorkflow extends AgentBaseTool { name = 'generate_workflow'; toolsetKey = 'copilot'; @@ -171,6 +239,40 @@ Notes: planId = latestPlan.planId; } + // Drift pre-check: ensure targeted tasks still exist on canvas + const canvasId = config.configurable?.canvasId; + if (canvasId) { + try { + const canvasData = await reflyService.getCanvasData(user, { canvasId }); + const canvasTaskIds = new Set(); + for (const node of canvasData.nodes ?? []) { + if (node.type === 'skillResponse') { + const tid = (node.data?.metadata as Record)?.taskId as + | string + | undefined; + if (tid) canvasTaskIds.add(tid); + } + } + + const taskOps = input.operations.filter( + (op) => (op.op === 'updateTask' || op.op === 'deleteTask') && op.taskId, + ); + const staleTaskOps = taskOps.filter((op) => !canvasTaskIds.has(op.taskId!)); + + if (staleTaskOps.length > 0) { + return { + status: 'error', + data: { + error: `Cannot patch: ${staleTaskOps.length} targeted task(s) no longer exist on canvas. Missing task IDs: ${staleTaskOps.map((op) => op.taskId).join(', ')}. The canvas may have been manually modified. Call get_workflow_summary to see drift details, or use generate_workflow to recreate.`, + }, + summary: 'Patch failed due to canvas drift', + }; + } + } catch { + // Non-critical: proceed with patch even if drift check fails + } + } + const { resultId, version: resultVersion } = config.configurable ?? {}; if (!resultId || typeof resultVersion !== 'number') { @@ -279,6 +381,18 @@ Use this tool when you need to: }; } + // Drift detection: compare plan tasks with actual canvas nodes + const canvasId = config.configurable?.canvasId; + let canvasDrift: CanvasDrift | undefined; + if (canvasId) { + try { + const canvasData = await this.params.reflyService.getCanvasData(user, { canvasId }); + canvasDrift = computeCanvasDrift(plan, canvasData.nodes ?? []); + } catch { + // Non-critical: proceed without drift info + } + } + return { status: 'success', data: { @@ -300,8 +414,20 @@ Use this tool when you need to: variableType: v.variableType, required: v.required, })), + ...(canvasDrift && { + canvasDrift: { + hasDrift: canvasDrift.hasDrift, + summary: canvasDrift.summary, + ...(canvasDrift.hasDrift && { + missingFromCanvas: canvasDrift.missingFromCanvas, + addedOnCanvas: canvasDrift.addedOnCanvas, + }), + }, + }), }, - summary: `Successfully retrieved workflow plan summary for plan ID: ${plan.planId} and version: ${plan.version}`, + summary: canvasDrift?.hasDrift + ? `Retrieved workflow plan (${plan.planId} v${plan.version}). WARNING: ${canvasDrift.summary}` + : `Successfully retrieved workflow plan summary for plan ID: ${plan.planId} and version: ${plan.version}`, }; } catch (e) { return { diff --git a/packages/skill-template/src/prompts/templates/copilot-agent-system.md b/packages/skill-template/src/prompts/templates/copilot-agent-system.md index 496c84bf8..6a6a6ad89 100644 --- a/packages/skill-template/src/prompts/templates/copilot-agent-system.md +++ b/packages/skill-template/src/prompts/templates/copilot-agent-system.md @@ -38,10 +38,11 @@ Default: **Conversational Workflow Design** | Need to recall task/variable IDs | `get_workflow_summary` | Retrieve current plan structure | | Long conversation, uncertain of current state | `get_workflow_summary` | Refresh context before patching | | `get_workflow_summary` returns no plan | `get_canvas_snapshot` | See actual canvas nodes when no plan exists | +| `get_workflow_summary` reports drift (`canvasDrift.hasDrift: true`) | `get_canvas_snapshot` | Canvas was manually modified; see full state to reconcile | | User asks about canvas content | `get_canvas_snapshot` | View real-time canvas nodes and edges | | Need to understand canvas before generating | `get_canvas_snapshot` | Get node details (query, toolsets) for accurate workflow design | -**Default Preference**: Use `patch_workflow` when an existing workflow plan exists and user requests specific modifications. Use `generate_workflow` for new workflows or major restructuring. Use `get_workflow_summary` when you need to verify task/variable IDs before making changes. Use `get_canvas_snapshot` when `get_workflow_summary` returns no plan or when you need to see the actual canvas state. +**Default Preference**: Use `patch_workflow` when an existing workflow plan exists and user requests specific modifications. Use `generate_workflow` for new workflows or major restructuring. Use `get_workflow_summary` when you need to verify task/variable IDs before making changes. Use `get_canvas_snapshot` when `get_workflow_summary` returns no plan, when drift is detected (`canvasDrift.hasDrift` is true), or when you need to see the actual canvas state. ### Image Understanding for Workflow Design @@ -336,15 +337,22 @@ The tool returns: - Plan ID and version - All tasks with IDs, titles, dependencies, and toolsets - All variables with IDs, names, types, and required status +- Canvas drift status: `canvasDrift.hasDrift` (boolean), `canvasDrift.summary` (description of differences) +- If drift detected: `canvasDrift.missingFromCanvas` (plan tasks no longer on canvas) and `canvasDrift.addedOnCanvas` (canvas nodes not in plan) **Note**: You don't need to call this tool if you just created or patched the workflow in recent turns — use the returned data from those operations instead. -**IMPORTANT Fallback**: If `get_workflow_summary` returns `{ exists: false }` (no workflow plan), **immediately call `get_canvas_snapshot`** to see the actual canvas nodes. The canvas may have nodes that were created manually or in a previous session. Use the snapshot data to understand the current canvas state, then use `generate_workflow` to create or modify the workflow based on what you see. +**IMPORTANT — Handling Drift and Missing Plans**: +- If `get_workflow_summary` returns `{ exists: false }` (no workflow plan), **immediately call `get_canvas_snapshot`** to see the actual canvas nodes. The canvas may have nodes that were created manually or in a previous session. Use the snapshot data to understand the current canvas state, then use `generate_workflow` to create or modify the workflow based on what you see. +- If `get_workflow_summary` returns a plan with `canvasDrift.hasDrift: true`, the canvas has been manually modified and the plan is stale: + - For **minor drift** (1-2 items in missingFromCanvas or addedOnCanvas): Call `get_canvas_snapshot` to see the full state, then use `patch_workflow` to reconcile or `generate_workflow` to recreate. + - For **major drift** (most plan tasks missing or many untracked nodes): Call `get_canvas_snapshot` to see the full canvas, then use `generate_workflow` to create a fresh plan that matches the current canvas state. ## get_canvas_snapshot Usage Call `get_canvas_snapshot` when: - `get_workflow_summary` returns no plan (fallback to see actual canvas) +- `get_workflow_summary` reports drift (`canvasDrift.hasDrift: true`) — to see the full current canvas state - User asks what's on the canvas - You need to understand the canvas layout before designing a workflow