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.
Files changed (47) hide show
  1. package/README.md +55 -8
  2. package/package.json +5 -3
  3. package/src/cli.ts +33 -192
  4. package/src/db/memory-db.test.ts +54 -0
  5. package/src/db/memory-db.ts +122 -0
  6. package/src/db/sqlite-setup.ts +49 -0
  7. package/src/db/workflow-db.test.ts +41 -10
  8. package/src/db/workflow-db.ts +84 -28
  9. package/src/expression/evaluator.test.ts +19 -0
  10. package/src/expression/evaluator.ts +134 -39
  11. package/src/parser/schema.ts +41 -0
  12. package/src/runner/audit-verification.test.ts +23 -0
  13. package/src/runner/auto-heal.test.ts +64 -0
  14. package/src/runner/debug-repl.test.ts +74 -0
  15. package/src/runner/debug-repl.ts +225 -0
  16. package/src/runner/foreach-executor.ts +327 -0
  17. package/src/runner/llm-adapter.test.ts +27 -14
  18. package/src/runner/llm-adapter.ts +90 -112
  19. package/src/runner/llm-executor.test.ts +91 -6
  20. package/src/runner/llm-executor.ts +26 -6
  21. package/src/runner/mcp-client.audit.test.ts +69 -0
  22. package/src/runner/mcp-client.test.ts +12 -3
  23. package/src/runner/mcp-client.ts +199 -19
  24. package/src/runner/mcp-manager.ts +19 -8
  25. package/src/runner/mcp-server.test.ts +8 -5
  26. package/src/runner/mcp-server.ts +31 -17
  27. package/src/runner/optimization-runner.ts +305 -0
  28. package/src/runner/reflexion.test.ts +87 -0
  29. package/src/runner/shell-executor.test.ts +12 -0
  30. package/src/runner/shell-executor.ts +9 -6
  31. package/src/runner/step-executor.test.ts +46 -1
  32. package/src/runner/step-executor.ts +154 -60
  33. package/src/runner/stream-utils.test.ts +65 -0
  34. package/src/runner/stream-utils.ts +186 -0
  35. package/src/runner/workflow-runner.test.ts +4 -4
  36. package/src/runner/workflow-runner.ts +436 -251
  37. package/src/templates/agents/keystone-architect.md +6 -4
  38. package/src/templates/full-feature-demo.yaml +4 -4
  39. package/src/types/assets.d.ts +14 -0
  40. package/src/types/status.ts +1 -1
  41. package/src/ui/dashboard.tsx +38 -26
  42. package/src/utils/auth-manager.ts +3 -1
  43. package/src/utils/logger.test.ts +76 -0
  44. package/src/utils/logger.ts +39 -0
  45. package/src/utils/prompt.ts +75 -0
  46. package/src/utils/redactor.test.ts +86 -4
  47. 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 = console,
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) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir, dryRun),
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
- // Check if we have a resume approval
155
- const stepInputs = context.inputs
156
- ? (context.inputs as Record<string, unknown>)[step.id]
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 path = ExpressionEvaluator.evaluateString(step.path, context);
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: ${path}`);
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(path);
261
+ const file = Bun.file(targetPath);
244
262
  if (!(await file.exists())) {
245
- throw new Error(`File not found: ${path}`);
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 fs = await import('node:fs/promises');
262
- const pathModule = await import('node:path');
263
- const dir = pathModule.dirname(path);
264
- await fs.mkdir(dir, { recursive: true });
279
+ const dir = path.dirname(targetPath);
280
+ if (!fs.existsSync(dir)) {
281
+ fs.mkdirSync(dir, { recursive: true });
282
+ }
265
283
 
266
- const bytes = await Bun.write(path, content);
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 fs = await import('node:fs/promises');
281
- const pathModule = await import('node:path');
282
- const dir = pathModule.dirname(path);
283
- await fs.mkdir(dir, { recursive: true });
298
+ const dir = path.dirname(targetPath);
299
+ if (!fs.existsSync(dir)) {
300
+ fs.mkdirSync(dir, { recursive: true });
301
+ }
284
302
 
285
- // Use Node.js fs for efficient append operation
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
- allowInsecureFallback: step.allowInsecure,
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('completed');
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);