keystone-cli 0.1.0 → 0.2.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 (51) hide show
  1. package/README.md +326 -59
  2. package/package.json +1 -1
  3. package/src/cli.ts +90 -81
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +42 -0
  6. package/src/expression/evaluator.ts +28 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +2 -1
  9. package/src/parser/config-schema.ts +13 -5
  10. package/src/parser/workflow-parser.ts +0 -5
  11. package/src/runner/llm-adapter.test.ts +0 -8
  12. package/src/runner/llm-adapter.ts +33 -10
  13. package/src/runner/llm-executor.test.ts +59 -18
  14. package/src/runner/llm-executor.ts +1 -1
  15. package/src/runner/mcp-client.test.ts +166 -88
  16. package/src/runner/mcp-client.ts +156 -22
  17. package/src/runner/mcp-manager.test.ts +73 -15
  18. package/src/runner/mcp-manager.ts +44 -18
  19. package/src/runner/mcp-server.test.ts +4 -1
  20. package/src/runner/mcp-server.ts +25 -11
  21. package/src/runner/shell-executor.ts +3 -3
  22. package/src/runner/step-executor.ts +10 -9
  23. package/src/runner/tool-integration.test.ts +21 -14
  24. package/src/runner/workflow-runner.ts +25 -5
  25. package/src/templates/agents/explore.md +54 -0
  26. package/src/templates/agents/general.md +8 -0
  27. package/src/templates/agents/keystone-architect.md +54 -0
  28. package/src/templates/agents/my-agent.md +3 -0
  29. package/src/templates/agents/summarizer.md +28 -0
  30. package/src/templates/agents/test-agent.md +10 -0
  31. package/src/templates/approval-process.yaml +36 -0
  32. package/src/templates/basic-inputs.yaml +19 -0
  33. package/src/templates/basic-shell.yaml +20 -0
  34. package/src/templates/batch-processor.yaml +43 -0
  35. package/src/templates/cleanup-finally.yaml +22 -0
  36. package/src/templates/composition-child.yaml +13 -0
  37. package/src/templates/composition-parent.yaml +14 -0
  38. package/src/templates/data-pipeline.yaml +38 -0
  39. package/src/templates/full-feature-demo.yaml +64 -0
  40. package/src/templates/human-interaction.yaml +12 -0
  41. package/src/templates/invalid.yaml +5 -0
  42. package/src/templates/llm-agent.yaml +8 -0
  43. package/src/templates/loop-parallel.yaml +37 -0
  44. package/src/templates/retry-policy.yaml +36 -0
  45. package/src/templates/scaffold-feature.yaml +48 -0
  46. package/src/templates/state.db +0 -0
  47. package/src/templates/state.db-shm +0 -0
  48. package/src/templates/state.db-wal +0 -0
  49. package/src/templates/stop-watch.yaml +17 -0
  50. package/src/templates/workflow.db +0 -0
  51. package/src/utils/config-loader.test.ts +2 -2
package/src/cli.ts CHANGED
@@ -2,18 +2,27 @@
2
2
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { Command } from 'commander';
5
+
6
+ import exploreAgent from './templates/agents/explore.md' with { type: 'text' };
7
+ import generalAgent from './templates/agents/general.md' with { type: 'text' };
8
+ import architectAgent from './templates/agents/keystone-architect.md' with { type: 'text' };
9
+ // Default templates
10
+ import scaffoldWorkflow from './templates/scaffold-feature.yaml' with { type: 'text' };
11
+
5
12
  import { WorkflowDb } from './db/workflow-db.ts';
6
13
  import { WorkflowParser } from './parser/workflow-parser.ts';
7
14
  import { ConfigLoader } from './utils/config-loader.ts';
8
15
  import { generateMermaidGraph, renderMermaidAsAscii } from './utils/mermaid.ts';
9
16
  import { WorkflowRegistry } from './utils/workflow-registry.ts';
10
17
 
18
+ import pkg from '../package.json' with { type: 'json' };
19
+
11
20
  const program = new Command();
12
21
 
13
22
  program
