Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion packages/agent-tools/src/copilot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CanvasNode>();
const canvasWorkflowNodes: CanvasNode[] = [];

for (const node of canvasNodes) {
if (node.type === 'skillResponse') {
canvasWorkflowNodes.push(node);
const taskId = (node.data?.metadata as Record<string, unknown>)?.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<string, unknown>)?.taskId as string | undefined;
return !taskId || !planTaskIds.has(taskId);
})
.map((n) => ({
nodeId: n.id,
title: n.data?.title ?? '',
taskId: (n.data?.metadata as Record<string, unknown>)?.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<CopilotToolParams> {
name = 'generate_workflow';
toolsetKey = 'copilot';
Expand Down Expand Up @@ -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<string>();
for (const node of canvasData.nodes ?? []) {
if (node.type === 'skillResponse') {
const tid = (node.data?.metadata as Record<string, unknown>)?.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') {
Expand Down Expand Up @@ -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: {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down