@leclabs/agent-flow-navigator-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine.js ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Workflow Engine
3
+ *
4
+ * Evaluates DAG transitions based on node outputs and retry state.
5
+ *
6
+ * Schema:
7
+ * - nodes: { [id]: { type, name, maxRetries?, ... } }
8
+ * - edges: [{ from, to, on?, label? }]
9
+ *
10
+ * Retry logic convention:
11
+ * - maxRetries on the node defines how many retries are allowed
12
+ * - Edge to non-end node = retry (taken if retries remaining)
13
+ * - Edge to end node = escalation (taken if retries exhausted)
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "fs";
17
+
18
+ /**
19
+ * Read and parse a task file
20
+ * @param {string} taskFilePath - Path to task JSON file
21
+ * @returns {Object|null} Task object or null if not found/invalid
22
+ */
23
+ export function readTaskFile(taskFilePath) {
24
+ if (!taskFilePath || !existsSync(taskFilePath)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(taskFilePath, "utf-8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Check if a node is a terminal node (start or end)
34
+ */
35
+ export function isTerminalNode(node) {
36
+ if (!node) return false;
37
+ return node.type === "start" || node.type === "end";
38
+ }
39
+
40
+ /**
41
+ * Get terminal type for a node
42
+ * Returns: "start" | "success" | "hitl" | "failure" | null
43
+ */
44
+ export function getTerminalType(node) {
45
+ if (!node) return null;
46
+ if (node.type === "start") return "start";
47
+ if (node.type === "end") {
48
+ if (node.escalation === "hitl") return "hitl";
49
+ return node.result === "success" ? "success" : "failure";
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Convert agent ID to subagent reference
56
+ * e.g., "developer" -> "@flow:developer"
57
+ */
58
+ export function toSubagentRef(agentId) {
59
+ if (!agentId) return null;
60
+ if (agentId.startsWith("@")) return agentId;
61
+ return `@flow:${agentId}`;
62
+ }
63
+
64
+ /**
65
+ * Generate baseline instructions based on step type
66
+ */
67
+ export function getBaselineInstructions(stepId, stepName) {
68
+ const id = stepId.toLowerCase();
69
+ const name = (stepName || "").toLowerCase();
70
+
71
+ // Analysis/Planning steps
72
+ if (id.includes("analyze") || id.includes("analysis") || name.includes("analyze")) {
73
+ return "Review the task requirements carefully. Identify key constraints, dependencies, and acceptance criteria. Create a clear plan before proceeding.";
74
+ }
75
+ if (id.includes("plan") || id.includes("design") || name.includes("plan")) {
76
+ return "Design the solution architecture. Consider edge cases, error handling, and how this fits with existing code. Document your approach.";
77
+ }
78
+ if (id.includes("investigate") || id.includes("reproduce")) {
79
+ return "Gather evidence and understand the root cause. Document reproduction steps and any patterns observed.";
80
+ }
81
+
82
+ // Implementation steps
83
+ if (id.includes("implement") || id.includes("build") || id.includes("develop") || id.includes("fix")) {
84
+ return "Write clean, well-structured code following project conventions. Keep changes focused and minimal. Add comments only where the logic isn't self-evident.";
85
+ }
86
+ if (id.includes("refactor")) {
87
+ return "Improve code structure without changing behavior. Ensure all tests pass before and after changes.";
88
+ }
89
+
90
+ // Testing steps
91
+ if (id.includes("test") || id.includes("verify") || id.includes("validate")) {
92
+ return "Verify the implementation works correctly. Test happy paths, edge cases, and error conditions. Document any issues found.";
93
+ }
94
+
95
+ // Review steps
96
+ if (id.includes("review")) {
97
+ return "Check for correctness, code quality, and adherence to project standards. Verify the implementation meets requirements.";
98
+ }
99
+
100
+ // Documentation steps
101
+ if (id.includes("document") || id.includes("readme")) {
102
+ return "Write clear, concise documentation. Focus on what users need to know, not implementation details.";
103
+ }
104
+
105
+ // Commit/PR steps
106
+ if (id.includes("commit")) {
107
+ return "Stage relevant changes and create a descriptive commit message. Follow project commit conventions.";
108
+ }
109
+ if (id.includes("pr") || id.includes("pull_request") || id.includes("pull-request")) {
110
+ return "Create a pull request with a clear title and description. Link related issues and describe what was changed and why.";
111
+ }
112
+
113
+ // Context/optimization steps
114
+ if (id.includes("context") || id.includes("optimize") || id.includes("compress")) {
115
+ return "Analyze the current state and identify improvements. Focus on clarity and efficiency.";
116
+ }
117
+
118
+ // Extract/transform steps
119
+ if (id.includes("extract") || id.includes("ir_")) {
120
+ return "Extract the relevant information systematically. Preserve important details while filtering noise.";
121
+ }
122
+
123
+ // Default
124
+ return "Complete this step thoroughly. Document your findings and any decisions made.";
125
+ }
126
+
127
+ /**
128
+ * Build orchestrator instructions for task creation/update
129
+ * Returns null for terminal nodes (no further work)
130
+ */
131
+ function buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description) {
132
+ if (!stepInstructions) return null; // Terminal nodes have no instructions
133
+
134
+ const delegationPrefix = subagent ? `Invoke ${subagent} to complete the following task: ` : "";
135
+
136
+ return `${delegationPrefix}${stepInstructions.guidance}
137
+
138
+ ${description || "{task description}"}`;
139
+ }
140
+
141
+ /**
142
+ * Build unified response shape for Navigate
143
+ * Minimal output: only what Orchestrator needs for control flow and delegation
144
+ */
145
+ function buildNavigateResponse(
146
+ workflowType,
147
+ stepId,
148
+ stepDef,
149
+ action,
150
+ retriesIncremented = false,
151
+ retryCount = 0,
152
+ description = null
153
+ ) {
154
+ const stage = stepDef.stage || null;
155
+ const subagent = stepDef.agent ? toSubagentRef(stepDef.agent) : null;
156
+
157
+ // Build step instructions from workflow definition + baseline
158
+ const isTerminal = isTerminalNode(stepDef);
159
+ const stepInstructions = isTerminal
160
+ ? null
161
+ : {
162
+ name: stepDef.name || stepId,
163
+ description: stepDef.description || null,
164
+ guidance: stepDef.instructions || getBaselineInstructions(stepId, stepDef.name),
165
+ };
166
+
167
+ // Build orchestrator instructions for all non-terminal actions
168
+ const orchestratorInstructions = isTerminal
169
+ ? null
170
+ : buildOrchestratorInstructions(workflowType, stepId, stage, subagent, stepInstructions, description);
171
+
172
+ // Build metadata for task storage
173
+ const metadata = {
174
+ workflowType,
175
+ currentStep: stepId,
176
+ retryCount: retriesIncremented ? retryCount + 1 : retryCount,
177
+ };
178
+
179
+ return {
180
+ currentStep: stepId,
181
+ stage,
182
+ subagent,
183
+ stepInstructions,
184
+ terminal: getTerminalType(stepDef),
185
+ action,
186
+ retriesIncremented,
187
+ orchestratorInstructions,
188
+ metadata,
189
+ };
190
+ }
191
+
192
+ export class WorkflowEngine {
193
+ constructor(store) {
194
+ this.store = store;
195
+ }
196
+
197
+ /**
198
+ * Build adjacency list from edges array
199
+ */
200
+ buildEdgeGraph(workflowId) {
201
+ const def = this.store.getDefinition(workflowId);
202
+ if (!def) throw new Error(`Workflow ${workflowId} not found`);
203
+
204
+ if (!def.nodes) {
205
+ throw new Error(`Workflow ${workflowId} must have nodes`);
206
+ }
207
+
208
+ const graph = {
209
+ nodes: def.nodes,
210
+ edges: new Map(), // from -> [{ to, on, label }]
211
+ reverseEdges: new Map(), // to -> [from] for dependency checking
212
+ };
213
+
214
+ if (!def.edges || !Array.isArray(def.edges)) {
215
+ throw new Error(`Workflow ${workflowId} must have edges array`);
216
+ }
217
+
218
+ for (const edge of def.edges) {
219
+ const { from, to, on = null, label = null } = edge;
220
+
221
+ if (!graph.edges.has(from)) {
222
+ graph.edges.set(from, []);
223
+ }
224
+ graph.edges.get(from).push({ to, on, label });
225
+
226
+ if (!graph.reverseEdges.has(to)) {
227
+ graph.reverseEdges.set(to, []);
228
+ }
229
+ graph.reverseEdges.get(to).push(from);
230
+ }
231
+
232
+ return graph;
233
+ }
234
+
235
+ /**
236
+ * Check if a node is an end node
237
+ */
238
+ isEndNode(node) {
239
+ return node?.type === "end";
240
+ }
241
+
242
+ /**
243
+ * Evaluate which edge to take based on result and retry count
244
+ */
245
+ evaluateEdge(workflowId, currentStep, result, retryCount = 0) {
246
+ const graph = this.buildEdgeGraph(workflowId);
247
+ const outgoingEdges = graph.edges.get(currentStep) || [];
248
+ const currentNode = graph.nodes[currentStep];
249
+
250
+ if (outgoingEdges.length === 0) {
251
+ return {
252
+ nextStep: null,
253
+ action: "no_outgoing_edges",
254
+ currentStep,
255
+ };
256
+ }
257
+
258
+ const maxRetries = currentNode?.maxRetries || 0;
259
+ const unconditionalEdges = outgoingEdges.filter((e) => !e.on);
260
+ const matchingEdges = outgoingEdges.filter((e) => e.on === result);
261
+
262
+ // No result provided - use unconditional edge
263
+ if (!result && unconditionalEdges.length > 0) {
264
+ const edge = unconditionalEdges[0];
265
+ return {
266
+ nextStep: edge.to,
267
+ action: "unconditional",
268
+ edge,
269
+ };
270
+ }
271
+
272
+ // No matching edges at all
273
+ if (matchingEdges.length === 0 && unconditionalEdges.length === 0) {
274
+ return {
275
+ nextStep: null,
276
+ action: "no_matching_edge",
277
+ currentStep,
278
+ result,
279
+ retryCount,
280
+ availableEdges: outgoingEdges.map((e) => ({ to: e.to, on: e.on })),
281
+ };
282
+ }
283
+
284
+ // Separate retry edges (to non-end) from escalation edges (to end)
285
+ const retryEdges = matchingEdges.filter((e) => !this.isEndNode(graph.nodes[e.to]));
286
+ const escalateEdges = matchingEdges.filter((e) => this.isEndNode(graph.nodes[e.to]));
287
+
288
+ // Handle failed with both retry and escalation paths
289
+ if (result === "failed" && retryEdges.length > 0 && escalateEdges.length > 0) {
290
+ if (retryCount < maxRetries) {
291
+ const edge = retryEdges[0];
292
+ return {
293
+ nextStep: edge.to,
294
+ action: "retry",
295
+ retriesUsed: retryCount + 1,
296
+ retriesRemaining: maxRetries - retryCount - 1,
297
+ maxRetries,
298
+ edge,
299
+ };
300
+ } else {
301
+ const edge = escalateEdges[0];
302
+ return {
303
+ nextStep: edge.to,
304
+ action: "escalate",
305
+ reason: "max_retries_exceeded",
306
+ retriesUsed: retryCount,
307
+ maxRetries,
308
+ edge,
309
+ };
310
+ }
311
+ }
312
+
313
+ // Single matching conditional edge
314
+ if (matchingEdges.length > 0) {
315
+ const edge = matchingEdges[0];
316
+ return {
317
+ nextStep: edge.to,
318
+ action: "conditional",
319
+ condition: result,
320
+ edge,
321
+ };
322
+ }
323
+
324
+ // Fall back to unconditional
325
+ if (unconditionalEdges.length > 0) {
326
+ const edge = unconditionalEdges[0];
327
+ return {
328
+ nextStep: edge.to,
329
+ action: "unconditional",
330
+ edge,
331
+ };
332
+ }
333
+
334
+ return {
335
+ nextStep: null,
336
+ action: "no_matching_edge",
337
+ currentStep,
338
+ result,
339
+ retryCount,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Navigate through workflow: start, get current state, or advance
345
+ *
346
+ * Two calling modes:
347
+ * 1. Task file mode: Pass taskFilePath to read workflow state from task metadata
348
+ * 2. Direct mode: Pass workflowType directly (for starting workflows)
349
+ *
350
+ * @param {Object} options - Navigation options
351
+ * @param {string} [options.taskFilePath] - Path to task file (for advance/current)
352
+ * @param {string} [options.workflowType] - Workflow ID (for start only)
353
+ * @param {string} [options.result] - Step result: "passed" | "failed" (for advance)
354
+ * @param {string} [options.description] - User's task description
355
+ * @returns {Object} Navigation response with currentStep, stepInstructions, terminal, action, metadata, etc.
356
+ */
357
+ navigate({ taskFilePath, workflowType, result, description } = {}) {
358
+ let currentStep = null;
359
+ let retryCount = 0;
360
+
361
+ // Task file mode: read workflow state from task metadata
362
+ if (taskFilePath) {
363
+ const task = readTaskFile(taskFilePath);
364
+ if (!task) {
365
+ throw new Error(`Task file not found: ${taskFilePath}`);
366
+ }
367
+ if (!task.metadata) {
368
+ throw new Error("Task has no metadata");
369
+ }
370
+
371
+ const {
372
+ userDescription,
373
+ workflowType: metaWorkflow,
374
+ currentStep: metaStep,
375
+ retryCount: metaRetry = 0,
376
+ } = task.metadata;
377
+ workflowType = metaWorkflow;
378
+ currentStep = metaStep;
379
+ retryCount = metaRetry;
380
+ description = description || userDescription;
381
+ }
382
+
383
+ // Validate workflowType
384
+ if (!workflowType) {
385
+ throw new Error("workflowType is required (either directly or via task metadata)");
386
+ }
387
+
388
+ const wfDef = this.store.getDefinition(workflowType);
389
+ if (!wfDef) {
390
+ throw new Error(`Workflow '${workflowType}' not found. Use ListWorkflows to see available workflows.`);
391
+ }
392
+
393
+ if (!wfDef.nodes) {
394
+ throw new Error(`Workflow '${workflowType}' must have nodes`);
395
+ }
396
+
397
+ const { nodes } = wfDef;
398
+
399
+ // Case 1: No currentStep - start at first work step
400
+ if (!currentStep) {
401
+ const startEntry = Object.entries(nodes).find(([, node]) => node.type === "start");
402
+ if (!startEntry) {
403
+ throw new Error(`Workflow '${workflowType}' has no start node`);
404
+ }
405
+ const startStepId = startEntry[0];
406
+
407
+ const firstEdge = wfDef.edges.find((e) => e.from === startStepId);
408
+ if (!firstEdge) {
409
+ throw new Error(`No edge from start step in workflow '${workflowType}'`);
410
+ }
411
+
412
+ const firstStepDef = nodes[firstEdge.to];
413
+ if (!firstStepDef) {
414
+ throw new Error(`First step '${firstEdge.to}' not found in workflow`);
415
+ }
416
+
417
+ return buildNavigateResponse(workflowType, firstEdge.to, firstStepDef, "start", false, 0, description);
418
+ }
419
+
420
+ // Case 2: currentStep but no result - return current state
421
+ if (!result) {
422
+ const stepDef = nodes[currentStep];
423
+ if (!stepDef) {
424
+ throw new Error(`Step '${currentStep}' not found in workflow '${workflowType}'`);
425
+ }
426
+
427
+ return buildNavigateResponse(workflowType, currentStep, stepDef, "current", false, retryCount, description);
428
+ }
429
+
430
+ // Case 3: currentStep and result - advance to next step
431
+ const evaluation = this.evaluateEdge(workflowType, currentStep, result, retryCount);
432
+
433
+ if (!evaluation.nextStep) {
434
+ return {
435
+ error: `No matching edge from '${currentStep}' with result '${result}'`,
436
+ currentStep,
437
+ evaluation,
438
+ };
439
+ }
440
+
441
+ const nextStepDef = nodes[evaluation.nextStep];
442
+ if (!nextStepDef) {
443
+ throw new Error(`Next step '${evaluation.nextStep}' not found in workflow`);
444
+ }
445
+
446
+ // Determine action and whether retries incremented
447
+ const isRetry = evaluation.action === "retry";
448
+ let action;
449
+ if (isRetry) {
450
+ action = "retry";
451
+ } else if (getTerminalType(nextStepDef) === "hitl") {
452
+ action = "escalate";
453
+ } else {
454
+ action = "advance";
455
+ }
456
+
457
+ return buildNavigateResponse(
458
+ workflowType,
459
+ evaluation.nextStep,
460
+ nextStepDef,
461
+ action,
462
+ isRetry,
463
+ retryCount,
464
+ description
465
+ );
466
+ }
467
+ }