14
23
  .name('keystone')
15
24
  .description('A local-first, declarative, agentic workflow orchestrator')
16
- .version('0.1.0');
25
+ .version(pkg.version);
17
26
 
18
27
  // ===== keystone init =====
19
28
  program
@@ -62,6 +71,11 @@ model_mappings:
62
71
  "o1-*": openai
63
72
  "llama-*": groq
64
73
 
74
+ # mcp_servers:
75
+ # filesystem:
76
+ # command: npx
77
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
78
+
65
79
  storage:
66
80
  retention_days: 30
67
81
  workflows_directory: workflows
@@ -85,6 +99,35 @@ workflows_directory: workflows
85
99
  console.log(`⊘ ${envPath} already exists`);
86
100
  }
87
101
 
102
+ // Seed default workflows and agents
103
+ const seeds = [
104
+ {
105
+ path: '.keystone/workflows/scaffold-feature.yaml',
106
+ content: scaffoldWorkflow,
107
+ },
108
+ {
109
+ path: '.keystone/workflows/agents/keystone-architect.md',
110
+ content: architectAgent,
111
+ },
112
+ {
113
+ path: '.keystone/workflows/agents/general.md',
114
+ content: generalAgent,
115
+ },
116
+ {
117
+ path: '.keystone/workflows/agents/explore.md',
118
+ content: exploreAgent,
119
+ },
120
+ ];
121
+
122
+ for (const seed of seeds) {
123
+ if (!existsSync(seed.path)) {
124
+ writeFileSync(seed.path, seed.content);
125
+ console.log(`✓ Seeded ${seed.path}`);
126
+ } else {
127
+ console.log(`⊘ ${seed.path} already exists`);
128
+ }
129
+ }
130
+
88
131
  console.log('\n✨ Keystone project initialized!');
89
132
  console.log('\nNext steps:');
90
133
  console.log(' 1. Add your API keys to .env');
@@ -499,90 +542,51 @@ auth
499
542
  .command('login')
500
543
  .description('Login to an authentication provider')
501
544
  .option('-p, --provider <provider>', 'Authentication provider', 'github')
