keystone-cli 0.6.0 → 0.7.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 +34 -0
- package/package.json +1 -1
- package/src/cli.ts +233 -21
- package/src/db/memory-db.ts +6 -0
- package/src/db/sqlite-setup.test.ts +47 -0
- package/src/db/workflow-db.ts +6 -0
- package/src/expression/evaluator.ts +2 -0
- package/src/parser/schema.ts +3 -0
- package/src/runner/debug-repl.test.ts +240 -6
- package/src/runner/llm-adapter.test.ts +10 -4
- package/src/runner/llm-executor.ts +39 -3
- package/src/runner/shell-executor.ts +40 -12
- package/src/runner/standard-tools-integration.test.ts +147 -0
- package/src/runner/standard-tools.test.ts +69 -0
- package/src/runner/standard-tools.ts +270 -0
- package/src/runner/step-executor.test.ts +194 -1
- package/src/runner/step-executor.ts +46 -15
- package/src/runner/stream-utils.test.ts +113 -7
- package/src/runner/stream-utils.ts +4 -4
- package/src/runner/workflow-runner.ts +14 -20
- package/src/templates/agents/keystone-architect.md +16 -2
- package/src/templates/agents/software-engineer.md +17 -0
- package/src/templates/memory-service.yaml +54 -0
- package/src/templates/robust-automation.yaml +44 -0
- package/src/templates/scaffold-feature.yaml +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ExpressionEvaluator } from '../expression/evaluator';
|
|
4
|
+
import type { AgentTool, Step } from '../parser/schema';
|
|
5
|
+
import { detectShellInjectionRisk } from './shell-executor';
|
|
6
|
+
|
|
7
|
+
export const STANDARD_TOOLS: AgentTool[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'read_file',
|
|
10
|
+
description: 'Read the contents of a file',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
path: { type: 'string', description: 'Path to the file to read' },
|
|
15
|
+
},
|
|
16
|
+
required: ['path'],
|
|
17
|
+
},
|
|
18
|
+
execution: {
|
|
19
|
+
id: 'std_read_file',
|
|
20
|
+
type: 'file',
|
|
21
|
+
op: 'read',
|
|
22
|
+
path: '${{ args.path }}',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'read_file_lines',
|
|
27
|
+
description: 'Read a specific range of lines from a file',
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
path: { type: 'string', description: 'Path to the file to read' },
|
|
32
|
+
start: { type: 'number', description: 'Starting line number (1-indexed)', default: 1 },
|
|
33
|
+
count: { type: 'number', description: 'Number of lines to read', default: 100 },
|
|
34
|
+
},
|
|
35
|
+
required: ['path'],
|
|
36
|
+
},
|
|
37
|
+
execution: {
|
|
38
|
+
id: 'std_read_file_lines',
|
|
39
|
+
type: 'script',
|
|
40
|
+
run: `
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const filePath = args.path;
|
|
44
|
+
const start = args.start || 1;
|
|
45
|
+
const count = args.count || 100;
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(filePath)) {
|
|
48
|
+
throw new Error('File not found: ' + filePath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
52
|
+
const lines = content.split('\\n');
|
|
53
|
+
return lines.slice(start - 1, start - 1 + count).join('\\n');
|
|
54
|
+
`,
|
|
55
|
+
allowInsecure: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'write_file',
|
|
60
|
+
description: 'Write or overwrite a file with content',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
path: { type: 'string', description: 'Path to the file to write' },
|
|
65
|
+
content: { type: 'string', description: 'Content to write to the file' },
|
|
66
|
+
},
|
|
67
|
+
required: ['path', 'content'],
|
|
68
|
+
},
|
|
69
|
+
execution: {
|
|
70
|
+
id: 'std_write_file',
|
|
71
|
+
type: 'file',
|
|
72
|
+
op: 'write',
|
|
73
|
+
path: '${{ args.path }}',
|
|
74
|
+
content: '${{ args.content }}',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'list_files',
|
|
79
|
+
description: 'List files in a directory',
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
path: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'Directory path (defaults to current directory)',
|
|
86
|
+
default: '.',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
execution: {
|
|
91
|
+
id: 'std_list_files',
|
|
92
|
+
type: 'script',
|
|
93
|
+
run: `
|
|
94
|
+
const fs = require('node:fs');
|
|
95
|
+
const path = require('node:path');
|
|
96
|
+
const dir = args.path || '.';
|
|
97
|
+
if (fs.existsSync(dir)) {
|
|
98
|
+
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
99
|
+
return files.map(f => ({
|
|
100
|
+
name: f.name,
|
|
101
|
+
type: f.isDirectory() ? 'directory' : 'file',
|
|
102
|
+
size: f.isFile() ? fs.statSync(path.join(dir, f.name)).size : undefined
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
throw new Error('Directory not found: ' + dir);
|
|
106
|
+
`,
|
|
107
|
+
allowInsecure: true,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'search_files',
|
|
112
|
+
description: 'Search for files by pattern (glob)',
|
|
113
|
+
parameters: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
pattern: { type: 'string', description: 'Glob pattern (e.g. **/*.ts)' },
|
|
117
|
+
dir: { type: 'string', description: 'Directory to search in', default: '.' },
|
|
118
|
+
},
|
|
119
|
+
required: ['pattern'],
|
|
120
|
+
},
|
|
121
|
+
execution: {
|
|
122
|
+
id: 'std_search_files',
|
|
123
|
+
type: 'script',
|
|
124
|
+
run: `
|
|
125
|
+
const fs = require('node:fs');
|
|
126
|
+
const path = require('node:path');
|
|
127
|
+
const { globSync } = require('glob');
|
|
128
|
+
const dir = args.dir || '.';
|
|
129
|
+
const pattern = args.pattern;
|
|
130
|
+
try {
|
|
131
|
+
return globSync(pattern, { cwd: dir, nodir: true });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
throw new Error('Search failed: ' + e.message);
|
|
134
|
+
}
|
|
135
|
+
`,
|
|
136
|
+
allowInsecure: true,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'search_content',
|
|
141
|
+
description: 'Search for a string or regex within files',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
query: { type: 'string', description: 'String or regex to search for' },
|
|
146
|
+
pattern: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: 'Glob pattern of files to search in',
|
|
149
|
+
default: '**/*',
|
|
150
|
+
},
|
|
151
|
+
dir: { type: 'string', description: 'Directory to search in', default: '.' },
|
|
152
|
+
},
|
|
153
|
+
required: ['query'],
|
|
154
|
+
},
|
|
155
|
+
execution: {
|
|
156
|
+
id: 'std_search_content',
|
|
157
|
+
type: 'script',
|
|
158
|
+
run: `
|
|
159
|
+
const fs = require('node:fs');
|
|
160
|
+
const path = require('node:path');
|
|
161
|
+
const { globSync } = require('glob');
|
|
162
|
+
const dir = args.dir || '.';
|
|
163
|
+
const pattern = args.pattern || '**/*';
|
|
164
|
+
const query = args.query;
|
|
165
|
+
if (query.length > 500) {
|
|
166
|
+
throw new Error('Search query exceeds maximum length of 500 characters');
|
|
167
|
+
}
|
|
168
|
+
const isRegex = query.startsWith('/') && query.endsWith('/');
|
|
169
|
+
let regex;
|
|
170
|
+
try {
|
|
171
|
+
regex = isRegex ? new RegExp(query.slice(1, -1)) : new RegExp(query.replace(/[.*+?^$\\{}()|[\\]\\\\]/g, '\\\\$&'), 'i');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
throw new Error('Invalid regular expression: ' + e.message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const files = globSync(pattern, { cwd: dir, nodir: true });
|
|
177
|
+
const results = [];
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const fullPath = path.join(dir, file);
|
|
180
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
181
|
+
const lines = content.split('\\n');
|
|
182
|
+
for (let i = 0; i < lines.length; i++) {
|
|
183
|
+
if (regex.test(lines[i])) {
|
|
184
|
+
results.push({
|
|
185
|
+
file,
|
|
186
|
+
line: i + 1,
|
|
187
|
+
content: lines[i].trim()
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (results.length > 100) break; // Limit results
|
|
191
|
+
}
|
|
192
|
+
if (results.length > 100) break;
|
|
193
|
+
}
|
|
194
|
+
return results;
|
|
195
|
+
`,
|
|
196
|
+
allowInsecure: true,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'run_command',
|
|
201
|
+
description: 'Run a shell command',
|
|
202
|
+
parameters: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
command: { type: 'string', description: 'The shell command to run' },
|
|
206
|
+
dir: { type: 'string', description: 'Working directory for the command' },
|
|
207
|
+
},
|
|
208
|
+
required: ['command'],
|
|
209
|
+
},
|
|
210
|
+
execution: {
|
|
211
|
+
id: 'std_run_command',
|
|
212
|
+
type: 'shell',
|
|
213
|
+
run: '${{ args.command }}',
|
|
214
|
+
dir: '${{ args.dir }}',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate that a tool call is safe to execute based on the LLM step's security flags.
|
|
221
|
+
*/
|
|
222
|
+
export function validateStandardToolSecurity(
|
|
223
|
+
toolName: string,
|
|
224
|
+
// biome-ignore lint/suspicious/noExplicitAny: arguments can be any shape
|
|
225
|
+
args: any,
|
|
226
|
+
options: { allowOutsideCwd?: boolean; allowInsecure?: boolean }
|
|
227
|
+
): void {
|
|
228
|
+
// 1. Check path traversal for file tools
|
|
229
|
+
if (
|
|
230
|
+
[
|
|
231
|
+
'read_file',
|
|
232
|
+
'read_file_lines',
|
|
233
|
+
'write_file',
|
|
234
|
+
'list_files',
|
|
235
|
+
'search_files',
|
|
236
|
+
'search_content',
|
|
237
|
+
].includes(toolName)
|
|
238
|
+
) {
|
|
239
|
+
const rawPath = args.path || args.dir || '.';
|
|
240
|
+
const cwd = process.cwd();
|
|
241
|
+
const resolvedPath = path.resolve(cwd, rawPath);
|
|
242
|
+
const realCwd = fs.realpathSync(cwd);
|
|
243
|
+
|
|
244
|
+
const isWithin = (target: string) => {
|
|
245
|
+
// Find the first existing ancestor to resolve the real path correctly
|
|
246
|
+
let current = target;
|
|
247
|
+
while (current !== path.dirname(current) && !fs.existsSync(current)) {
|
|
248
|
+
current = path.dirname(current);
|
|
249
|
+
}
|
|
250
|
+
const realTarget = fs.existsSync(current) ? fs.realpathSync(current) : current;
|
|
251
|
+
const relativePath = path.relative(realCwd, realTarget);
|
|
252
|
+
return !(relativePath.startsWith('..') || path.isAbsolute(relativePath));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (!options.allowOutsideCwd && !isWithin(resolvedPath)) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Access denied: Path '${rawPath}' resolves outside the working directory. Use 'allowOutsideCwd: true' to override.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 2. Check shell risk for run_command
|
|
263
|
+
if (toolName === 'run_command' && !options.allowInsecure) {
|
|
264
|
+
if (detectShellInjectionRisk(args.command)) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Security Error: Command contains risky shell characters. Use 'allowInsecure: true' on the llm step to execute this.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -13,6 +13,7 @@ import * as dns from 'node:dns/promises';
|
|
|
13
13
|
import { mkdirSync, rmSync } from 'node:fs';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
+
import type { MemoryDb } from '../db/memory-db';
|
|
16
17
|
import type { ExpressionContext } from '../expression/evaluator';
|
|
17
18
|
import type {
|
|
18
19
|
FileStep,
|
|
@@ -22,6 +23,8 @@ import type {
|
|
|
22
23
|
SleepStep,
|
|
23
24
|
WorkflowStep,
|
|
24
25
|
} from '../parser/schema';
|
|
26
|
+
import type { SafeSandbox } from '../utils/sandbox';
|
|
27
|
+
import type { getAdapter } from './llm-adapter';
|
|
25
28
|
import { executeStep } from './step-executor';
|
|
26
29
|
|
|
27
30
|
// Mock executeLlmStep
|
|
@@ -227,6 +230,196 @@ describe('step-executor', () => {
|
|
|
227
230
|
}
|
|
228
231
|
}
|
|
229
232
|
});
|
|
233
|
+
|
|
234
|
+
it('should block path traversal outside cwd by default', async () => {
|
|
235
|
+
const outsidePath = join(process.cwd(), '..', 'outside.txt');
|
|
236
|
+
const step: FileStep = {
|
|
237
|
+
id: 'f1',
|
|
238
|
+
type: 'file',
|
|
239
|
+
needs: [],
|
|
240
|
+
op: 'read',
|
|
241
|
+
path: outsidePath,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const result = await executeStep(step, context);
|
|
245
|
+
expect(result.status).toBe('failed');
|
|
246
|
+
expect(result.error).toContain('Access denied');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should block path traversal with .. inside path resolving outside', async () => {
|
|
250
|
+
const outsidePath = 'foo/../../passwd';
|
|
251
|
+
const step: FileStep = {
|
|
252
|
+
id: 'f1',
|
|
253
|
+
type: 'file',
|
|
254
|
+
needs: [],
|
|
255
|
+
op: 'read',
|
|
256
|
+
path: outsidePath,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const result = await executeStep(step, context);
|
|
260
|
+
expect(result.status).toBe('failed');
|
|
261
|
+
expect(result.error).toContain('Access denied');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('script', () => {
|
|
266
|
+
const mockSandbox = {
|
|
267
|
+
execute: mock((code) => {
|
|
268
|
+
if (code === 'fail') throw new Error('Script failed');
|
|
269
|
+
return Promise.resolve('script-result');
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
it('should fail if allowInsecure is not set', async () => {
|
|
274
|
+
// @ts-ignore
|
|
275
|
+
const step = {
|
|
276
|
+
id: 's1',
|
|
277
|
+
type: 'script',
|
|
278
|
+
run: 'console.log("hello")',
|
|
279
|
+
};
|
|
280
|
+
const result = await executeStep(step, context, undefined, {
|
|
281
|
+
sandbox: mockSandbox as unknown as typeof SafeSandbox,
|
|
282
|
+
});
|
|
283
|
+
expect(result.status).toBe('failed');
|
|
284
|
+
expect(result.error).toContain('Script execution is disabled by default');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should execute script if allowInsecure is true', async () => {
|
|
288
|
+
// @ts-ignore
|
|
289
|
+
const step = {
|
|
290
|
+
id: 's1',
|
|
291
|
+
type: 'script',
|
|
292
|
+
run: 'console.log("hello")',
|
|
293
|
+
allowInsecure: true,
|
|
294
|
+
};
|
|
295
|
+
const result = await executeStep(step, context, undefined, {
|
|
296
|
+
sandbox: mockSandbox as unknown as typeof SafeSandbox,
|
|
297
|
+
});
|
|
298
|
+
expect(result.status).toBe('success');
|
|
299
|
+
expect(result.output).toBe('script-result');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should handle script failure', async () => {
|
|
303
|
+
// @ts-ignore
|
|
304
|
+
const step = {
|
|
305
|
+
id: 's1',
|
|
306
|
+
type: 'script',
|
|
307
|
+
run: 'fail',
|
|
308
|
+
allowInsecure: true,
|
|
309
|
+
};
|
|
310
|
+
const result = await executeStep(step, context, undefined, {
|
|
311
|
+
sandbox: mockSandbox as unknown as typeof SafeSandbox,
|
|
312
|
+
});
|
|
313
|
+
expect(result.status).toBe('failed');
|
|
314
|
+
expect(result.error).toBe('Script failed');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('memory', () => {
|
|
319
|
+
const mockMemoryDb = {
|
|
320
|
+
store: mock(() => Promise.resolve('mem-id')),
|
|
321
|
+
search: mock(() => Promise.resolve([{ content: 'found', similarity: 0.9 }])),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const mockGetAdapter = mock((model) => {
|
|
325
|
+
if (model === 'no-embed') return { adapter: {}, resolvedModel: model };
|
|
326
|
+
return {
|
|
327
|
+
adapter: {
|
|
328
|
+
embed: mock((text) => Promise.resolve([0.1, 0.2, 0.3])),
|
|
329
|
+
},
|
|
330
|
+
resolvedModel: model,
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should fail if memoryDb is not provided', async () => {
|
|
335
|
+
// @ts-ignore
|
|
336
|
+
const step = { id: 'm1', type: 'memory', op: 'store', text: 'foo' };
|
|
337
|
+
const result = await executeStep(step, context, undefined, {
|
|
338
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
339
|
+
});
|
|
340
|
+
expect(result.status).toBe('failed');
|
|
341
|
+
expect(result.error).toBe('Memory database not initialized');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should fail if adapter does not support embedding', async () => {
|
|
345
|
+
// @ts-ignore
|
|
346
|
+
const step = { id: 'm1', type: 'memory', op: 'store', text: 'foo', model: 'no-embed' };
|
|
347
|
+
// @ts-ignore
|
|
348
|
+
const result = await executeStep(step, context, undefined, {
|
|
349
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
350
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
351
|
+
});
|
|
352
|
+
expect(result.status).toBe('failed');
|
|
353
|
+
expect(result.error).toContain('does not support embeddings');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should store memory', async () => {
|
|
357
|
+
// @ts-ignore
|
|
358
|
+
const step = {
|
|
359
|
+
id: 'm1',
|
|
360
|
+
type: 'memory',
|
|
361
|
+
op: 'store',
|
|
362
|
+
text: 'foo',
|
|
363
|
+
metadata: { source: 'test' },
|
|
364
|
+
};
|
|
365
|
+
// @ts-ignore
|
|
366
|
+
const result = await executeStep(step, context, undefined, {
|
|
367
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
368
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
369
|
+
});
|
|
370
|
+
expect(result.status).toBe('success');
|
|
371
|
+
expect(result.output).toEqual({ id: 'mem-id', status: 'stored' });
|
|
372
|
+
expect(mockMemoryDb.store).toHaveBeenCalledWith('foo', [0.1, 0.2, 0.3], { source: 'test' });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should search memory', async () => {
|
|
376
|
+
// @ts-ignore
|
|
377
|
+
const step = { id: 'm1', type: 'memory', op: 'search', query: 'foo', limit: 5 };
|
|
378
|
+
// @ts-ignore
|
|
379
|
+
const result = await executeStep(step, context, undefined, {
|
|
380
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
381
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
382
|
+
});
|
|
383
|
+
expect(result.status).toBe('success');
|
|
384
|
+
expect(result.output).toEqual([{ content: 'found', similarity: 0.9 }]);
|
|
385
|
+
expect(mockMemoryDb.search).toHaveBeenCalledWith([0.1, 0.2, 0.3], 5);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should fail store if text is missing', async () => {
|
|
389
|
+
// @ts-ignore
|
|
390
|
+
const step = { id: 'm1', type: 'memory', op: 'store' };
|
|
391
|
+
// @ts-ignore
|
|
392
|
+
const result = await executeStep(step, context, undefined, {
|
|
393
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
394
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
395
|
+
});
|
|
396
|
+
expect(result.status).toBe('failed');
|
|
397
|
+
expect(result.error).toBe('Text is required for memory store operation');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should fail search if query is missing', async () => {
|
|
401
|
+
// @ts-ignore
|
|
402
|
+
const step = { id: 'm1', type: 'memory', op: 'search' };
|
|
403
|
+
// @ts-ignore
|
|
404
|
+
const result = await executeStep(step, context, undefined, {
|
|
405
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
406
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
407
|
+
});
|
|
408
|
+
expect(result.status).toBe('failed');
|
|
409
|
+
expect(result.error).toBe('Query is required for memory search operation');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should fail for unknown memory operation', async () => {
|
|
413
|
+
// @ts-ignore
|
|
414
|
+
const step = { id: 'm1', type: 'memory', op: 'unknown', text: 'foo' };
|
|
415
|
+
// @ts-ignore
|
|
416
|
+
const result = await executeStep(step, context, undefined, {
|
|
417
|
+
memoryDb: mockMemoryDb as unknown as MemoryDb,
|
|
418
|
+
getAdapter: mockGetAdapter as unknown as typeof getAdapter,
|
|
419
|
+
});
|
|
420
|
+
expect(result.status).toBe('failed');
|
|
421
|
+
expect(result.error).toContain('Unknown memory operation');
|
|
422
|
+
});
|
|
230
423
|
});
|
|
231
424
|
|
|
232
425
|
describe('sleep', () => {
|
|
@@ -517,7 +710,7 @@ describe('step-executor', () => {
|
|
|
517
710
|
);
|
|
518
711
|
|
|
519
712
|
// @ts-ignore
|
|
520
|
-
const result = await executeStep(step, context, undefined, executeWorkflowFn);
|
|
713
|
+
const result = await executeStep(step, context, undefined, { executeWorkflowFn });
|
|
521
714
|
expect(result.status).toBe('success');
|
|
522
715
|
expect(result.output).toBe('child-output');
|
|
523
716
|
expect(executeWorkflowFn).toHaveBeenCalled();
|
|
@@ -48,6 +48,20 @@ export interface StepResult {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Execute a single step based on its type
|
|
53
|
+
*/
|
|
54
|
+
export interface StepExecutorOptions {
|
|
55
|
+
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>;
|
|
56
|
+
mcpManager?: MCPManager;
|
|
57
|
+
memoryDb?: MemoryDb;
|
|
58
|
+
workflowDir?: string;
|
|
59
|
+
dryRun?: boolean;
|
|
60
|
+
// Dependency injection for testing
|
|
61
|
+
getAdapter?: typeof getAdapter;
|
|
62
|
+
sandbox?: typeof SafeSandbox;
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
/**
|
|
52
66
|
* Execute a single step based on its type
|
|
53
67
|
*/
|
|
@@ -55,12 +69,18 @@ export async function executeStep(
|
|
|
55
69
|
step: Step,
|
|
56
70
|
context: ExpressionContext,
|
|
57
71
|
logger: Logger = new ConsoleLogger(),
|
|
58
|
-
|
|
59
|
-
mcpManager?: MCPManager,
|
|
60
|
-
memoryDb?: MemoryDb,
|
|
61
|
-
workflowDir?: string,
|
|
62
|
-
dryRun?: boolean
|
|
72
|
+
options: StepExecutorOptions = {}
|
|
63
73
|
): Promise<StepResult> {
|
|
74
|
+
const {
|
|
75
|
+
executeWorkflowFn,
|
|
76
|
+
mcpManager,
|
|
77
|
+
memoryDb,
|
|
78
|
+
workflowDir,
|
|
79
|
+
dryRun,
|
|
80
|
+
getAdapter: injectedGetAdapter,
|
|
81
|
+
sandbox: injectedSandbox,
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
64
84
|
try {
|
|
65
85
|
let result: StepResult;
|
|
66
86
|
switch (step.type) {
|
|
@@ -83,15 +103,14 @@ export async function executeStep(
|
|
|
83
103
|
result = await executeLlmStep(
|
|
84
104
|
step,
|
|
85
105
|
context,
|
|
86
|
-
(s, c) =>
|
|
87
|
-
executeStep(s, c, logger, executeWorkflowFn, mcpManager, memoryDb, workflowDir, dryRun),
|
|
106
|
+
(s, c) => executeStep(s, c, logger, options),
|
|
88
107
|
logger,
|
|
89
108
|
mcpManager,
|
|
90
109
|
workflowDir
|
|
91
110
|
);
|
|
92
111
|
break;
|
|
93
112
|
case 'memory':
|
|
94
|
-
result = await executeMemoryStep(step, context, logger, memoryDb);
|
|
113
|
+
result = await executeMemoryStep(step, context, logger, memoryDb, injectedGetAdapter);
|
|
95
114
|
break;
|
|
96
115
|
case 'workflow':
|
|
97
116
|
if (!executeWorkflowFn) {
|
|
@@ -100,7 +119,7 @@ export async function executeStep(
|
|
|
100
119
|
result = await executeWorkflowFn(step, context);
|
|
101
120
|
break;
|
|
102
121
|
case 'script':
|
|
103
|
-
result = await executeScriptStep(step, context, logger);
|
|
122
|
+
result = await executeScriptStep(step, context, logger, injectedSandbox);
|
|
104
123
|
break;
|
|
105
124
|
default:
|
|
106
125
|
throw new Error(`Unknown step type: ${(step as Step).type}`);
|
|
@@ -383,7 +402,13 @@ async function executeRequestStep(
|
|
|
383
402
|
output: {
|
|
384
403
|
status: response.status,
|
|
385
404
|
statusText: response.statusText,
|
|
386
|
-
headers:
|
|
405
|
+
headers: (() => {
|
|
406
|
+
const h: Record<string, string> = {};
|
|
407
|
+
response.headers.forEach((v, k) => {
|
|
408
|
+
h[k] = v;
|
|
409
|
+
});
|
|
410
|
+
return h;
|
|
411
|
+
})(),
|
|
387
412
|
data: responseData,
|
|
388
413
|
},
|
|
389
414
|
status: response.ok ? 'success' : 'failed',
|
|
@@ -416,7 +441,11 @@ async function executeHumanStep(
|
|
|
416
441
|
return {
|
|
417
442
|
output:
|
|
418
443
|
step.inputType === 'confirm'
|
|
419
|
-
? answer === true ||
|
|
444
|
+
? answer === true ||
|
|
445
|
+
(typeof answer === 'string' &&
|
|
446
|
+
(answer.toLowerCase() === 'true' ||
|
|
447
|
+
answer.toLowerCase() === 'yes' ||
|
|
448
|
+
answer.toLowerCase() === 'y'))
|
|
420
449
|
: answer,
|
|
421
450
|
status: 'success',
|
|
422
451
|
};
|
|
@@ -503,7 +532,8 @@ async function executeSleepStep(
|
|
|
503
532
|
async function executeScriptStep(
|
|
504
533
|
step: ScriptStep,
|
|
505
534
|
context: ExpressionContext,
|
|
506
|
-
_logger: Logger
|
|
535
|
+
_logger: Logger,
|
|
536
|
+
sandbox = SafeSandbox
|
|
507
537
|
): Promise<StepResult> {
|
|
508
538
|
try {
|
|
509
539
|
if (!step.allowInsecure) {
|
|
@@ -513,7 +543,7 @@ async function executeScriptStep(
|
|
|
513
543
|
);
|
|
514
544
|
}
|
|
515
545
|
|
|
516
|
-
const result = await
|
|
546
|
+
const result = await sandbox.execute(
|
|
517
547
|
step.run,
|
|
518
548
|
{
|
|
519
549
|
inputs: context.inputs,
|
|
@@ -546,14 +576,15 @@ async function executeMemoryStep(
|
|
|
546
576
|
step: MemoryStep,
|
|
547
577
|
context: ExpressionContext,
|
|
548
578
|
logger: Logger,
|
|
549
|
-
memoryDb?: MemoryDb
|
|
579
|
+
memoryDb?: MemoryDb,
|
|
580
|
+
getAdapterFn = getAdapter
|
|
550
581
|
): Promise<StepResult> {
|
|
551
582
|
if (!memoryDb) {
|
|
552
583
|
throw new Error('Memory database not initialized');
|
|
553
584
|
}
|
|
554
585
|
|
|
555
586
|
try {
|
|
556
|
-
const { adapter, resolvedModel } =
|
|
587
|
+
const { adapter, resolvedModel } = getAdapterFn(step.model || 'local');
|
|
557
588
|
if (!adapter.embed) {
|
|
558
589
|
throw new Error(`Provider for model ${step.model || 'local'} does not support embeddings`);
|
|
559
590
|
}
|