keystone-cli 0.5.0 → 0.6.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 +55 -8
- package/package.json +5 -3
- package/src/cli.ts +33 -192
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +122 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +84 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +74 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +27 -14
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +91 -6
- package/src/runner/llm-executor.ts +26 -6
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +46 -1
- package/src/runner/step-executor.ts +154 -60
- package/src/runner/stream-utils.test.ts +65 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +436 -251
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- package/src/utils/redactor.ts +48 -13
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { MemoryDb } from '../db/memory-db.ts';
|
|
1
2
|
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
2
3
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
3
4
|
// Removed synchronous file I/O imports - using Bun's async file API instead
|
|
4
5
|
import type {
|
|
5
6
|
FileStep,
|
|
6
7
|
HumanStep,
|
|
8
|
+
MemoryStep,
|
|
7
9
|
RequestStep,
|
|
8
10
|
ScriptStep,
|
|
9
11
|
ShellStep,
|
|
@@ -11,12 +13,17 @@ import type {
|
|
|
11
13
|
Step,
|
|
12
14
|
WorkflowStep,
|
|
13
15
|
} from '../parser/schema.ts';
|
|
16
|
+
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
17
|
+
import { getAdapter } from './llm-adapter.ts';
|
|
14
18
|
import { detectShellInjectionRisk, executeShell } from './shell-executor.ts';
|
|
15
|
-
import type { Logger } from './workflow-runner.ts';
|
|
16
19
|
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
17
23
|
import * as readline from 'node:readline/promises';
|
|
18
24
|
import { SafeSandbox } from '../utils/sandbox.ts';
|
|
19
25
|
import { executeLlmStep } from './llm-executor.ts';
|
|
26
|
+
import { validateRemoteUrl } from './mcp-client.ts';
|
|
20
27
|
import type { MCPManager } from './mcp-manager.ts';
|
|
21
28
|
|
|
22
29
|
export class WorkflowSuspendedError extends Error {
|
|
@@ -47,9 +54,10 @@ export interface StepResult {
|
|
|
47
54
|
export async function executeStep(
|
|
48
55
|
step: Step,
|
|
49
56
|
context: ExpressionContext,
|
|
50
|
-
logger: Logger =
|
|
57
|
+
logger: Logger = new ConsoleLogger(),
|
|
51
58
|
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
|
|
52
59
|
mcpManager?: MCPManager,
|
|
60
|
+
memoryDb?: MemoryDb,
|
|
53
61
|
workflowDir?: string,
|
|
54
62
|
dryRun?: boolean
|
|
55
63
|
): Promise<StepResult> {
|
|
@@ -75,12 +83,16 @@ export async function executeStep(
|
|
|
75
83
|
result = await executeLlmStep(
|
|
76
84
|
step,
|
|
77
85
|
context,
|
|
78
|
-
(s, c) =>
|
|
86
|
+
(s, c) =>
|
|
87
|
+
executeStep(s, c, logger, executeWorkflowFn, mcpManager, memoryDb, workflowDir, dryRun),
|
|
79
88
|
logger,
|
|
80
89
|
mcpManager,
|
|
81
90
|
workflowDir
|
|
82
91
|
);
|
|
83
92
|
break;
|
|
93
|
+
case 'memory':
|
|
94
|
+
result = await executeMemoryStep(step, context, logger, memoryDb);
|
|
95
|
+
break;
|
|
84
96
|
case 'workflow':
|
|
85
97
|
if (!executeWorkflowFn) {
|
|
86
98
|
throw new Error('Workflow executor not provided');
|
|
@@ -150,44 +162,10 @@ async function executeShellStep(
|
|
|
150
162
|
const command = ExpressionEvaluator.evaluateString(step.run, context);
|
|
151
163
|
const isRisky = detectShellInjectionRisk(command);
|
|
152
164
|
|
|
153
|
-
if (isRisky) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
: undefined;
|
|
158
|
-
if (
|
|
159
|
-
stepInputs &&
|
|
160
|
-
typeof stepInputs === 'object' &&
|
|
161
|
-
'__approved' in stepInputs &&
|
|
162
|
-
stepInputs.__approved === true
|
|
163
|
-
) {
|
|
164
|
-
// Already approved, proceed
|
|
165
|
-
} else {
|
|
166
|
-
const message = `Potentially risky shell command detected: ${command}`;
|
|
167
|
-
|
|
168
|
-
if (!process.stdin.isTTY) {
|
|
169
|
-
return {
|
|
170
|
-
output: null,
|
|
171
|
-
status: 'suspended',
|
|
172
|
-
error: `APPROVAL_REQUIRED: ${message}`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const rl = readline.createInterface({
|
|
177
|
-
input: process.stdin,
|
|
178
|
-
output: process.stdout,
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
logger.warn(`\n⚠️ ${message}`);
|
|
183
|
-
const answer = (await rl.question('Do you want to execute this command? (y/N): ')).trim();
|
|
184
|
-
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
185
|
-
throw new Error('Command execution denied by user');
|
|
186
|
-
}
|
|
187
|
-
} finally {
|
|
188
|
-
rl.close();
|
|
189
|
-
}
|
|
190
|
-
}
|
|
165
|
+
if (isRisky && !step.allowInsecure) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Security Error: Command contains shell metacharacters that may indicate injection risk.\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To execute this command, set 'allowInsecure: true' on the step definition.`
|
|
168
|
+
);
|
|
191
169
|
}
|
|
192
170
|
|
|
193
171
|
const result = await executeShell(step, context, logger);
|
|
@@ -227,22 +205,62 @@ async function executeFileStep(
|
|
|
227
205
|
_logger: Logger,
|
|
228
206
|
dryRun?: boolean
|
|
229
207
|
): Promise<StepResult> {
|
|
230
|
-
const
|
|
208
|
+
const rawPath = ExpressionEvaluator.evaluateString(step.path, context);
|
|
209
|
+
|
|
210
|
+
// Security: Prevent path traversal
|
|
211
|
+
const cwd = process.cwd();
|
|
212
|
+
const resolvedPath = path.resolve(cwd, rawPath);
|
|
213
|
+
const realCwd = fs.realpathSync(cwd);
|
|
214
|
+
const isWithin = (target: string) => {
|
|
215
|
+
const relativePath = path.relative(realCwd, target);
|
|
216
|
+
return !(relativePath.startsWith('..') || path.isAbsolute(relativePath));
|
|
217
|
+
};
|
|
218
|
+
const getExistingAncestorRealPath = (start: string) => {
|
|
219
|
+
let current = start;
|
|
220
|
+
while (!fs.existsSync(current)) {
|
|
221
|
+
const parent = path.dirname(current);
|
|
222
|
+
if (parent === current) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
current = parent;
|
|
226
|
+
}
|
|
227
|
+
if (!fs.existsSync(current)) {
|
|
228
|
+
return realCwd;
|
|
229
|
+
}
|
|
230
|
+
return fs.realpathSync(current);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (!step.allowOutsideCwd) {
|
|
234
|
+
if (fs.existsSync(resolvedPath)) {
|
|
235
|
+
const realTarget = fs.realpathSync(resolvedPath);
|
|
236
|
+
if (!isWithin(realTarget)) {
|
|
237
|
+
throw new Error(`Access denied: Path '${rawPath}' resolves outside the working directory.`);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
const realParent = getExistingAncestorRealPath(path.dirname(resolvedPath));
|
|
241
|
+
if (!isWithin(realParent)) {
|
|
242
|
+
throw new Error(`Access denied: Path '${rawPath}' resolves outside the working directory.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Use resolved path for operations
|
|
248
|
+
const targetPath = resolvedPath;
|
|
231
249
|
|
|
232
250
|
if (dryRun && step.op !== 'read') {
|
|
233
251
|
const opVerb = step.op === 'write' ? 'write to' : 'append to';
|
|
234
|
-
_logger.log(`[DRY RUN] Would ${opVerb} file: ${
|
|
252
|
+
_logger.log(`[DRY RUN] Would ${opVerb} file: ${targetPath}`);
|
|
235
253
|
return {
|
|
236
|
-
output: { path, bytes: 0 },
|
|
254
|
+
output: { path: targetPath, bytes: 0 },
|
|
237
255
|
status: 'success',
|
|
238
256
|
};
|
|
239
257
|
}
|
|
240
258
|
|
|
241
259
|
switch (step.op) {
|
|
242
260
|
case 'read': {
|
|
243
|
-
const file = Bun.file(
|
|
261
|
+
const file = Bun.file(targetPath);
|
|
244
262
|
if (!(await file.exists())) {
|
|
245
|
-
throw new Error(`File not found: ${
|
|
263
|
+
throw new Error(`File not found: ${targetPath}`);
|
|
246
264
|
}
|
|
247
265
|
const content = await file.text();
|
|
248
266
|
return {
|
|
@@ -258,14 +276,14 @@ async function executeFileStep(
|
|
|
258
276
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
259
277
|
|
|
260
278
|
// Ensure parent directory exists
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
279
|
+
const dir = path.dirname(targetPath);
|
|
280
|
+
if (!fs.existsSync(dir)) {
|
|
281
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
282
|
+
}
|
|
265
283
|
|
|
266
|
-
|
|
284
|
+
await Bun.write(targetPath, content);
|
|
267
285
|
return {
|
|
268
|
-
output: { path, bytes },
|
|
286
|
+
output: { path: targetPath, bytes: content.length },
|
|
269
287
|
status: 'success',
|
|
270
288
|
};
|
|
271
289
|
}
|
|
@@ -277,16 +295,15 @@ async function executeFileStep(
|
|
|
277
295
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
278
296
|
|
|
279
297
|
// Ensure parent directory exists
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
298
|
+
const dir = path.dirname(targetPath);
|
|
299
|
+
if (!fs.existsSync(dir)) {
|
|
300
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
301
|
+
}
|
|
284
302
|
|
|
285
|
-
|
|
286
|
-
await fs.appendFile(path, content, 'utf-8');
|
|
303
|
+
fs.appendFileSync(targetPath, content);
|
|
287
304
|
|
|
288
305
|
return {
|
|
289
|
-
output: { path, bytes: content.length },
|
|
306
|
+
output: { path: targetPath, bytes: content.length },
|
|
290
307
|
status: 'success',
|
|
291
308
|
};
|
|
292
309
|
}
|
|
@@ -306,6 +323,9 @@ async function executeRequestStep(
|
|
|
306
323
|
): Promise<StepResult> {
|
|
307
324
|
const url = ExpressionEvaluator.evaluateString(step.url, context);
|
|
308
325
|
|
|
326
|
+
// Validate URL to prevent SSRF
|
|
327
|
+
await validateRemoteUrl(url);
|
|
328
|
+
|
|
309
329
|
// Evaluate headers
|
|
310
330
|
const headers: Record<string, string> = {};
|
|
311
331
|
if (step.headers) {
|
|
@@ -486,6 +506,13 @@ async function executeScriptStep(
|
|
|
486
506
|
_logger: Logger
|
|
487
507
|
): Promise<StepResult> {
|
|
488
508
|
try {
|
|
509
|
+
if (!step.allowInsecure) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
'Script execution is disabled by default because Bun uses an insecure VM sandbox. ' +
|
|
512
|
+
"Set 'allowInsecure: true' on the script step to run it anyway."
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
489
516
|
const result = await SafeSandbox.execute(
|
|
490
517
|
step.run,
|
|
491
518
|
{
|
|
@@ -495,7 +522,7 @@ async function executeScriptStep(
|
|
|
495
522
|
env: context.env,
|
|
496
523
|
},
|
|
497
524
|
{
|
|
498
|
-
|
|
525
|
+
timeout: step.timeout,
|
|
499
526
|
}
|
|
500
527
|
);
|
|
501
528
|
|
|
@@ -511,3 +538,70 @@ async function executeScriptStep(
|
|
|
511
538
|
};
|
|
512
539
|
}
|
|
513
540
|
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Execute a memory operation (search or store)
|
|
544
|
+
*/
|
|
545
|
+
async function executeMemoryStep(
|
|
546
|
+
step: MemoryStep,
|
|
547
|
+
context: ExpressionContext,
|
|
548
|
+
logger: Logger,
|
|
549
|
+
memoryDb?: MemoryDb
|
|
550
|
+
): Promise<StepResult> {
|
|
551
|
+
if (!memoryDb) {
|
|
552
|
+
throw new Error('Memory database not initialized');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const { adapter, resolvedModel } = getAdapter(step.model || 'local');
|
|
557
|
+
if (!adapter.embed) {
|
|
558
|
+
throw new Error(`Provider for model ${step.model || 'local'} does not support embeddings`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (step.op === 'store') {
|
|
562
|
+
const text = step.text ? ExpressionEvaluator.evaluateString(step.text, context) : '';
|
|
563
|
+
if (!text) {
|
|
564
|
+
throw new Error('Text is required for memory store operation');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
logger.log(
|
|
568
|
+
` 💾 Storing in memory: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`
|
|
569
|
+
);
|
|
570
|
+
const embedding = await adapter.embed(text, resolvedModel);
|
|
571
|
+
const metadata = step.metadata
|
|
572
|
+
? // biome-ignore lint/suspicious/noExplicitAny: metadata typing
|
|
573
|
+
(ExpressionEvaluator.evaluateObject(step.metadata, context) as Record<string, any>)
|
|
574
|
+
: {};
|
|
575
|
+
|
|
576
|
+
const id = await memoryDb.store(text, embedding, metadata);
|
|
577
|
+
return {
|
|
578
|
+
output: { id, status: 'stored' },
|
|
579
|
+
status: 'success',
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (step.op === 'search') {
|
|
584
|
+
const query = step.query ? ExpressionEvaluator.evaluateString(step.query, context) : '';
|
|
585
|
+
if (!query) {
|
|
586
|
+
throw new Error('Query is required for memory search operation');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
logger.log(` 🔍 Recalling memory: "${query}"`);
|
|
590
|
+
const embedding = await adapter.embed(query, resolvedModel);
|
|
591
|
+
const results = await memoryDb.search(embedding, step.limit);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
output: results,
|
|
595
|
+
status: 'success',
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
throw new Error(`Unknown memory operation: ${step.op}`);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return {
|
|
602
|
+
output: null,
|
|
603
|
+
status: 'failed',
|
|
604
|
+
error: error instanceof Error ? error.message : String(error),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { processOpenAIStream } from './stream-utils';
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
function responseFromChunks(chunks: string[]): Response {
|
|
7
|
+
const stream = new ReadableStream({
|
|
8
|
+
start(controller) {
|
|
9
|
+
for (const chunk of chunks) {
|
|
10
|
+
controller.enqueue(encoder.encode(chunk));
|
|
11
|
+
}
|
|
12
|
+
controller.close();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return new Response(stream);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('processOpenAIStream', () => {
|
|
20
|
+
it('accumulates content and tool calls across chunks', async () => {
|
|
21
|
+
const onStream = mock(() => {});
|
|
22
|
+
const response = responseFromChunks([
|
|
23
|
+
'data: {"choices":[{"delta":{"content":"hello "}}]}\n',
|
|
24
|
+
'data: {"choices":[{"delta":{"content":"world","tool_calls":[{"index":0,"id":"call_1","function":{"name":"my_tool","arguments":"{\\"arg\\":"}}]}}]}\n',
|
|
25
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]}}]}\n',
|
|
26
|
+
'data: [DONE]\n',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const result = await processOpenAIStream(response, { onStream });
|
|
30
|
+
|
|
31
|
+
expect(result.message.content).toBe('hello world');
|
|
32
|
+
expect(onStream).toHaveBeenCalledTimes(2);
|
|
33
|
+
expect(result.message.tool_calls?.[0]?.function?.name).toBe('my_tool');
|
|
34
|
+
expect(result.message.tool_calls?.[0]?.function?.arguments).toBe('{"arg":1}');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses a final line without a newline', async () => {
|
|
38
|
+
const onStream = mock(() => {});
|
|
39
|
+
const response = responseFromChunks(['data: {"choices":[{"delta":{"content":"tail"}}]}']);
|
|
40
|
+
|
|
41
|
+
const result = await processOpenAIStream(response, { onStream });
|
|
42
|
+
|
|
43
|
+
expect(result.message.content).toBe('tail');
|
|
44
|
+
expect(onStream).toHaveBeenCalledTimes(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('logs malformed JSON and continues processing', async () => {
|
|
48
|
+
const logger = {
|
|
49
|
+
log: mock(() => {}),
|
|
50
|
+
error: mock(() => {}),
|
|
51
|
+
warn: mock(() => {}),
|
|
52
|
+
info: mock(() => {}),
|
|
53
|
+
};
|
|
54
|
+
const response = responseFromChunks([
|
|
55
|
+
'data: {bad json}\n',
|
|
56
|
+
'data: {"choices":[{"delta":{"content":"ok"}}]}\n',
|
|
57
|
+
'data: [DONE]\n',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const result = await processOpenAIStream(response, { logger });
|
|
61
|
+
|
|
62
|
+
expect(result.message.content).toBe('ok');
|
|
63
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
2
|
+
import type { LLMResponse, LLMToolCall } from './llm-adapter.ts';
|
|
3
|
+
|
|
4
|
+
// Maximum response size to prevent memory exhaustion (1MB)
|
|
5
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
6
|
+
const MAX_BUFFER_SIZE = MAX_RESPONSE_SIZE;
|
|
7
|
+
|
|
8
|
+
type ToolCallDelta = {
|
|
9
|
+
index: number;
|
|
10
|
+
id?: string;
|
|
11
|
+
function?: {
|
|
12
|
+
name?: string;
|
|
13
|
+
arguments?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function processOpenAIStream(
|
|
18
|
+
response: Response,
|
|
19
|
+
options?: { onStream?: (chunk: string) => void; logger?: Logger },
|
|
20
|
+
streamLabel = 'OpenAI'
|
|
21
|
+
): Promise<LLMResponse> {
|
|
22
|
+
if (!response.body) throw new Error('Response body is null');
|
|
23
|
+
const reader = response.body.getReader();
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
let fullContent = '';
|
|
26
|
+
const toolCalls: LLMToolCall[] = [];
|
|
27
|
+
let buffer = '';
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
while (true) {
|
|
31
|
+
const { done, value } = await reader.read();
|
|
32
|
+
if (done) break;
|
|
33
|
+
|
|
34
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
35
|
+
buffer += chunk;
|
|
36
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
37
|
+
throw new Error(`LLM stream line exceed maximum size of ${MAX_BUFFER_SIZE} bytes`);
|
|
38
|
+
}
|
|
39
|
+
const lines = buffer.split('\n');
|
|
40
|
+
// Keep the last partial line in the buffer
|
|
41
|
+
buffer = lines.pop() || '';
|
|
42
|
+
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const trimmedLine = line.trim();
|
|
45
|
+
if (trimmedLine === '' || trimmedLine === 'data: [DONE]') continue;
|
|
46
|
+
if (!trimmedLine.startsWith('data: ')) continue;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(trimmedLine.slice(6));
|
|
50
|
+
|
|
51
|
+
// Handle Copilot's occasional 'choices' missing or different structure if needed,
|
|
52
|
+
// but generally they match OpenAI.
|
|
53
|
+
// Some proxies might return null delta.
|
|
54
|
+
const delta = data.choices?.[0]?.delta;
|
|
55
|
+
if (!delta) continue;
|
|
56
|
+
|
|
57
|
+
if (delta.content) {
|
|
58
|
+
if (fullContent.length + delta.content.length > MAX_RESPONSE_SIZE) {
|
|
59
|
+
throw new Error(`LLM response exceeds maximum size of ${MAX_RESPONSE_SIZE} bytes`);
|
|
60
|
+
}
|
|
61
|
+
fullContent += delta.content;
|
|
62
|
+
options?.onStream?.(delta.content);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (delta.tool_calls) {
|
|
66
|
+
for (const tc of delta.tool_calls) {
|
|
67
|
+
const toolCall = tc as ToolCallDelta;
|
|
68
|
+
if (!toolCalls[toolCall.index]) {
|
|
69
|
+
toolCalls[toolCall.index] = {
|
|
70
|
+
id: toolCall.id,
|
|
71
|
+
type: 'function',
|
|
72
|
+
function: { name: '', arguments: '' },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const existing = toolCalls[toolCall.index];
|
|
76
|
+
if (toolCall.function?.name) existing.function.name += toolCall.function.name;
|
|
77
|
+
if (toolCall.function?.arguments) {
|
|
78
|
+
if (
|
|
79
|
+
fullContent.length +
|
|
80
|
+
toolCalls.reduce((acc, t) => acc + (t?.function?.arguments?.length || 0), 0) +
|
|
81
|
+
toolCall.function.arguments.length >
|
|
82
|
+
MAX_RESPONSE_SIZE
|
|
83
|
+
) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`LLM tool call arguments exceed maximum size of ${MAX_RESPONSE_SIZE} bytes`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
existing.function.arguments += toolCall.function.arguments;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
const activeLogger = options?.logger || new ConsoleLogger();
|
|
94
|
+
|
|
95
|
+
// Rethrow size limit errors so they bubble up
|
|
96
|
+
if (String(e).toLowerCase().includes('exceed maximum size')) {
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (e instanceof SyntaxError) {
|
|
101
|
+
activeLogger.warn(
|
|
102
|
+
`[${streamLabel} Stream] Malformed JSON line: ${line.slice(0, 80)}...`
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
activeLogger.warn(`[${streamLabel} Stream] Error processing chunk: ${e}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
try {
|
|
112
|
+
await reader.cancel();
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore cancel errors while bubbling up the original issue.
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Final check for any remaining data in the buffer (in case of no final newline)
|
|
120
|
+
if (buffer.trim()) {
|
|
121
|
+
const trimmedLine = buffer.trim();
|
|
122
|
+
if (trimmedLine.startsWith('data: ') && trimmedLine !== 'data: [DONE]') {
|
|
123
|
+
try {
|
|
124
|
+
const data = JSON.parse(trimmedLine.slice(6));
|
|
125
|
+
const delta = data.choices?.[0]?.delta;
|
|
126
|
+
if (delta) {
|
|
127
|
+
if (delta.content) {
|
|
128
|
+
if (fullContent.length + delta.content.length > MAX_RESPONSE_SIZE) {
|
|
129
|
+
throw new Error(`LLM response exceeds maximum size of ${MAX_RESPONSE_SIZE} bytes`);
|
|
130
|
+
}
|
|
131
|
+
fullContent += delta.content;
|
|
132
|
+
options?.onStream?.(delta.content);
|
|
133
|
+
}
|
|
134
|
+
if (delta.tool_calls) {
|
|
135
|
+
// Tool calls in the very last chunk are unlikely but possible
|
|
136
|
+
for (const tc of delta.tool_calls) {
|
|
137
|
+
const toolCall = tc as ToolCallDelta;
|
|
138
|
+
if (!toolCalls[toolCall.index]) {
|
|
139
|
+
toolCalls[toolCall.index] = {
|
|
140
|
+
id: toolCall.id,
|
|
141
|
+
type: 'function',
|
|
142
|
+
function: { name: '', arguments: '' },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const existing = toolCalls[toolCall.index];
|
|
146
|
+
if (toolCall.function?.name) existing.function.name += toolCall.function.name;
|
|
147
|
+
if (toolCall.function?.arguments) {
|
|
148
|
+
if (
|
|
149
|
+
fullContent.length +
|
|
150
|
+
toolCalls.reduce((acc, t) => acc + (t?.function?.arguments?.length || 0), 0) +
|
|
151
|
+
toolCall.function.arguments.length >
|
|
152
|
+
MAX_RESPONSE_SIZE
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`LLM tool call arguments exceed maximum size of ${MAX_RESPONSE_SIZE} bytes`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
existing.function.arguments += toolCall.function.arguments;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (String(e).toLowerCase().includes('exceed maximum size')) {
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
const activeLogger = options?.logger || new ConsoleLogger();
|
|
168
|
+
if (e instanceof SyntaxError) {
|
|
169
|
+
activeLogger.warn(
|
|
170
|
+
`[${streamLabel} Stream] Malformed JSON line: ${trimmedLine.slice(0, 80)}...`
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
activeLogger.warn(`[${streamLabel} Stream] Error processing final line: ${e}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
message: {
|
|
181
|
+
role: 'assistant',
|
|
182
|
+
content: fullContent || null,
|
|
183
|
+
tool_calls: toolCalls.length > 0 ? toolCalls.filter(Boolean) : undefined,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -457,10 +457,10 @@ describe('WorkflowRunner', () => {
|
|
|
457
457
|
|
|
458
458
|
// Check DB status - parent should be 'paused' and step should be 'suspended'
|
|
459
459
|
const db = new WorkflowDb(resumeDbPath);
|
|
460
|
-
const run = db.getRun(runId);
|
|
460
|
+
const run = await db.getRun(runId);
|
|
461
461
|
expect(run?.status).toBe('paused');
|
|
462
462
|
|
|
463
|
-
const steps = db.getStepsByRun(runId);
|
|
463
|
+
const steps = await db.getStepsByRun(runId);
|
|
464
464
|
const parentStep = steps.find(
|
|
465
465
|
(s: { step_id: string; iteration_index: number | null }) =>
|
|
466
466
|
s.step_id === 'process' && s.iteration_index === null
|
|
@@ -481,8 +481,8 @@ describe('WorkflowRunner', () => {
|
|
|
481
481
|
expect(outputs.results).toEqual(['ok', 'ok']);
|
|
482
482
|
|
|
483
483
|
const finalDb = new WorkflowDb(resumeDbPath);
|
|
484
|
-
const finalRun = finalDb.getRun(runId);
|
|
485
|
-
expect(finalRun?.status).toBe('
|
|
484
|
+
const finalRun = await finalDb.getRun(runId);
|
|
485
|
+
expect(finalRun?.status).toBe('success');
|
|
486
486
|
finalDb.close();
|
|
487
487
|
|
|
488
488
|
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|