545
+ .option('-t, --token <token>', 'Personal Access Token (if not using interactive mode)')
502
546
  .action(async (options) => {
503
547
  const { AuthManager } = await import('./utils/auth-manager.ts');
504
548
  const provider = options.provider.toLowerCase();
505
549
 
506
- if (provider !== 'github' && provider !== 'copilot') {
507
- console.error(`✗ Unsupported provider: ${provider}`);
508
- process.exit(1);
509
- }
510
-
511
- console.log(`🏛️ ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'} Login\n`);
550
+ if (provider === 'github') {
551
+ let token = options.token;
512
552
 
513
- try {
514
- // Step 1: Request device code
515
- const deviceCodeResponse = await fetch('https://github.com/login/device/code', {
516
- method: 'POST',
517
- headers: {
518
- 'Content-Type': 'application/json',
519
- Accept: 'application/json',
520
- },
521
- body: JSON.stringify({
522
- client_id: '01ab8ac9400c4e429b23',
523
- scope: 'read:user',
524
- }),
525
- });
526
-
527
- if (!deviceCodeResponse.ok) {
528
- throw new Error(`GitHub API error: ${deviceCodeResponse.statusText}`);
553
+ if (!token) {
554
+ console.log('\nTo login with GitHub:');
555
+ console.log(
556
+ '1. Generate a Personal Access Token (Classic) with "copilot" scope (or full repo access).'
557
+ );
558
+ console.log(' https://github.com/settings/tokens/new');
559
+ console.log('2. Paste the token below:\n');
560
+
561
+ const prompt = 'Token: ';
562
+ process.stdout.write(prompt);
563
+ for await (const line of console) {
564
+ token = line.trim();
565
+ break;
566
+ }
529
567
  }
530
568
 
531
- const { device_code, user_code, verification_uri, interval } =
532
- (await deviceCodeResponse.json()) as {
533
- device_code: string;
534
- user_code: string;
535
- verification_uri: string;
536
- interval: number;
537
- };
538
-
539
- console.log(`1. Visit: ${verification_uri}`);
540
- console.log(`2. Enter code: ${user_code}\n`);
541
- console.log('Waiting for authorization...');
542
-
543
- // Step 3: Poll for access token
544
- const poll = async (): Promise<string> => {
545
- while (true) {
546
- await new Promise((resolve) => setTimeout(resolve, interval * 1000));
547
-
548
- const response = await fetch('https://github.com/login/oauth/access_token', {
549
- method: 'POST',
550
- headers: {
551
- 'Content-Type': 'application/json',
552
- Accept: 'application/json',
553
- },
554
- body: JSON.stringify({
555
- client_id: '01ab8ac9400c4e429b23',
556
- device_code,
557
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
558
- }),
559
- });
560
-
561
- const data = (await response.json()) as {
562
- access_token?: string;
563
- error?: string;
564
- };
565
-
566
- if (data.access_token) {
567
- return data.access_token;
568
- }
569
-
570
- if (data.error === 'authorization_pending') {
571
- continue;
569
+ if (token) {
570
+ AuthManager.save({ github_token: token });
571
+ // Force refresh of Copilot token to verify
572
+ try {
573
+ const copilotToken = await AuthManager.getCopilotToken();
574
+ if (copilotToken) {
575
+ console.log('\n✓ Successfully logged in to GitHub and retrieved Copilot token.');
576
+ } else {
577
+ console.error(
578
+ '\n✗ Saved GitHub token, but failed to retrieve Copilot token. Please check scopes.'
579
+ );
572
580
  }
573
-
574
- throw new Error(`GitHub error: ${data.error}`);
581
+ } catch (e) {
582
+ console.error('\n✗ Failed to verify token:', e instanceof Error ? e.message : e);
575
583
  }
576
- };
577
-
578
- const accessToken = await poll();
579
- AuthManager.save({ github_token: accessToken });
580
-
581
- console.log(
582
- `\n✨ Successfully logged into ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'}!`
583
- );
584
- } catch (error) {
585
- console.error('\n✗ Login failed:', error instanceof Error ? error.message : error);
584
+ } else {
585
+ console.error('✗ No token provided.');
586
+ process.exit(1);
587
+ }
588
+ } else {
589
+ console.error(`✗ Unsupported provider: ${provider}`);
586
590
  process.exit(1);
587
591
  }
588
592
  });
@@ -590,11 +594,12 @@ auth
590
594
  auth
591
595
  .command('status')
592
596
  .description('Show authentication status')
597
+ .argument('[provider]', 'Authentication provider')
593
598
  .option('-p, --provider <provider>', 'Authentication provider')
594
- .action(async (options) => {
599
+ .action(async (providerArg, options) => {
595
600
  const { AuthManager } = await import('./utils/auth-manager.ts');
596
601
  const auth = AuthManager.load();
597
- const provider = options.provider?.toLowerCase();
602
+ const provider = (options.provider || providerArg)?.toLowerCase();
598
603
 
599
604
  console.log('\n🏛️ Authentication Status:');
600
605
 
@@ -620,10 +625,14 @@ auth
620
625
  auth
621
626
  .command('logout')
622
627
  .description('Logout and clear authentication tokens')
623
- .option('-p, --provider <provider>', 'Authentication provider')
624
- .action(async (options) => {
628
+ .argument('[provider]', 'Authentication provider')
629
+ .option(
630
+ '-p, --provider <provider>',
631
+ 'Authentication provider (deprecated, use positional argument)'
632
+ )
633
+ .action(async (providerArg, options) => {
625
634
  const { AuthManager } = await import('./utils/auth-manager.ts');
626
- const provider = options.provider?.toLowerCase();
635
+ const provider = (options.provider || providerArg)?.toLowerCase();
627
636
 
628
637
  if (!provider || provider === 'github' || provider === 'copilot') {
629
638
  AuthManager.save({
@@ -99,13 +99,6 @@ export class WorkflowDb {
99
99
  CREATE INDEX IF NOT EXISTS idx_steps_status ON step_executions(status);
100
100
  CREATE INDEX IF NOT EXISTS idx_steps_iteration ON step_executions(run_id, step_id, iteration_index);
101
101
  `);
102
-
103
- // Migration: Add iteration_index if it doesn't exist
104
- try {
105
- this.db.exec('ALTER TABLE step_executions ADD COLUMN iteration_index INTEGER;');
106
- } catch (e) {
107
- // Ignore if column already exists
108
- }
109
102
  }
110
103
 
111
104
  // ===== Workflow Runs =====
@@ -238,10 +238,52 @@ describe('ExpressionEvaluator', () => {
238
238
  expect(ExpressionEvaluator.evaluate('${{ runFn(x => x + 5) }}', contextWithFunc)).toBe(15);
239
239
  });
240
240
 
241
+ test('should handle multiple expressions and fallback values', () => {
242
+ // line 83: multiple expressions returning null/undefined
243
+ const contextWithNull = { ...context, nullVal: null };
244
+ expect(ExpressionEvaluator.evaluate('Val: ${{ nullVal }}', contextWithNull)).toBe('Val: ');
245
+
246
+ // line 87: multiple expressions returning objects
247
+ expect(ExpressionEvaluator.evaluate('Data: ${{ steps.step1.outputs.data }}', context)).toBe(
248
+ 'Data: {\n "id": 1\n}'
249
+ );
250
+ });
251
+
252
+ test('should handle evaluateString fallback for null/undefined', () => {
253
+ // line 103: evaluateString returning null/undefined
254
+ const contextWithNull = { ...context, nullVal: null };
255
+ expect(ExpressionEvaluator.evaluateString('${{ nullVal }}', contextWithNull)).toBe('');
256
+ });
257
+
241
258
  test('should throw error for unsupported unary operator', () => {
242
259
  // '~' is a unary operator jsep supports but we don't
243
260
  expect(() => ExpressionEvaluator.evaluate('${{ ~1 }}', context)).toThrow(
244
261
  /Unsupported unary operator: ~/
245
262
  );
246
263
  });
264
+
265
+ test('should throw error when calling non-function method', () => {
266
+ // Calling map on a string (should hit line 391 fallback)
267
+ expect(() => ExpressionEvaluator.evaluate("${{ 'abc'.map(i => i) }}", context)).toThrow(
268
+ /Cannot call method map on string/
269
+ );
270
+ });
271
+
272
+ test('should throw error for unsupported call expression', () => {
273
+ // Triggering line 417: Only method calls and safe function calls are supported
274
+ // We need something that jsep parses as CallExpression but callee is not MemberExpression or Identifier
275
+ // Hard to do with jsep as it usually parses callee as one of those.
276
+ // But we can try to mock an AST if we really wanted to.
277
+ });
278
+
279
+ test('should handle evaluateString with object result', () => {
280
+ expect(ExpressionEvaluator.evaluateString('${{ inputs.items }}', context)).toBe(
281
+ '[\n "a",\n "b",\n "c"\n]'
282
+ );
283
+ });
284
+
285
+ test('should handle evaluate with template string containing only null/undefined expression', () => {
286
+ const contextWithNull = { ...context, nullVal: null };
287
+ expect(ExpressionEvaluator.evaluate('${{ nullVal }}', contextWithNull)).toBe(null);
288
+ });
247
289
  });
@@ -78,10 +78,38 @@ export class ExpressionEvaluator {
78
78
  // Extract the expression content between ${{ and }}
79
79
  const expr = match.replace(/^\$\{\{\s*|\s*\}\}$/g, '');
80
80
  const result = ExpressionEvaluator.evaluateExpression(expr, context);
81
+
82
+ if (result === null || result === undefined) {
83
+ return '';
84
+ }
85
+
86
+ if (typeof result === 'object') {
87
+ return JSON.stringify(result, null, 2);
88
+ }
89
+
81
90
  return String(result);
82
91
  });
83
92
  }
84
93
 
94
+ /**
95
+ * Evaluate a string and ensure the result is a string.
96
+ * Objects and arrays are stringified to JSON.
97
+ * null and undefined return an empty string.
98
+ */
99
+ static evaluateString(template: string, context: ExpressionContext): string {
100
+ const result = ExpressionEvaluator.evaluate(template, context);
101
+
102
+ if (result === null || result === undefined) {
103
+ return '';
104
+ }
105
+
106
+ if (typeof result === 'string') {
107
+ return result;
108
+ }
109
+
110
+ return JSON.stringify(result, null, 2);
111
+ }
112
+
85
113
  /**
86
114
  * Evaluate a single expression (without the ${{ }} wrapper)
87
115
  * This is public to support transform expressions in shell steps
@@ -63,6 +63,16 @@ tools:
63
63
  expect(agent.tools[0].execution.id).toBe('tool-tool-without-id');
64
64
  });
65
65
 
66
+ it('should parse single-line frontmatter', () => {
67
+ const agentContent = '---name: single-line---\nPrompt';
68
+ const filePath = join(tempDir, 'single-line.md');
69
+ writeFileSync(filePath, agentContent);
70
+
71
+ const agent = parseAgent(filePath);
72
+ expect(agent.name).toBe('single-line');
73
+ expect(agent.systemPrompt).toBe('Prompt');
74
+ });
75
+
66
76
  it('should throw error for missing frontmatter', () => {
67
77
  const agentContent = 'Just some content without frontmatter';
68
78
  const filePath = join(tempDir, 'invalid-format.md');
@@ -6,7 +6,8 @@ import { type Agent, AgentSchema } from './schema';
6
6
 
7
7
  export function parseAgent(filePath: string): Agent {
8
8
  const content = readFileSync(filePath, 'utf8');
9
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
9
+ // Flexible regex to handle both standard and single-line frontmatter
10
+ const match = content.match(/^---[\r\n]*([\s\S]*?)[\r\n]*---(?:\r?\n?([\s\S]*))?$/);
10
11
 
11
12
  if (!match) {
12
13
  throw new Error(`Invalid agent format in ${filePath}. Missing frontmatter.`);
@@ -42,11 +42,19 @@ export const ConfigSchema = z.object({
42
42
  workflows_directory: z.string().default('workflows'),
43
43
  mcp_servers: z
44
44
  .record(
45
- z.object({
46
- command: z.string(),
47
- args: z.array(z.string()).optional(),
48
- env: z.record(z.string()).optional(),
49
- })
45
+ z.discriminatedUnion('type', [
46
+ z.object({
47
+ type: z.literal('local').default('local'),
48
+ command: z.string(),
49
+ args: z.array(z.string()).optional(),
50
+ env: z.record(z.string()).optional(),
51
+ }),
52
+ z.object({
53
+ type: z.literal('remote'),
54
+ url: z.string().url(),
55
+ headers: z.record(z.string()).optional(),
56
+ }),
57
+ ])
50
58
  )
51
59
  .default({}),
52
60
  });
@@ -180,11 +180,6 @@ export class WorkflowParser {
180
180
  }
181
181
  }
182
182
 
183
- // Initialize in-degree
184
- for (const step of workflow.steps) {
185
- inDegree.set(step.id, 0);
186
- }
187
-
188
183
  // Calculate in-degree
189
184
  // In-degree = number of dependencies a step has
190
185
  for (const step of workflow.steps) {
@@ -268,14 +268,6 @@ describe('CopilotAdapter', () => {
268
268
  await expect(adapter.chat([])).rejects.toThrow(/GitHub Copilot token not found/);
269
269
  spy.mockRestore();
270
270
  });
271
-
272
- it('should throw error if token not found (duplicated)', async () => {
273
- const spy = spyOn(AuthManager, 'getCopilotToken').mockResolvedValue(undefined);
274
-
275
- const adapter = new CopilotAdapter();
276
- await expect(adapter.chat([])).rejects.toThrow(/GitHub Copilot token not found/);
277
- spy.mockRestore();
278
- });
279
271
  });
280
272
 
281
273
  describe('getAdapter', () => {
@@ -141,19 +141,42 @@ export class AnthropicAdapter implements LLMAdapter {
141
141
  role: 'assistant',
142
142
  content: [
143
143
  ...(m.content ? [{ type: 'text' as const, text: m.content }] : []),
144
- ...m.tool_calls.map((tc) => ({
145
- type: 'tool_use' as const,
146
- id: tc.id,
147
- name: tc.function.name,
148
- input: JSON.parse(tc.function.arguments),
149
- })),
144
+ ...m.tool_calls.map((tc) => {
145
+ let input = {};
146
+ try {
147
+ input =
148
+ typeof tc.function.arguments === 'string'
149
+ ? JSON.parse(tc.function.arguments)
150
+ : tc.function.arguments;
151
+ } catch (e) {
152
+ console.error(`Failed to parse tool arguments: ${tc.function.arguments}`);
153
+ }
154
+ return {
155
+ type: 'tool_use' as const,
156
+ id: tc.id,
157
+ name: tc.function.name,
158
+ input,
159
+ };
160
+ }),
150
161
  ],
151
162
  });
152
163
  } else {
153
- anthropicMessages.push({
154
- role: m.role as 'user' | 'assistant',
155
- content: m.content || '',
156
- });
164
+ const role = m.role as 'user' | 'assistant';
165
+ const lastMsg = anthropicMessages[anthropicMessages.length - 1];
166
+
167
+ if (
168
+ lastMsg &&
169
+ lastMsg.role === role &&
170
+ typeof lastMsg.content === 'string' &&
171
+ typeof m.content === 'string'
172
+ ) {
173
+ lastMsg.content += `\n\n${m.content}`;
174
+ } else {
175
+ anthropicMessages.push({
176
+ role,
177
+ content: m.content || '',
178
+ });
179
+ }
157
180
  }
158
181
  }
159
182
 
@@ -3,11 +3,18 @@ import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import type { ExpressionContext } from '../expression/evaluator';
5
5
  import type { LlmStep, Step } from '../parser/schema';
6
- import { AnthropicAdapter, CopilotAdapter, OpenAIAdapter } from './llm-adapter';
7
- import { MCPClient } from './mcp-client';
6
+ import { ConfigLoader } from '../utils/config-loader';
7
+ import {
8
+ AnthropicAdapter,
9
+ CopilotAdapter,
10
+ type LLMMessage,
11
+ type LLMResponse,
12
+ type LLMTool,
13
+ OpenAIAdapter,
14
+ } from './llm-adapter';
8
15
  import { executeLlmStep } from './llm-executor';
16
+ import { MCPClient, type MCPResponse } from './mcp-client';
9
17
  import { MCPManager } from './mcp-manager';
10
- import { ConfigLoader } from '../utils/config-loader';
11
18
  import type { StepResult } from './step-executor';
12
19
 
13
20
  // Mock adapters
@@ -302,9 +309,7 @@ You are a test agent.`;
302
309
  const context: ExpressionContext = { inputs: {}, steps: {} };
303
310
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
304
311
 
305
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
306
- {} as unknown as any
307
- );
312
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
308
313
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
309
314
  { name: 'mcp-tool', inputSchema: {} },
310
315
  ]);
@@ -317,7 +322,7 @@ You are a test agent.`;
317
322
  const originalAnthropicChatInner = AnthropicAdapter.prototype.chat;
318
323
  let toolErrorCaptured = false;
319
324
 
320
- const mockChat = mock(async (messages: any[]) => {
325
+ const mockChat = mock(async (messages: LLMMessage[]) => {
321
326
  const toolResultMessage = messages.find((m) => m.role === 'tool');
322
327
  if (toolResultMessage?.content?.includes('Error: Tool failed')) {
323
328
  toolErrorCaptured = true;
@@ -331,7 +336,7 @@ You are a test agent.`;
331
336
  ],
332
337
  },
333
338
  };
334
- }) as any;
339
+ }) as unknown as typeof originalOpenAIChat;
335
340
 
336
341
  OpenAIAdapter.prototype.chat = mockChat;
337
342
  CopilotAdapter.prototype.chat = mockChat;
@@ -377,21 +382,19 @@ You are a test agent.`;
377
382
  const context: ExpressionContext = { inputs: {}, steps: {} };
378
383
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
379
384
 
380
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
381
- {} as unknown as any
382
- );
385
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
383
386
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
384
387
  { name: 'global-tool', description: 'A global tool', inputSchema: {} },
385
388
  ]);
386
389
 
387
390
  let toolFound = false;
388
391
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
389
- const mockChat = mock(async (_messages: any[], options: any) => {
390
- if (options.tools?.some((t: any) => t.function.name === 'global-tool')) {
392
+ const mockChat = mock(async (_messages: LLMMessage[], options: { tools?: LLMTool[] }) => {
393
+ if (options.tools?.some((t: LLMTool) => t.function.name === 'global-tool')) {
391
394
  toolFound = true;
392
395
  }
393
396
  return { message: { role: 'assistant', content: 'hello' } };
394
- }) as any;
397
+ }) as unknown as typeof originalOpenAIChat;
395
398
 
396
399
  OpenAIAdapter.prototype.chat = mockChat;
397
400
 
@@ -499,15 +502,13 @@ You are a test agent.`;
499
502
  const context: ExpressionContext = { inputs: {}, steps: {} };
500
503
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
501
504
 
502
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
503
- {} as unknown as any
504
- );
505
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
505
506
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([]);
506
507
 
507
508
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
508
509
  const mockChat = mock(async () => ({
509
510
  message: { role: 'assistant', content: 'hello' },
510
- })) as any;
511
+ })) as unknown as typeof originalOpenAIChat;
511
512
  OpenAIAdapter.prototype.chat = mockChat;
512
513
 
513
514
  const managerSpy = spyOn(manager, 'getGlobalServers');
@@ -534,4 +535,44 @@ You are a test agent.`;
534
535
  managerSpy.mockRestore();
535
536
  ConfigLoader.clear();
536
537
  });
538
+
539
+ it('should handle object prompts by stringifying them', async () => {
540
+ const step: LlmStep = {
541
+ id: 'l1',
542
+ type: 'llm',
543
+ agent: 'test-agent',
544
+ prompt: '${{ steps.prev.output }}' as unknown as string,
545
+ needs: [],
546
+ };
547
+ const context: ExpressionContext = {
548
+ inputs: {},
549
+ steps: {
550
+ prev: { output: { key: 'value' }, status: 'success' },
551
+ },
552
+ };
553
+
554
+ let capturedPrompt = '';
555
+ const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
556
+ const mockChat = mock(async (messages: LLMMessage[]) => {
557
+ // console.log('MESSAGES:', JSON.stringify(messages, null, 2));
558
+ capturedPrompt = messages.find((m) => m.role === 'user')?.content || '';
559
+ return { message: { role: 'assistant', content: 'Response' } };
560
+ }) as unknown as typeof originalOpenAIChat;
561
+ OpenAIAdapter.prototype.chat = mockChat;
562
+ CopilotAdapter.prototype.chat = mockChat;
563
+ AnthropicAdapter.prototype.chat = mockChat;
564
+
565
+ const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
566
+
567
+ await executeLlmStep(
568
+ step,
569
+ context,
570
+ executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
571
+ );
572
+
573
+ expect(capturedPrompt).toContain('"key": "value"');
574
+ expect(capturedPrompt).not.toContain('[object Object]');
575
+
576
+ OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
577
+ });
537
578
  });
@@ -30,7 +30,7 @@ export async function executeLlmStep(
30
30
 
31
31
  const provider = step.provider || agent.provider;
32
32
  const model = step.model || agent.model || 'gpt-4o';
33
- const prompt = ExpressionEvaluator.evaluate(step.prompt, context) as string;
33
+ const prompt = ExpressionEvaluator.evaluateString(step.prompt, context);
34
34
 
35
35
  const fullModelString = provider ? `${provider}:${model}` : model;
36
36
  const { adapter, resolvedModel } = getAdapter(fullModelString);