agent-state-machine 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/README.md +299 -0
- package/bin/cli.js +246 -0
- package/lib/index.js +110 -0
- package/lib/index.mjs +9 -0
- package/lib/llm.js +472 -0
- package/lib/runtime/agent.js +359 -0
- package/lib/runtime/index.js +35 -0
- package/lib/runtime/memory.js +88 -0
- package/lib/runtime/parallel.js +66 -0
- package/lib/runtime/prompt.js +118 -0
- package/lib/runtime/runtime.js +387 -0
- package/lib/setup.js +398 -0
- package/lib/state-machine.js +1359 -0
- package/package.json +32 -0
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /lib/state-machine.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import readline from 'readline';
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
// State machine states
|
|
14
|
+
const States = {
|
|
15
|
+
IDLE: 'IDLE',
|
|
16
|
+
RUNNING: 'RUNNING',
|
|
17
|
+
STEP_EXECUTING: 'STEP_EXECUTING',
|
|
18
|
+
STEP_COMPLETED: 'STEP_COMPLETED',
|
|
19
|
+
STEP_FAILED: 'STEP_FAILED',
|
|
20
|
+
WORKFLOW_COMPLETED: 'WORKFLOW_COMPLETED',
|
|
21
|
+
WORKFLOW_FAILED: 'WORKFLOW_FAILED',
|
|
22
|
+
PAUSED: 'PAUSED'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Built-in agents
|
|
26
|
+
const BUILTIN_AGENTS = {
|
|
27
|
+
echo: async (context) => {
|
|
28
|
+
console.log('[Agent: echo] Context:', JSON.stringify(context, null, 2));
|
|
29
|
+
return { ...context, echoed: true };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
transform: async (context) => {
|
|
33
|
+
console.log('[Agent: transform] Processing...');
|
|
34
|
+
return {
|
|
35
|
+
...context,
|
|
36
|
+
transformed: true,
|
|
37
|
+
transformedAt: new Date().toISOString()
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
validate: async (context) => {
|
|
42
|
+
console.log('[Agent: validate] Validating...');
|
|
43
|
+
const isValid = context !== null && typeof context === 'object';
|
|
44
|
+
return { ...context, validated: isValid };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
log: async (context) => {
|
|
48
|
+
console.log('[Agent: log]', JSON.stringify(context, null, 2));
|
|
49
|
+
return context;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
delay: async (context) => {
|
|
53
|
+
const ms = context._delay || 1000;
|
|
54
|
+
console.log(`[Agent: delay] Waiting ${ms}ms...`);
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
56
|
+
return context;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
class StateMachine {
|
|
61
|
+
constructor(workflowName) {
|
|
62
|
+
this.workflowName = workflowName;
|
|
63
|
+
this.workflowsDir = path.join(process.cwd(), 'workflows');
|
|
64
|
+
this.workflowDir = workflowName
|
|
65
|
+
? path.join(this.workflowsDir, workflowName)
|
|
66
|
+
: null;
|
|
67
|
+
this.customAgents = {};
|
|
68
|
+
this.loop = {
|
|
69
|
+
count: 0,
|
|
70
|
+
stepCounts: {}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get stateDir() {
|
|
75
|
+
return this.workflowDir ? path.join(this.workflowDir, 'state') : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get interactionsDir() {
|
|
79
|
+
return this.workflowDir ? path.join(this.workflowDir, 'interactions') : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get currentStateFile() {
|
|
83
|
+
return this.stateDir ? path.join(this.stateDir, 'current.json') : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get historyFile() {
|
|
87
|
+
return this.stateDir ? path.join(this.stateDir, 'history.jsonl') : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get workflowFile() {
|
|
91
|
+
return this.workflowDir ? path.join(this.workflowDir, 'workflow.js') : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get agentsDir() {
|
|
95
|
+
return this.workflowDir ? path.join(this.workflowDir, 'agents') : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get scriptsDir() {
|
|
99
|
+
return this.workflowDir ? path.join(this.workflowDir, 'scripts') : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get steeringDir() {
|
|
103
|
+
return this.workflowDir ? path.join(this.workflowDir, 'steering') : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensure workflow exists
|
|
108
|
+
*/
|
|
109
|
+
ensureWorkflow() {
|
|
110
|
+
if (!this.workflowName) {
|
|
111
|
+
throw new Error('No workflow name specified');
|
|
112
|
+
}
|
|
113
|
+
if (!fs.existsSync(this.workflowDir)) {
|
|
114
|
+
throw new Error(`Workflow '${this.workflowName}' not found. Run 'state-machine --setup ${this.workflowName}' first.`);
|
|
115
|
+
}
|
|
116
|
+
if (!fs.existsSync(this.workflowFile)) {
|
|
117
|
+
throw new Error(`workflow.js not found in ${this.workflowDir}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load workflow configuration from workflow.js
|
|
123
|
+
*/
|
|
124
|
+
loadWorkflowConfig() {
|
|
125
|
+
this.ensureWorkflow();
|
|
126
|
+
// Clear require cache to allow hot reloading
|
|
127
|
+
delete require.cache[require.resolve(this.workflowFile)];
|
|
128
|
+
return require(this.workflowFile);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Load current state
|
|
133
|
+
*/
|
|
134
|
+
loadCurrentState() {
|
|
135
|
+
if (fs.existsSync(this.currentStateFile)) {
|
|
136
|
+
return JSON.parse(fs.readFileSync(this.currentStateFile, 'utf-8'));
|
|
137
|
+
}
|
|
138
|
+
return this.createInitialState();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create initial state
|
|
143
|
+
*/
|
|
144
|
+
createInitialState() {
|
|
145
|
+
return {
|
|
146
|
+
status: States.IDLE,
|
|
147
|
+
workflow: null,
|
|
148
|
+
currentStepIndex: 0,
|
|
149
|
+
context: {},
|
|
150
|
+
loop: {
|
|
151
|
+
count: 0,
|
|
152
|
+
stepCounts: {}
|
|
153
|
+
},
|
|
154
|
+
startedAt: null,
|
|
155
|
+
lastUpdatedAt: null,
|
|
156
|
+
error: null,
|
|
157
|
+
pendingInteraction: null
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
sanitizeInteractionSlug(slug) {
|
|
162
|
+
const raw = String(slug || '').trim();
|
|
163
|
+
const withoutExt = raw.toLowerCase().endsWith('.md') ? raw.slice(0, -3) : raw;
|
|
164
|
+
const sanitized = withoutExt
|
|
165
|
+
.trim()
|
|
166
|
+
.replace(/[\\/]+/g, '-')
|
|
167
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
168
|
+
.replace(/-+/g, '-')
|
|
169
|
+
.replace(/^-|-$/g, '');
|
|
170
|
+
return sanitized || 'interaction';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ensureInteractionsDir() {
|
|
174
|
+
if (!this.interactionsDir) return;
|
|
175
|
+
if (!fs.existsSync(this.interactionsDir)) {
|
|
176
|
+
fs.mkdirSync(this.interactionsDir, { recursive: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async promptYesToContinue() {
|
|
181
|
+
if (!process.stdin.isTTY) {
|
|
182
|
+
console.log('stdin is not a TTY; edit the interaction file and run `state-machine resume <workflow>` to continue.');
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
187
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
188
|
+
try {
|
|
189
|
+
while (true) {
|
|
190
|
+
const answer = String(await ask("Type 'y' to continue (or 'q' to stop): ")).trim().toLowerCase();
|
|
191
|
+
if (answer === 'y' || answer === 'yes') return true;
|
|
192
|
+
if (answer === 'q' || answer === 'quit' || answer === 'n' || answer === 'no') return false;
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
rl.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async handleInteractionIfNeeded(context, state, workflowName, stepName, stepIndex) {
|
|
200
|
+
const interaction = context?._interaction;
|
|
201
|
+
if (!interaction) return context;
|
|
202
|
+
|
|
203
|
+
const slug = this.sanitizeInteractionSlug(interaction.slug || interaction.key || interaction.targetKey || stepName);
|
|
204
|
+
const targetKey = String(interaction.targetKey || interaction.key || slug);
|
|
205
|
+
const initialContent = String(interaction.content ?? '');
|
|
206
|
+
|
|
207
|
+
this.ensureInteractionsDir();
|
|
208
|
+
const filePath = path.join(this.interactionsDir, `${slug}.md`);
|
|
209
|
+
const relativePath = path.relative(this.workflowDir, filePath);
|
|
210
|
+
const instructions = `Enter your response here. Its okay to delete all content and leave only your response.`;
|
|
211
|
+
const fileExists = fs.existsSync(filePath);
|
|
212
|
+
const interactionContent = fileExists ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
213
|
+
|
|
214
|
+
if (!fileExists || interactionContent.trim() === '') {
|
|
215
|
+
fs.writeFileSync(filePath, initialContent || `# ${slug}\n\n${instructions}\n`);
|
|
216
|
+
} else {
|
|
217
|
+
fs.writeFileSync(filePath, initialContent + '\n\n' + instructions + '\n\n' + interactionContent);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
state.status = States.PAUSED;
|
|
221
|
+
state.pendingInteraction = {
|
|
222
|
+
slug,
|
|
223
|
+
targetKey,
|
|
224
|
+
file: relativePath,
|
|
225
|
+
stepIndex,
|
|
226
|
+
step: stepName
|
|
227
|
+
};
|
|
228
|
+
this.saveCurrentState(state);
|
|
229
|
+
|
|
230
|
+
this.prependHistory({
|
|
231
|
+
event: 'INTERACTION_REQUESTED',
|
|
232
|
+
workflow: workflowName,
|
|
233
|
+
step: stepName,
|
|
234
|
+
stepIndex,
|
|
235
|
+
slug,
|
|
236
|
+
file: relativePath,
|
|
237
|
+
targetKey
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
console.log(`\n⏸ Interaction required: ${relativePath}`);
|
|
241
|
+
console.log(`The workflow is paused at step ${stepIndex + 1}.`);
|
|
242
|
+
console.log(`After editing, ${targetKey} will be set to the file contents in context.`);
|
|
243
|
+
const continued = await this.promptYesToContinue();
|
|
244
|
+
|
|
245
|
+
if (!continued) {
|
|
246
|
+
console.log(`Workflow paused. Resume with: state-machine resume ${this.workflowName}`);
|
|
247
|
+
return context;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const finalContent = fs.readFileSync(filePath, 'utf-8');
|
|
251
|
+
const nextContext = { ...context, [targetKey]: finalContent };
|
|
252
|
+
delete nextContext._interaction;
|
|
253
|
+
|
|
254
|
+
state.context = nextContext;
|
|
255
|
+
state.status = States.RUNNING;
|
|
256
|
+
state.pendingInteraction = null;
|
|
257
|
+
this.saveCurrentState(state);
|
|
258
|
+
|
|
259
|
+
this.prependHistory({
|
|
260
|
+
event: 'INTERACTION_RESOLVED',
|
|
261
|
+
workflow: workflowName,
|
|
262
|
+
step: stepName,
|
|
263
|
+
stepIndex,
|
|
264
|
+
slug,
|
|
265
|
+
file: relativePath,
|
|
266
|
+
targetKey
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return nextContext;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Save current state
|
|
274
|
+
*/
|
|
275
|
+
saveCurrentState(state) {
|
|
276
|
+
state.lastUpdatedAt = new Date().toISOString();
|
|
277
|
+
state.loop = this.loop;
|
|
278
|
+
fs.writeFileSync(this.currentStateFile, JSON.stringify(state, null, 2));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Prepend to history
|
|
283
|
+
*/
|
|
284
|
+
prependHistory(entry) {
|
|
285
|
+
const historyEntry = {
|
|
286
|
+
...entry,
|
|
287
|
+
timestamp: new Date().toISOString()
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const line = JSON.stringify(historyEntry) + '\n';
|
|
291
|
+
|
|
292
|
+
let existing = '';
|
|
293
|
+
if (fs.existsSync(this.historyFile)) {
|
|
294
|
+
existing = fs.readFileSync(this.historyFile, 'utf8');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fs.writeFileSync(this.historyFile, line + existing, 'utf8');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Load history
|
|
302
|
+
*/
|
|
303
|
+
loadHistory() {
|
|
304
|
+
if (!fs.existsSync(this.historyFile)) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const content = fs.readFileSync(this.historyFile, 'utf-8');
|
|
308
|
+
return content
|
|
309
|
+
.split('\n')
|
|
310
|
+
.filter(line => line.trim())
|
|
311
|
+
.map(line => JSON.parse(line));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Load custom agents from workflow's agents directory
|
|
316
|
+
* Supports both .js (code) and .md (prompt-based) agents
|
|
317
|
+
*/
|
|
318
|
+
loadCustomAgents() {
|
|
319
|
+
if (!fs.existsSync(this.agentsDir)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const files = fs.readdirSync(this.agentsDir);
|
|
324
|
+
for (const file of files) {
|
|
325
|
+
if (file.endsWith('.js')) {
|
|
326
|
+
// JavaScript agent
|
|
327
|
+
const agentName = path.basename(file, '.js');
|
|
328
|
+
const agentPath = path.join(this.agentsDir, file);
|
|
329
|
+
try {
|
|
330
|
+
// Clear require cache
|
|
331
|
+
delete require.cache[require.resolve(agentPath)];
|
|
332
|
+
const agentModule = require(agentPath);
|
|
333
|
+
const handler = agentModule.handler || agentModule.default || agentModule;
|
|
334
|
+
if (typeof handler === 'function') {
|
|
335
|
+
this.customAgents[agentName] = handler;
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.warn(`Warning: Failed to load agent '${agentName}': ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
} else if (file.endsWith('.md')) {
|
|
341
|
+
// Markdown prompt agent
|
|
342
|
+
const agentName = path.basename(file, '.md');
|
|
343
|
+
const agentPath = path.join(this.agentsDir, file);
|
|
344
|
+
try {
|
|
345
|
+
const promptContent = fs.readFileSync(agentPath, 'utf-8');
|
|
346
|
+
// Parse frontmatter if present
|
|
347
|
+
const { config, prompt } = this.parseMarkdownAgent(promptContent);
|
|
348
|
+
this.customAgents[agentName] = this.createMarkdownAgentHandler(agentName, prompt, config);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.warn(`Warning: Failed to load markdown agent '${agentName}': ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Parse markdown agent file - supports YAML frontmatter
|
|
358
|
+
*/
|
|
359
|
+
parseMarkdownAgent(content) {
|
|
360
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
361
|
+
|
|
362
|
+
if (frontmatterMatch) {
|
|
363
|
+
// Parse simple YAML-like frontmatter
|
|
364
|
+
const frontmatter = frontmatterMatch[1];
|
|
365
|
+
const prompt = frontmatterMatch[2].trim();
|
|
366
|
+
|
|
367
|
+
const config = {};
|
|
368
|
+
frontmatter.split('\n').forEach(line => {
|
|
369
|
+
const [key, ...valueParts] = line.split(':');
|
|
370
|
+
if (key && valueParts.length) {
|
|
371
|
+
const value = valueParts.join(':').trim();
|
|
372
|
+
// Remove quotes if present
|
|
373
|
+
config[key.trim()] = value.replace(/^["']|["']$/g, '');
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return { config, prompt };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { config: {}, prompt: content.trim() };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Create a handler function for markdown-based agents
|
|
385
|
+
*/
|
|
386
|
+
createMarkdownAgentHandler(agentName, promptTemplate, config) {
|
|
387
|
+
const self = this;
|
|
388
|
+
|
|
389
|
+
return async function markdownAgentHandler(context) {
|
|
390
|
+
const { llm } = require('./llm');
|
|
391
|
+
|
|
392
|
+
const getByPath = (obj, pathStr) => {
|
|
393
|
+
const trimmed = String(pathStr || '').trim();
|
|
394
|
+
if (!trimmed) return undefined;
|
|
395
|
+
const normalized = trimmed.startsWith('context.') ? trimmed.slice('context.'.length) : trimmed;
|
|
396
|
+
const parts = normalized.split('.').filter(Boolean);
|
|
397
|
+
let current = obj;
|
|
398
|
+
for (const part of parts) {
|
|
399
|
+
if (current == null) return undefined;
|
|
400
|
+
current = current[String(part)];
|
|
401
|
+
}
|
|
402
|
+
return current;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Interpolate context variables in prompt using {{path}} syntax (supports dots and hyphens).
|
|
406
|
+
const prompt = promptTemplate.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, pathStr) => {
|
|
407
|
+
const value = getByPath(context, pathStr);
|
|
408
|
+
return value !== undefined ? String(value) : match;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const model = config.model || 'fast';
|
|
412
|
+
const outputKey = config.output || 'result';
|
|
413
|
+
|
|
414
|
+
console.log(` [MD Agent: ${agentName}] Using model: ${model}`);
|
|
415
|
+
|
|
416
|
+
const response = await llm(context, {
|
|
417
|
+
model: model,
|
|
418
|
+
prompt: prompt,
|
|
419
|
+
includeContext: config.includeContext !== 'false'
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Parse output based on config
|
|
423
|
+
let output = response.text;
|
|
424
|
+
if (config.format === 'json') {
|
|
425
|
+
try {
|
|
426
|
+
const { parseJSON } = require('./llm');
|
|
427
|
+
output = parseJSON(response.text);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
console.warn(` [MD Agent: ${agentName}] Failed to parse JSON output`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const { parseInteractionRequest } = require('./llm');
|
|
434
|
+
|
|
435
|
+
const explicitInteraction =
|
|
436
|
+
config.format === 'interaction' ||
|
|
437
|
+
config.interaction === 'true' ||
|
|
438
|
+
(typeof config.interaction === 'string' && config.interaction.length > 0);
|
|
439
|
+
|
|
440
|
+
// Structured interaction detection: LLM responds with { "interact": "question" }
|
|
441
|
+
const parsedInteraction = parseInteractionRequest(response.text);
|
|
442
|
+
const structuredInteraction =
|
|
443
|
+
config.autoInteract !== 'false' && parsedInteraction.isInteraction;
|
|
444
|
+
|
|
445
|
+
if (explicitInteraction || structuredInteraction) {
|
|
446
|
+
const slug =
|
|
447
|
+
(typeof config.interaction === 'string' && config.interaction !== 'true' ? config.interaction : null) ||
|
|
448
|
+
config.interactionSlug ||
|
|
449
|
+
config.interactionKey ||
|
|
450
|
+
outputKey ||
|
|
451
|
+
agentName;
|
|
452
|
+
|
|
453
|
+
const targetKey = config.interactionKey || outputKey || slug;
|
|
454
|
+
|
|
455
|
+
// Use parsed question for content if structured interaction, otherwise full response
|
|
456
|
+
const interactionContent = structuredInteraction
|
|
457
|
+
? parsedInteraction.question
|
|
458
|
+
: response.text;
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
...context,
|
|
462
|
+
_interaction: {
|
|
463
|
+
slug,
|
|
464
|
+
targetKey,
|
|
465
|
+
content: interactionContent
|
|
466
|
+
},
|
|
467
|
+
[`_${agentName}_model`]: response.model
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
...context,
|
|
473
|
+
[outputKey]: output,
|
|
474
|
+
[`_${agentName}_model`]: response.model
|
|
475
|
+
};
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Load steering configuration and global prompt
|
|
481
|
+
*/
|
|
482
|
+
loadSteering() {
|
|
483
|
+
this.steering = {
|
|
484
|
+
enabled: false,
|
|
485
|
+
global: null,
|
|
486
|
+
config: null
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (!fs.existsSync(this.steeringDir)) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Load steering config
|
|
494
|
+
const configPath = path.join(this.steeringDir, 'config.json');
|
|
495
|
+
if (fs.existsSync(configPath)) {
|
|
496
|
+
try {
|
|
497
|
+
this.steering.config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
498
|
+
this.steering.enabled = this.steering.config.enabled !== false;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
console.warn(`Warning: Failed to load steering config: ${err.message}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Load global.md if present
|
|
505
|
+
const globalPath = path.join(this.steeringDir, 'global.md');
|
|
506
|
+
if (fs.existsSync(globalPath)) {
|
|
507
|
+
try {
|
|
508
|
+
this.steering.global = fs.readFileSync(globalPath, 'utf-8');
|
|
509
|
+
} catch (err) {
|
|
510
|
+
console.warn(`Warning: Failed to load global.md: ${err.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Get agent by name (custom first, then builtin)
|
|
517
|
+
*/
|
|
518
|
+
getAgent(name) {
|
|
519
|
+
return this.customAgents[name] || BUILTIN_AGENTS[name];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Parse step definition (string format)
|
|
524
|
+
*/
|
|
525
|
+
parseStep(step) {
|
|
526
|
+
if (typeof step !== 'string') {
|
|
527
|
+
return null; // Not a simple step
|
|
528
|
+
}
|
|
529
|
+
if (step.startsWith('agent:')) {
|
|
530
|
+
return { type: 'agent', name: step.slice(6) };
|
|
531
|
+
} else if (step.startsWith('script:')) {
|
|
532
|
+
return { type: 'script', path: step.slice(7) };
|
|
533
|
+
} else {
|
|
534
|
+
// Default to agent if no prefix
|
|
535
|
+
return { type: 'agent', name: step };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Get step name for display
|
|
541
|
+
*/
|
|
542
|
+
getStepName(step) {
|
|
543
|
+
if (typeof step === 'string') {
|
|
544
|
+
return step;
|
|
545
|
+
}
|
|
546
|
+
if (step.if) {
|
|
547
|
+
return `conditional`;
|
|
548
|
+
}
|
|
549
|
+
if (step.forEach) {
|
|
550
|
+
return `forEach`;
|
|
551
|
+
}
|
|
552
|
+
return 'unknown';
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Resolve goto target to step index
|
|
557
|
+
* Special values: 'end' exits the workflow
|
|
558
|
+
*/
|
|
559
|
+
resolveGoto(goto, steps, currentIndex) {
|
|
560
|
+
// Special case: end the workflow
|
|
561
|
+
if (goto === 'end') {
|
|
562
|
+
return steps.length; // Return index past the end to exit
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (typeof goto === 'number') {
|
|
566
|
+
if (goto < 0) {
|
|
567
|
+
// Relative jump backward
|
|
568
|
+
return currentIndex + goto;
|
|
569
|
+
}
|
|
570
|
+
// Absolute index
|
|
571
|
+
return goto;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (typeof goto === 'string') {
|
|
575
|
+
// Find step by name
|
|
576
|
+
const targetIndex = steps.findIndex(s => {
|
|
577
|
+
if (typeof s === 'string') {
|
|
578
|
+
return s === goto;
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (targetIndex === -1) {
|
|
584
|
+
throw new Error(`Goto target not found: "${goto}". Step name does not exist in workflow.`);
|
|
585
|
+
}
|
|
586
|
+
return targetIndex;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
throw new Error(`Invalid goto target: ${goto}. Must be a number, step name, or 'end'.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Execute an agent
|
|
594
|
+
*/
|
|
595
|
+
async executeAgent(agentName, context) {
|
|
596
|
+
console.log(`\n▶ Executing agent: ${agentName}`);
|
|
597
|
+
|
|
598
|
+
const agent = this.getAgent(agentName);
|
|
599
|
+
if (!agent) {
|
|
600
|
+
const availableAgents = [
|
|
601
|
+
...Object.keys(this.customAgents),
|
|
602
|
+
...Object.keys(BUILTIN_AGENTS)
|
|
603
|
+
].join(', ');
|
|
604
|
+
throw new Error(`Unknown agent: ${agentName}. Available agents: ${availableAgents}`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Inject steering and loop info into context
|
|
608
|
+
let agentContext = { ...context };
|
|
609
|
+
if (this.steering && this.steering.enabled && this.steering.global) {
|
|
610
|
+
agentContext._steering = {
|
|
611
|
+
global: this.steering.global,
|
|
612
|
+
config: this.steering.config
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
agentContext._loop = { ...this.loop };
|
|
616
|
+
|
|
617
|
+
// Inject config for llm() helper
|
|
618
|
+
agentContext._config = {
|
|
619
|
+
models: this.workflowConfig?.models || {},
|
|
620
|
+
apiKeys: this.workflowConfig?.apiKeys || {},
|
|
621
|
+
workflowDir: this.workflowDir
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const result = await agent(agentContext);
|
|
625
|
+
console.log(`✓ Agent ${agentName} completed`);
|
|
626
|
+
|
|
627
|
+
// Remove internal props from result
|
|
628
|
+
if (result) {
|
|
629
|
+
delete result._steering;
|
|
630
|
+
delete result._loop;
|
|
631
|
+
delete result._config;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Execute a script
|
|
639
|
+
*/
|
|
640
|
+
async executeScript(scriptPath, context) {
|
|
641
|
+
const resolvedPath = path.join(this.scriptsDir, scriptPath);
|
|
642
|
+
console.log(`\n▶ Executing script: ${scriptPath}`);
|
|
643
|
+
|
|
644
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
645
|
+
throw new Error(`Script not found: ${resolvedPath}`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Inject steering into context for scripts too
|
|
649
|
+
let scriptContext = { ...context };
|
|
650
|
+
if (this.steering && this.steering.enabled && this.steering.global) {
|
|
651
|
+
scriptContext._steering = {
|
|
652
|
+
global: this.steering.global,
|
|
653
|
+
config: this.steering.config
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
scriptContext._loop = { ...this.loop };
|
|
657
|
+
|
|
658
|
+
// Inject config for scripts
|
|
659
|
+
scriptContext._config = {
|
|
660
|
+
models: this.workflowConfig?.models || {},
|
|
661
|
+
apiKeys: this.workflowConfig?.apiKeys || {},
|
|
662
|
+
workflowDir: this.workflowDir
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
const child = spawn('node', [resolvedPath], {
|
|
667
|
+
cwd: this.workflowDir,
|
|
668
|
+
env: {
|
|
669
|
+
...process.env,
|
|
670
|
+
AGENT_CONTEXT: JSON.stringify(scriptContext),
|
|
671
|
+
AGENT_STEERING: this.steering?.global || '',
|
|
672
|
+
AGENT_LOOP_COUNT: String(this.loop.count),
|
|
673
|
+
WORKFLOW_DIR: this.workflowDir,
|
|
674
|
+
WORKFLOW_NAME: this.workflowName
|
|
675
|
+
},
|
|
676
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
let stdout = '';
|
|
680
|
+
let stderr = '';
|
|
681
|
+
|
|
682
|
+
child.stdout.on('data', (data) => {
|
|
683
|
+
const text = data.toString();
|
|
684
|
+
stdout += text;
|
|
685
|
+
process.stdout.write(text);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
child.stderr.on('data', (data) => {
|
|
689
|
+
const text = data.toString();
|
|
690
|
+
stderr += text;
|
|
691
|
+
process.stderr.write(text);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
child.on('close', (code) => {
|
|
695
|
+
if (code === 0) {
|
|
696
|
+
console.log(`✓ Script ${scriptPath} completed`);
|
|
697
|
+
// Try to parse last line as JSON result
|
|
698
|
+
const lines = stdout.trim().split('\n');
|
|
699
|
+
const lastLine = lines[lines.length - 1];
|
|
700
|
+
try {
|
|
701
|
+
const result = JSON.parse(lastLine);
|
|
702
|
+
resolve({ ...context, ...result });
|
|
703
|
+
} catch {
|
|
704
|
+
resolve({ ...context, scriptOutput: stdout.trim() });
|
|
705
|
+
}
|
|
706
|
+
} else {
|
|
707
|
+
reject(new Error(`Script exited with code ${code}: ${stderr}`));
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
child.on('error', reject);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Execute a simple step (agent or script)
|
|
717
|
+
*/
|
|
718
|
+
async executeSimpleStep(step, context) {
|
|
719
|
+
const parsed = this.parseStep(step);
|
|
720
|
+
|
|
721
|
+
if (parsed.type === 'agent') {
|
|
722
|
+
return this.executeAgent(parsed.name, context);
|
|
723
|
+
} else if (parsed.type === 'script') {
|
|
724
|
+
return this.executeScript(parsed.path, context);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
throw new Error(`Unknown step type: ${parsed.type}`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Execute a forEach step
|
|
732
|
+
*/
|
|
733
|
+
async executeForEach(step, context, state, workflowName) {
|
|
734
|
+
const items = step.forEach(context);
|
|
735
|
+
const itemName = step.as || 'item';
|
|
736
|
+
const subSteps = step.steps || [];
|
|
737
|
+
const parallel = step.parallel || false;
|
|
738
|
+
|
|
739
|
+
if (!Array.isArray(items)) {
|
|
740
|
+
throw new Error(`forEach must return an array, got: ${typeof items}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
console.log(`\n▶ forEach: ${items.length} items (${parallel ? 'parallel' : 'sequential'})`);
|
|
744
|
+
|
|
745
|
+
let currentContext = { ...context };
|
|
746
|
+
|
|
747
|
+
if (parallel) {
|
|
748
|
+
// Parallel execution
|
|
749
|
+
const results = await Promise.all(items.map(async (item, itemIndex) => {
|
|
750
|
+
let itemContext = { ...currentContext, [itemName]: item, [`${itemName}Index`]: itemIndex };
|
|
751
|
+
|
|
752
|
+
for (const subStep of subSteps) {
|
|
753
|
+
const result = await this.executeStepWithControl(subStep, itemContext, state, workflowName, subSteps, 0);
|
|
754
|
+
itemContext = result.context;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return itemContext;
|
|
758
|
+
}));
|
|
759
|
+
|
|
760
|
+
// Merge results (last item's context wins for conflicts)
|
|
761
|
+
currentContext = results.reduce((acc, ctx) => ({ ...acc, ...ctx }), currentContext);
|
|
762
|
+
} else {
|
|
763
|
+
// Sequential execution
|
|
764
|
+
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
|
765
|
+
const item = items[itemIndex];
|
|
766
|
+
currentContext[itemName] = item;
|
|
767
|
+
currentContext[`${itemName}Index`] = itemIndex;
|
|
768
|
+
|
|
769
|
+
console.log(`\n [${itemIndex + 1}/${items.length}] Processing ${itemName}`);
|
|
770
|
+
|
|
771
|
+
for (const subStep of subSteps) {
|
|
772
|
+
const result = await this.executeStepWithControl(subStep, currentContext, state, workflowName, subSteps, 0);
|
|
773
|
+
currentContext = result.context;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Clean up iteration variables
|
|
779
|
+
delete currentContext[itemName];
|
|
780
|
+
delete currentContext[`${itemName}Index`];
|
|
781
|
+
|
|
782
|
+
console.log(`✓ forEach completed`);
|
|
783
|
+
return currentContext;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Execute a step and handle control flow (conditionals, goto, forEach)
|
|
788
|
+
* Returns: { context, goto: number | null }
|
|
789
|
+
*/
|
|
790
|
+
async executeStepWithControl(step, context, state, workflowName, steps, currentIndex) {
|
|
791
|
+
// Handle conditional step
|
|
792
|
+
if (step.if && typeof step.if === 'function') {
|
|
793
|
+
console.log(`\n▶ Evaluating conditional...`);
|
|
794
|
+
|
|
795
|
+
const condition = step.if(context, this.loop);
|
|
796
|
+
console.log(` Condition result: ${condition}`);
|
|
797
|
+
|
|
798
|
+
const branch = condition ? step.true : step.false;
|
|
799
|
+
|
|
800
|
+
if (branch && branch.goto !== undefined) {
|
|
801
|
+
const targetIndex = this.resolveGoto(branch.goto, steps, currentIndex);
|
|
802
|
+
console.log(` → Jumping to step ${targetIndex}`);
|
|
803
|
+
return { context, goto: targetIndex };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// No goto, just continue
|
|
807
|
+
return { context, goto: null };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Handle forEach step
|
|
811
|
+
if (step.forEach && typeof step.forEach === 'function') {
|
|
812
|
+
const newContext = await this.executeForEach(step, context, state, workflowName);
|
|
813
|
+
return { context: newContext, goto: null };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Handle simple step (string)
|
|
817
|
+
if (typeof step === 'string') {
|
|
818
|
+
const newContext = await this.executeSimpleStep(step, context);
|
|
819
|
+
return { context: newContext, goto: null };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
throw new Error(`Unknown step format: ${JSON.stringify(step)}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Run the workflow
|
|
827
|
+
*/
|
|
828
|
+
async run() {
|
|
829
|
+
this.ensureWorkflow();
|
|
830
|
+
|
|
831
|
+
// Load workflow config
|
|
832
|
+
const config = this.loadWorkflowConfig();
|
|
833
|
+
this.workflowConfig = config; // Store for access in agents
|
|
834
|
+
const steps = config.steps || [];
|
|
835
|
+
const workflowName = config.name || this.workflowName;
|
|
836
|
+
|
|
837
|
+
// Load custom agents
|
|
838
|
+
this.loadCustomAgents();
|
|
839
|
+
|
|
840
|
+
// Load steering configuration
|
|
841
|
+
this.loadSteering();
|
|
842
|
+
|
|
843
|
+
if (this.steering.enabled && this.steering.global) {
|
|
844
|
+
console.log(`Steering: global.md loaded (${this.steering.global.length} chars)`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
848
|
+
console.log(`Starting workflow: ${workflowName}`);
|
|
849
|
+
console.log(`Steps: ${steps.length}`);
|
|
850
|
+
console.log(`${'═'.repeat(50)}`);
|
|
851
|
+
|
|
852
|
+
// Initialize state
|
|
853
|
+
let state = this.loadCurrentState();
|
|
854
|
+
state.status = States.RUNNING;
|
|
855
|
+
state.workflow = { name: workflowName, stepCount: steps.length };
|
|
856
|
+
state.currentStepIndex = 0;
|
|
857
|
+
state.context = config.initialContext || {};
|
|
858
|
+
state.startedAt = new Date().toISOString();
|
|
859
|
+
state.error = null;
|
|
860
|
+
|
|
861
|
+
// Initialize loop tracking
|
|
862
|
+
this.loop = {
|
|
863
|
+
count: 0,
|
|
864
|
+
stepCounts: {}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
this.saveCurrentState(state);
|
|
868
|
+
|
|
869
|
+
this.prependHistory({
|
|
870
|
+
event: 'WORKFLOW_STARTED',
|
|
871
|
+
workflow: workflowName,
|
|
872
|
+
steps: steps.length
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Execute steps with goto support
|
|
876
|
+
let i = 0;
|
|
877
|
+
const maxIterations = 10000; // Safety limit
|
|
878
|
+
let iterations = 0;
|
|
879
|
+
|
|
880
|
+
while (i < steps.length && iterations < maxIterations) {
|
|
881
|
+
iterations++;
|
|
882
|
+
this.loop.count = iterations;
|
|
883
|
+
|
|
884
|
+
const step = steps[i];
|
|
885
|
+
const stepName = this.getStepName(step);
|
|
886
|
+
|
|
887
|
+
// Track step execution count
|
|
888
|
+
this.loop.stepCounts[i] = (this.loop.stepCounts[i] || 0) + 1;
|
|
889
|
+
|
|
890
|
+
state.currentStepIndex = i;
|
|
891
|
+
state.status = States.STEP_EXECUTING;
|
|
892
|
+
state.context = { ...state.context }; // Ensure we're saving current context
|
|
893
|
+
this.saveCurrentState(state);
|
|
894
|
+
|
|
895
|
+
console.log(`\n${'─'.repeat(40)}`);
|
|
896
|
+
console.log(`Step ${i + 1}/${steps.length}: ${stepName} (iteration ${iterations}, step runs: ${this.loop.stepCounts[i]})`);
|
|
897
|
+
console.log(`${'─'.repeat(40)}`);
|
|
898
|
+
|
|
899
|
+
this.prependHistory({
|
|
900
|
+
event: 'STEP_STARTED',
|
|
901
|
+
workflow: workflowName,
|
|
902
|
+
step: stepName,
|
|
903
|
+
stepIndex: i,
|
|
904
|
+
loopCount: this.loop.count,
|
|
905
|
+
stepRunCount: this.loop.stepCounts[i]
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
try {
|
|
909
|
+
const result = await this.executeStepWithControl(step, state.context, state, workflowName, steps, i);
|
|
910
|
+
|
|
911
|
+
state.context = await this.handleInteractionIfNeeded(
|
|
912
|
+
result.context,
|
|
913
|
+
state,
|
|
914
|
+
workflowName,
|
|
915
|
+
stepName,
|
|
916
|
+
i
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
if (state.status === States.PAUSED) {
|
|
920
|
+
return state.context;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
state.status = States.STEP_COMPLETED;
|
|
924
|
+
this.saveCurrentState(state);
|
|
925
|
+
|
|
926
|
+
this.prependHistory({
|
|
927
|
+
event: 'STEP_COMPLETED',
|
|
928
|
+
workflow: workflowName,
|
|
929
|
+
step: stepName,
|
|
930
|
+
stepIndex: i,
|
|
931
|
+
context: state.context
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Handle goto
|
|
935
|
+
if (result.goto !== null) {
|
|
936
|
+
if (result.goto < 0 || result.goto > steps.length) {
|
|
937
|
+
throw new Error(`Goto index out of bounds: ${result.goto}. Valid range: 0-${steps.length}`);
|
|
938
|
+
}
|
|
939
|
+
i = result.goto;
|
|
940
|
+
} else {
|
|
941
|
+
i++;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
} catch (error) {
|
|
945
|
+
console.error(`\n✗ Step failed: ${error.message}`);
|
|
946
|
+
state.status = States.STEP_FAILED;
|
|
947
|
+
state.error = {
|
|
948
|
+
step: stepName,
|
|
949
|
+
stepIndex: i,
|
|
950
|
+
message: error.message,
|
|
951
|
+
stack: error.stack
|
|
952
|
+
};
|
|
953
|
+
this.saveCurrentState(state);
|
|
954
|
+
|
|
955
|
+
this.prependHistory({
|
|
956
|
+
event: 'STEP_FAILED',
|
|
957
|
+
workflow: workflowName,
|
|
958
|
+
step: stepName,
|
|
959
|
+
stepIndex: i,
|
|
960
|
+
error: error.message
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// Mark workflow as failed
|
|
964
|
+
state.status = States.WORKFLOW_FAILED;
|
|
965
|
+
this.saveCurrentState(state);
|
|
966
|
+
|
|
967
|
+
this.prependHistory({
|
|
968
|
+
event: 'WORKFLOW_FAILED',
|
|
969
|
+
workflow: workflowName,
|
|
970
|
+
failedStep: stepName,
|
|
971
|
+
failedStepIndex: i,
|
|
972
|
+
error: error.message
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
976
|
+
console.log(`✗ Workflow failed at step ${i + 1}`);
|
|
977
|
+
console.log(`${'═'.repeat(50)}\n`);
|
|
978
|
+
|
|
979
|
+
process.exitCode = 1;
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (iterations >= maxIterations) {
|
|
985
|
+
const error = new Error(`Workflow exceeded maximum iterations (${maxIterations}). Possible infinite loop.`);
|
|
986
|
+
state.status = States.WORKFLOW_FAILED;
|
|
987
|
+
state.error = { message: error.message };
|
|
988
|
+
this.saveCurrentState(state);
|
|
989
|
+
|
|
990
|
+
this.prependHistory({
|
|
991
|
+
event: 'WORKFLOW_FAILED',
|
|
992
|
+
workflow: workflowName,
|
|
993
|
+
error: error.message
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
process.exitCode = 1;
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Workflow completed
|
|
1001
|
+
state.status = States.WORKFLOW_COMPLETED;
|
|
1002
|
+
this.saveCurrentState(state);
|
|
1003
|
+
|
|
1004
|
+
this.prependHistory({
|
|
1005
|
+
event: 'WORKFLOW_COMPLETED',
|
|
1006
|
+
workflow: workflowName,
|
|
1007
|
+
finalContext: state.context,
|
|
1008
|
+
totalIterations: iterations
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
1012
|
+
console.log(`✓ Workflow completed successfully (${iterations} iterations)`);
|
|
1013
|
+
console.log(`${'═'.repeat(50)}`);
|
|
1014
|
+
console.log('\nFinal context:');
|
|
1015
|
+
console.log(JSON.stringify(state.context, null, 2));
|
|
1016
|
+
|
|
1017
|
+
return state.context;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Show current status
|
|
1022
|
+
*/
|
|
1023
|
+
showStatus() {
|
|
1024
|
+
if (!this.workflowName) {
|
|
1025
|
+
console.log('\nNo workflow specified. Use: state-machine status <workflow-name>');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
this.ensureWorkflow();
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
console.error(err.message);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const state = this.loadCurrentState();
|
|
1037
|
+
console.log(`\nWorkflow: ${this.workflowName}`);
|
|
1038
|
+
console.log('Current State:');
|
|
1039
|
+
console.log('─'.repeat(40));
|
|
1040
|
+
console.log(JSON.stringify(state, null, 2));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Show history
|
|
1045
|
+
*/
|
|
1046
|
+
showHistory(limit = 20) {
|
|
1047
|
+
if (!this.workflowName) {
|
|
1048
|
+
console.log('\nNo workflow specified. Use: state-machine history <workflow-name>');
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
try {
|
|
1053
|
+
this.ensureWorkflow();
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
console.error(err.message);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const history = this.loadHistory();
|
|
1060
|
+
const entries = history.slice(-limit);
|
|
1061
|
+
|
|
1062
|
+
console.log(`\nWorkflow: ${this.workflowName}`);
|
|
1063
|
+
console.log(`Execution History (last ${entries.length} entries):`);
|
|
1064
|
+
console.log('─'.repeat(60));
|
|
1065
|
+
|
|
1066
|
+
if (entries.length === 0) {
|
|
1067
|
+
console.log('No history yet.');
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
entries.forEach((entry) => {
|
|
1072
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
1073
|
+
console.log(`\n[${time}] ${entry.event}`);
|
|
1074
|
+
if (entry.step) console.log(` Step: ${entry.step}`);
|
|
1075
|
+
if (entry.loopCount) console.log(` Loop: ${entry.loopCount}`);
|
|
1076
|
+
if (entry.error) console.log(` Error: ${entry.error}`);
|
|
1077
|
+
});
|
|
1078
|
+
console.log('');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Reset state
|
|
1083
|
+
*/
|
|
1084
|
+
reset() {
|
|
1085
|
+
if (!this.workflowName) {
|
|
1086
|
+
console.log('\nNo workflow specified. Use: state-machine reset <workflow-name>');
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
try {
|
|
1091
|
+
this.ensureWorkflow();
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
console.error(err.message);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const state = this.createInitialState();
|
|
1098
|
+
this.saveCurrentState(state);
|
|
1099
|
+
console.log(`Workflow '${this.workflowName}' state reset to initial values`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Resume a failed or stopped workflow from where it left off
|
|
1104
|
+
*/
|
|
1105
|
+
async resume() {
|
|
1106
|
+
this.ensureWorkflow();
|
|
1107
|
+
|
|
1108
|
+
// Load saved state
|
|
1109
|
+
const savedState = this.loadCurrentState();
|
|
1110
|
+
|
|
1111
|
+
// Check if workflow can be resumed
|
|
1112
|
+
if (savedState.status === 'IDLE') {
|
|
1113
|
+
console.log('No workflow to resume. Use `state-machine run` to start.');
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (savedState.status === 'WORKFLOW_COMPLETED') {
|
|
1118
|
+
console.log('Workflow already completed. Use `state-machine run` to start fresh.');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (!savedState.workflow) {
|
|
1123
|
+
console.log('No workflow state found. Use `state-machine run` to start.');
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Load workflow config
|
|
1128
|
+
const config = this.loadWorkflowConfig();
|
|
1129
|
+
this.workflowConfig = config;
|
|
1130
|
+
const steps = config.steps || [];
|
|
1131
|
+
const workflowName = config.name || this.workflowName;
|
|
1132
|
+
|
|
1133
|
+
// Load custom agents and steering
|
|
1134
|
+
this.loadCustomAgents();
|
|
1135
|
+
this.loadSteering();
|
|
1136
|
+
|
|
1137
|
+
if (this.steering.enabled && this.steering.global) {
|
|
1138
|
+
console.log(`Steering: global.md loaded (${this.steering.global.length} chars)`);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// If paused for an interaction, finalize it and move on
|
|
1142
|
+
if (savedState.status === States.PAUSED && savedState.pendingInteraction) {
|
|
1143
|
+
const pending = savedState.pendingInteraction;
|
|
1144
|
+
const filePath = path.join(this.workflowDir, pending.file);
|
|
1145
|
+
if (fs.existsSync(filePath)) {
|
|
1146
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1147
|
+
savedState.context = { ...savedState.context, [pending.targetKey]: content };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
savedState.pendingInteraction = null;
|
|
1151
|
+
savedState.status = States.STEP_COMPLETED;
|
|
1152
|
+
this.saveCurrentState(savedState);
|
|
1153
|
+
|
|
1154
|
+
this.prependHistory({
|
|
1155
|
+
event: 'INTERACTION_RESOLVED',
|
|
1156
|
+
workflow: workflowName,
|
|
1157
|
+
step: pending.step,
|
|
1158
|
+
stepIndex: pending.stepIndex,
|
|
1159
|
+
slug: pending.slug,
|
|
1160
|
+
file: pending.file,
|
|
1161
|
+
targetKey: pending.targetKey,
|
|
1162
|
+
resumed: true
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Determine resume point
|
|
1167
|
+
let resumeIndex = savedState.currentStepIndex;
|
|
1168
|
+
|
|
1169
|
+
// If the step failed, retry it; otherwise start from the next one
|
|
1170
|
+
if (savedState.status === 'STEP_FAILED' || savedState.status === 'WORKFLOW_FAILED') {
|
|
1171
|
+
console.log(`\nResuming from failed step ${resumeIndex + 1}...`);
|
|
1172
|
+
} else if (savedState.status === 'STEP_COMPLETED') {
|
|
1173
|
+
resumeIndex = savedState.currentStepIndex + 1;
|
|
1174
|
+
console.log(`\nResuming from step ${resumeIndex + 1}...`);
|
|
1175
|
+
} else {
|
|
1176
|
+
console.log(`\nResuming from step ${resumeIndex + 1} (status: ${savedState.status})...`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Check if there are steps to run
|
|
1180
|
+
if (resumeIndex >= steps.length) {
|
|
1181
|
+
console.log('No more steps to run. Workflow complete.');
|
|
1182
|
+
savedState.status = States.WORKFLOW_COMPLETED;
|
|
1183
|
+
this.saveCurrentState(savedState);
|
|
1184
|
+
return savedState.context;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
1188
|
+
console.log(`Resuming workflow: ${workflowName}`);
|
|
1189
|
+
console.log(`From step: ${resumeIndex + 1}/${steps.length}`);
|
|
1190
|
+
console.log(`Previous context keys: ${Object.keys(savedState.context).filter(k => !k.startsWith('_')).join(', ') || 'none'}`);
|
|
1191
|
+
console.log(`${'═'.repeat(50)}`);
|
|
1192
|
+
|
|
1193
|
+
// Restore state
|
|
1194
|
+
let state = savedState;
|
|
1195
|
+
state.status = States.RUNNING;
|
|
1196
|
+
state.error = null;
|
|
1197
|
+
|
|
1198
|
+
// Restore loop tracking
|
|
1199
|
+
this.loop = savedState.loop || {
|
|
1200
|
+
count: 0,
|
|
1201
|
+
stepCounts: {}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
this.saveCurrentState(state);
|
|
1205
|
+
|
|
1206
|
+
this.prependHistory({
|
|
1207
|
+
event: 'WORKFLOW_RESUMED',
|
|
1208
|
+
workflow: workflowName,
|
|
1209
|
+
resumeFromStep: resumeIndex,
|
|
1210
|
+
previousStatus: savedState.status
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// Execute remaining steps
|
|
1214
|
+
let i = resumeIndex;
|
|
1215
|
+
const maxIterations = 10000;
|
|
1216
|
+
let iterations = this.loop.count || 0;
|
|
1217
|
+
|
|
1218
|
+
while (i < steps.length && iterations < maxIterations) {
|
|
1219
|
+
iterations++;
|
|
1220
|
+
this.loop.count = iterations;
|
|
1221
|
+
|
|
1222
|
+
const step = steps[i];
|
|
1223
|
+
const stepName = this.getStepName(step);
|
|
1224
|
+
|
|
1225
|
+
// Track step execution count
|
|
1226
|
+
this.loop.stepCounts[i] = (this.loop.stepCounts[i] || 0) + 1;
|
|
1227
|
+
|
|
1228
|
+
state.currentStepIndex = i;
|
|
1229
|
+
state.status = States.STEP_EXECUTING;
|
|
1230
|
+
this.saveCurrentState(state);
|
|
1231
|
+
|
|
1232
|
+
console.log(`\n${'─'.repeat(40)}`);
|
|
1233
|
+
console.log(`Step ${i + 1}/${steps.length}: ${stepName} (iteration ${iterations}, step runs: ${this.loop.stepCounts[i]})`);
|
|
1234
|
+
console.log(`${'─'.repeat(40)}`);
|
|
1235
|
+
|
|
1236
|
+
this.prependHistory({
|
|
1237
|
+
event: 'STEP_STARTED',
|
|
1238
|
+
workflow: workflowName,
|
|
1239
|
+
step: stepName,
|
|
1240
|
+
stepIndex: i,
|
|
1241
|
+
loopCount: this.loop.count,
|
|
1242
|
+
stepRunCount: this.loop.stepCounts[i],
|
|
1243
|
+
resumed: true
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
try {
|
|
1247
|
+
const result = await this.executeStepWithControl(step, state.context, state, workflowName, steps, i);
|
|
1248
|
+
|
|
1249
|
+
state.context = await this.handleInteractionIfNeeded(
|
|
1250
|
+
result.context,
|
|
1251
|
+
state,
|
|
1252
|
+
workflowName,
|
|
1253
|
+
stepName,
|
|
1254
|
+
i
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
if (state.status === States.PAUSED) {
|
|
1258
|
+
return state.context;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
state.status = States.STEP_COMPLETED;
|
|
1262
|
+
this.saveCurrentState(state);
|
|
1263
|
+
|
|
1264
|
+
this.prependHistory({
|
|
1265
|
+
event: 'STEP_COMPLETED',
|
|
1266
|
+
workflow: workflowName,
|
|
1267
|
+
step: stepName,
|
|
1268
|
+
stepIndex: i,
|
|
1269
|
+
context: state.context
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// Handle goto
|
|
1273
|
+
if (result.goto !== null) {
|
|
1274
|
+
if (result.goto < 0 || result.goto > steps.length) {
|
|
1275
|
+
throw new Error(`Goto index out of bounds: ${result.goto}. Valid range: 0-${steps.length}`);
|
|
1276
|
+
}
|
|
1277
|
+
i = result.goto;
|
|
1278
|
+
} else {
|
|
1279
|
+
i++;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
console.error(`\n✗ Step failed: ${error.message}`);
|
|
1284
|
+
state.status = States.STEP_FAILED;
|
|
1285
|
+
state.error = {
|
|
1286
|
+
step: stepName,
|
|
1287
|
+
stepIndex: i,
|
|
1288
|
+
message: error.message,
|
|
1289
|
+
stack: error.stack
|
|
1290
|
+
};
|
|
1291
|
+
this.saveCurrentState(state);
|
|
1292
|
+
|
|
1293
|
+
this.prependHistory({
|
|
1294
|
+
event: 'STEP_FAILED',
|
|
1295
|
+
workflow: workflowName,
|
|
1296
|
+
step: stepName,
|
|
1297
|
+
stepIndex: i,
|
|
1298
|
+
error: error.message
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
state.status = States.WORKFLOW_FAILED;
|
|
1302
|
+
this.saveCurrentState(state);
|
|
1303
|
+
|
|
1304
|
+
this.prependHistory({
|
|
1305
|
+
event: 'WORKFLOW_FAILED',
|
|
1306
|
+
workflow: workflowName,
|
|
1307
|
+
failedStep: stepName,
|
|
1308
|
+
failedStepIndex: i,
|
|
1309
|
+
error: error.message
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
1313
|
+
console.log(`✗ Workflow failed at step ${i + 1}`);
|
|
1314
|
+
console.log(`Use 'state-machine resume ${this.workflowName}' to retry`);
|
|
1315
|
+
console.log(`${'═'.repeat(50)}\n`);
|
|
1316
|
+
|
|
1317
|
+
process.exitCode = 1;
|
|
1318
|
+
throw error;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (iterations >= maxIterations) {
|
|
1323
|
+
const error = new Error(`Workflow exceeded maximum iterations (${maxIterations}). Possible infinite loop.`);
|
|
1324
|
+
state.status = States.WORKFLOW_FAILED;
|
|
1325
|
+
state.error = { message: error.message };
|
|
1326
|
+
this.saveCurrentState(state);
|
|
1327
|
+
|
|
1328
|
+
this.prependHistory({
|
|
1329
|
+
event: 'WORKFLOW_FAILED',
|
|
1330
|
+
workflow: workflowName,
|
|
1331
|
+
error: error.message
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
process.exitCode = 1;
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Workflow completed
|
|
1339
|
+
state.status = States.WORKFLOW_COMPLETED;
|
|
1340
|
+
this.saveCurrentState(state);
|
|
1341
|
+
|
|
1342
|
+
this.prependHistory({
|
|
1343
|
+
event: 'WORKFLOW_COMPLETED',
|
|
1344
|
+
workflow: workflowName,
|
|
1345
|
+
finalContext: state.context,
|
|
1346
|
+
totalIterations: iterations,
|
|
1347
|
+
resumed: true
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
1351
|
+
console.log(`✓ Workflow completed successfully (${iterations} iterations)`);
|
|
1352
|
+
console.log(`${'═'.repeat(50)}`);
|
|
1353
|
+
console.log('\nFinal context:');
|
|
1354
|
+
console.log(JSON.stringify(state.context, null, 2));
|
|
1355
|
+
|
|
1356
|
+
return state.context;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
export { StateMachine, States, BUILTIN_AGENTS };
|