keystone-cli 0.1.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 +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ExpressionContext } from '../expression/evaluator';
|
|
5
|
+
import type { LlmStep, Step } from '../parser/schema';
|
|
6
|
+
import { AnthropicAdapter, CopilotAdapter, OpenAIAdapter } from './llm-adapter';
|
|
7
|
+
import { MCPClient } from './mcp-client';
|
|
8
|
+
import { executeLlmStep } from './llm-executor';
|
|
9
|
+
import { MCPManager } from './mcp-manager';
|
|
10
|
+
import { ConfigLoader } from '../utils/config-loader';
|
|
11
|
+
import type { StepResult } from './step-executor';
|
|
12
|
+
|
|
13
|
+
// Mock adapters
|
|
14
|
+
const originalOpenAIChat = OpenAIAdapter.prototype.chat;
|
|
15
|
+
const originalCopilotChat = CopilotAdapter.prototype.chat;
|
|
16
|
+
const originalAnthropicChat = AnthropicAdapter.prototype.chat;
|
|
17
|
+
|
|
18
|
+
describe('llm-executor', () => {
|
|
19
|
+
const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
const agentContent = `---
|
|
26
|
+
name: test-agent
|
|
27
|
+
model: gpt-4
|
|
28
|
+
tools:
|
|
29
|
+
- name: test-tool
|
|
30
|
+
execution:
|
|
31
|
+
type: shell
|
|
32
|
+
run: echo "tool executed with \${{ args.val }}"
|
|
33
|
+
---
|
|
34
|
+
You are a test agent.`;
|
|
35
|
+
writeFileSync(join(agentsDir, 'test-agent.md'), agentContent);
|
|
36
|
+
|
|
37
|
+
const mockChat = async (messages: unknown[], _options?: unknown) => {
|
|
38
|
+
const lastMessage = messages[messages.length - 1] as { content?: string };
|
|
39
|
+
const systemMessage = messages.find(
|
|
40
|
+
(m) =>
|
|
41
|
+
typeof m === 'object' &&
|
|
42
|
+
m !== null &&
|
|
43
|
+
'role' in m &&
|
|
44
|
+
(m as { role: string }).role === 'system'
|
|
45
|
+
) as { content?: string } | undefined;
|
|
46
|
+
|
|
47
|
+
if (systemMessage?.content?.includes('IMPORTANT: You must output valid JSON')) {
|
|
48
|
+
return {
|
|
49
|
+
message: { role: 'assistant', content: '```json\n{"foo": "bar"}\n```' },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lastMessage?.content?.includes('trigger tool')) {
|
|
54
|
+
return {
|
|
55
|
+
message: {
|
|
56
|
+
role: 'assistant',
|
|
57
|
+
content: null,
|
|
58
|
+
tool_calls: [
|
|
59
|
+
{
|
|
60
|
+
id: 'call-1',
|
|
61
|
+
type: 'function',
|
|
62
|
+
function: { name: 'test-tool', arguments: '{"val": 123}' },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (lastMessage?.content?.includes('trigger adhoc tool')) {
|
|
70
|
+
return {
|
|
71
|
+
message: {
|
|
72
|
+
role: 'assistant',
|
|
73
|
+
content: null,
|
|
74
|
+
tool_calls: [
|
|
75
|
+
{
|
|
76
|
+
id: 'call-adhoc',
|
|
77
|
+
type: 'function',
|
|
78
|
+
function: { name: 'adhoc-tool', arguments: '{}' },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
message: { role: 'assistant', content: 'LLM Response' },
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
OpenAIAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalOpenAIChat;
|
|
90
|
+
CopilotAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalCopilotChat;
|
|
91
|
+
AnthropicAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalAnthropicChat;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChat;
|
|
96
|
+
CopilotAdapter.prototype.chat = originalCopilotChat;
|
|
97
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChat;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should execute a simple LLM step', async () => {
|
|
101
|
+
const step: LlmStep = {
|
|
102
|
+
id: 'l1',
|
|
103
|
+
type: 'llm',
|
|
104
|
+
agent: 'test-agent',
|
|
105
|
+
prompt: 'hello',
|
|
106
|
+
needs: [],
|
|
107
|
+
};
|
|
108
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
109
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
110
|
+
|
|
111
|
+
const result = await executeLlmStep(
|
|
112
|
+
step,
|
|
113
|
+
context,
|
|
114
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
115
|
+
);
|
|
116
|
+
expect(result.status).toBe('success');
|
|
117
|
+
expect(result.output).toBe('LLM Response');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should execute LLM step with tool calls', async () => {
|
|
121
|
+
const step: LlmStep = {
|
|
122
|
+
id: 'l1',
|
|
123
|
+
type: 'llm',
|
|
124
|
+
agent: 'test-agent',
|
|
125
|
+
prompt: 'trigger tool',
|
|
126
|
+
needs: [],
|
|
127
|
+
};
|
|
128
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
129
|
+
|
|
130
|
+
const executeStepFn = async (s: Step) => {
|
|
131
|
+
if (s.type === 'shell') {
|
|
132
|
+
return { status: 'success' as const, output: { stdout: 'tool result' } };
|
|
133
|
+
}
|
|
134
|
+
return { status: 'success' as const, output: 'ok' };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await executeLlmStep(
|
|
138
|
+
step,
|
|
139
|
+
context,
|
|
140
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
141
|
+
);
|
|
142
|
+
expect(result.status).toBe('success');
|
|
143
|
+
expect(result.output).toBe('LLM Response');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should support schema for JSON output', async () => {
|
|
147
|
+
const step: LlmStep = {
|
|
148
|
+
id: 'l1',
|
|
149
|
+
type: 'llm',
|
|
150
|
+
agent: 'test-agent',
|
|
151
|
+
prompt: 'give me json',
|
|
152
|
+
needs: [],
|
|
153
|
+
schema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
foo: { type: 'string' },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
161
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
162
|
+
|
|
163
|
+
const result = await executeLlmStep(
|
|
164
|
+
step,
|
|
165
|
+
context,
|
|
166
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
167
|
+
);
|
|
168
|
+
expect(result.status).toBe('success');
|
|
169
|
+
expect(result.output).toEqual({ foo: 'bar' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should throw error if JSON parsing fails for schema', async () => {
|
|
173
|
+
const step: LlmStep = {
|
|
174
|
+
id: 'l1',
|
|
175
|
+
type: 'llm',
|
|
176
|
+
agent: 'test-agent',
|
|
177
|
+
prompt: 'give me invalid json',
|
|
178
|
+
needs: [],
|
|
179
|
+
schema: { type: 'object' },
|
|
180
|
+
};
|
|
181
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
182
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
183
|
+
|
|
184
|
+
// Mock response with invalid JSON
|
|
185
|
+
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
186
|
+
const originalCopilotChatInner = CopilotAdapter.prototype.chat;
|
|
187
|
+
const originalAnthropicChatInner = AnthropicAdapter.prototype.chat;
|
|
188
|
+
|
|
189
|
+
const mockChat = mock(async () => ({
|
|
190
|
+
message: { role: 'assistant', content: 'Not JSON' },
|
|
191
|
+
})) as unknown as typeof originalOpenAIChat;
|
|
192
|
+
|
|
193
|
+
OpenAIAdapter.prototype.chat = mockChat;
|
|
194
|
+
CopilotAdapter.prototype.chat = mockChat;
|
|
195
|
+
AnthropicAdapter.prototype.chat = mockChat;
|
|
196
|
+
|
|
197
|
+
await expect(
|
|
198
|
+
executeLlmStep(
|
|
199
|
+
step,
|
|
200
|
+
context,
|
|
201
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
202
|
+
)
|
|
203
|
+
).rejects.toThrow(/Failed to parse LLM output as JSON/);
|
|
204
|
+
|
|
205
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
206
|
+
CopilotAdapter.prototype.chat = originalCopilotChatInner;
|
|
207
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChatInner;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should handle tool not found', async () => {
|
|
211
|
+
const step: LlmStep = {
|
|
212
|
+
id: 'l1',
|
|
213
|
+
type: 'llm',
|
|
214
|
+
agent: 'test-agent',
|
|
215
|
+
prompt: 'trigger unknown tool',
|
|
216
|
+
needs: [],
|
|
217
|
+
};
|
|
218
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
219
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
220
|
+
|
|
221
|
+
let toolErrorCaptured = false;
|
|
222
|
+
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
223
|
+
const originalCopilotChatInner = CopilotAdapter.prototype.chat;
|
|
224
|
+
const originalAnthropicChatInner = AnthropicAdapter.prototype.chat;
|
|
225
|
+
|
|
226
|
+
const mockChat = mock(async (messages: LLMMessage[]) => {
|
|
227
|
+
const toolResultMessage = messages.find((m) => m.role === 'tool');
|
|
228
|
+
if (toolResultMessage?.content?.includes('Error: Tool unknown-tool not found')) {
|
|
229
|
+
toolErrorCaptured = true;
|
|
230
|
+
return { message: { role: 'assistant', content: 'Correctly handled error' } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
message: {
|
|
235
|
+
role: 'assistant',
|
|
236
|
+
tool_calls: [
|
|
237
|
+
{
|
|
238
|
+
id: 'call-1',
|
|
239
|
+
type: 'function',
|
|
240
|
+
function: { name: 'unknown-tool', arguments: '{}' },
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
} as LLMResponse;
|
|
245
|
+
}) as unknown as typeof originalOpenAIChat;
|
|
246
|
+
|
|
247
|
+
OpenAIAdapter.prototype.chat = mockChat;
|
|
248
|
+
CopilotAdapter.prototype.chat = mockChat;
|
|
249
|
+
AnthropicAdapter.prototype.chat = mockChat;
|
|
250
|
+
|
|
251
|
+
await executeLlmStep(
|
|
252
|
+
step,
|
|
253
|
+
context,
|
|
254
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(toolErrorCaptured).toBe(true);
|
|
258
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
259
|
+
CopilotAdapter.prototype.chat = originalCopilotChatInner;
|
|
260
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChatInner;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should handle MCP connection failure', async () => {
|
|
264
|
+
const step: LlmStep = {
|
|
265
|
+
id: 'l1',
|
|
266
|
+
type: 'llm',
|
|
267
|
+
agent: 'test-agent',
|
|
268
|
+
prompt: 'hello',
|
|
269
|
+
needs: [],
|
|
270
|
+
mcpServers: [{ name: 'fail-mcp', command: 'node', args: [] }],
|
|
271
|
+
};
|
|
272
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
273
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
274
|
+
|
|
275
|
+
const spy = spyOn(MCPClient.prototype, 'initialize').mockRejectedValue(
|
|
276
|
+
new Error('Connect failed')
|
|
277
|
+
);
|
|
278
|
+
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
279
|
+
|
|
280
|
+
await executeLlmStep(
|
|
281
|
+
step,
|
|
282
|
+
context,
|
|
283
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
287
|
+
expect.stringContaining('Failed to connect to MCP server fail-mcp')
|
|
288
|
+
);
|
|
289
|
+
spy.mockRestore();
|
|
290
|
+
consoleSpy.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should handle MCP tool call failure', async () => {
|
|
294
|
+
const step: LlmStep = {
|
|
295
|
+
id: 'l1',
|
|
296
|
+
type: 'llm',
|
|
297
|
+
agent: 'test-agent',
|
|
298
|
+
prompt: 'trigger mcp tool',
|
|
299
|
+
needs: [],
|
|
300
|
+
mcpServers: [{ name: 'test-mcp', command: 'node', args: [] }],
|
|
301
|
+
};
|
|
302
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
303
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
304
|
+
|
|
305
|
+
const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
|
|
306
|
+
{} as unknown as any
|
|
307
|
+
);
|
|
308
|
+
const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
|
|
309
|
+
{ name: 'mcp-tool', inputSchema: {} },
|
|
310
|
+
]);
|
|
311
|
+
const callSpy = spyOn(MCPClient.prototype, 'callTool').mockRejectedValue(
|
|
312
|
+
new Error('Tool failed')
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
316
|
+
const originalCopilotChatInner = CopilotAdapter.prototype.chat;
|
|
317
|
+
const originalAnthropicChatInner = AnthropicAdapter.prototype.chat;
|
|
318
|
+
let toolErrorCaptured = false;
|
|
319
|
+
|
|
320
|
+
const mockChat = mock(async (messages: any[]) => {
|
|
321
|
+
const toolResultMessage = messages.find((m) => m.role === 'tool');
|
|
322
|
+
if (toolResultMessage?.content?.includes('Error: Tool failed')) {
|
|
323
|
+
toolErrorCaptured = true;
|
|
324
|
+
return { message: { role: 'assistant', content: 'Handled tool failure' } };
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
message: {
|
|
328
|
+
role: 'assistant',
|
|
329
|
+
tool_calls: [
|
|
330
|
+
{ id: 'c1', type: 'function', function: { name: 'mcp-tool', arguments: '{}' } },
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}) as any;
|
|
335
|
+
|
|
336
|
+
OpenAIAdapter.prototype.chat = mockChat;
|
|
337
|
+
CopilotAdapter.prototype.chat = mockChat;
|
|
338
|
+
AnthropicAdapter.prototype.chat = mockChat;
|
|
339
|
+
|
|
340
|
+
await executeLlmStep(
|
|
341
|
+
step,
|
|
342
|
+
context,
|
|
343
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(toolErrorCaptured).toBe(true);
|
|
347
|
+
|
|
348
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
349
|
+
CopilotAdapter.prototype.chat = originalCopilotChatInner;
|
|
350
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChatInner;
|
|
351
|
+
initSpy.mockRestore();
|
|
352
|
+
listSpy.mockRestore();
|
|
353
|
+
callSpy.mockRestore();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should use global MCP servers when useGlobalMcp is true', async () => {
|
|
357
|
+
ConfigLoader.setConfig({
|
|
358
|
+
mcp_servers: {
|
|
359
|
+
'global-mcp': { command: 'node', args: ['server.js'] },
|
|
360
|
+
},
|
|
361
|
+
providers: {
|
|
362
|
+
openai: { apiKey: 'test' },
|
|
363
|
+
},
|
|
364
|
+
model_mappings: {},
|
|
365
|
+
default_provider: 'openai',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const manager = new MCPManager();
|
|
369
|
+
const step: LlmStep = {
|
|
370
|
+
id: 'l1',
|
|
371
|
+
type: 'llm',
|
|
372
|
+
agent: 'test-agent',
|
|
373
|
+
prompt: 'hello',
|
|
374
|
+
needs: [],
|
|
375
|
+
useGlobalMcp: true,
|
|
376
|
+
};
|
|
377
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
378
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
379
|
+
|
|
380
|
+
const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
|
|
381
|
+
{} as unknown as any
|
|
382
|
+
);
|
|
383
|
+
const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
|
|
384
|
+
{ name: 'global-tool', description: 'A global tool', inputSchema: {} },
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
let toolFound = false;
|
|
388
|
+
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')) {
|
|
391
|
+
toolFound = true;
|
|
392
|
+
}
|
|
393
|
+
return { message: { role: 'assistant', content: 'hello' } };
|
|
394
|
+
}) as any;
|
|
395
|
+
|
|
396
|
+
OpenAIAdapter.prototype.chat = mockChat;
|
|
397
|
+
|
|
398
|
+
await executeLlmStep(
|
|
399
|
+
step,
|
|
400
|
+
context,
|
|
401
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
402
|
+
console,
|
|
403
|
+
manager
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
expect(toolFound).toBe(true);
|
|
407
|
+
|
|
408
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
409
|
+
initSpy.mockRestore();
|
|
410
|
+
listSpy.mockRestore();
|
|
411
|
+
ConfigLoader.clear();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should support ad-hoc tools defined in the step', async () => {
|
|
415
|
+
const step: LlmStep = {
|
|
416
|
+
id: 'l1',
|
|
417
|
+
type: 'llm',
|
|
418
|
+
agent: 'test-agent',
|
|
419
|
+
prompt: 'trigger adhoc tool',
|
|
420
|
+
needs: [],
|
|
421
|
+
tools: [
|
|
422
|
+
{
|
|
423
|
+
name: 'adhoc-tool',
|
|
424
|
+
execution: {
|
|
425
|
+
id: 'adhoc-step',
|
|
426
|
+
type: 'shell',
|
|
427
|
+
run: 'echo "adhoc"',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
};
|
|
432
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
433
|
+
let toolExecuted = false;
|
|
434
|
+
const executeStepFn = async (s: Step) => {
|
|
435
|
+
if (s.id === 'adhoc-step') {
|
|
436
|
+
toolExecuted = true;
|
|
437
|
+
return { status: 'success' as const, output: { stdout: 'adhoc result' } };
|
|
438
|
+
}
|
|
439
|
+
return { status: 'success' as const, output: 'ok' };
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
await executeLlmStep(
|
|
443
|
+
step,
|
|
444
|
+
context,
|
|
445
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
expect(toolExecuted).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should handle global MCP server name without manager', async () => {
|
|
452
|
+
const step: LlmStep = {
|
|
453
|
+
id: 'l1',
|
|
454
|
+
type: 'llm',
|
|
455
|
+
agent: 'test-agent',
|
|
456
|
+
prompt: 'hello',
|
|
457
|
+
needs: [],
|
|
458
|
+
mcpServers: ['some-global-server'],
|
|
459
|
+
};
|
|
460
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
461
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
462
|
+
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
463
|
+
|
|
464
|
+
await executeLlmStep(
|
|
465
|
+
step,
|
|
466
|
+
context,
|
|
467
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
468
|
+
console // Passing console as logger but no manager
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
472
|
+
expect.stringContaining(
|
|
473
|
+
"Cannot reference global MCP server 'some-global-server' without MCPManager"
|
|
474
|
+
)
|
|
475
|
+
);
|
|
476
|
+
consoleSpy.mockRestore();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should not add global MCP server if already explicitly listed', async () => {
|
|
480
|
+
ConfigLoader.setConfig({
|
|
481
|
+
mcp_servers: {
|
|
482
|
+
'test-mcp': { command: 'node', args: ['server.js'] },
|
|
483
|
+
},
|
|
484
|
+
providers: { openai: { apiKey: 'test' } },
|
|
485
|
+
model_mappings: {},
|
|
486
|
+
default_provider: 'openai',
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const manager = new MCPManager();
|
|
490
|
+
const step: LlmStep = {
|
|
491
|
+
id: 'l1',
|
|
492
|
+
type: 'llm',
|
|
493
|
+
agent: 'test-agent',
|
|
494
|
+
prompt: 'hello',
|
|
495
|
+
needs: [],
|
|
496
|
+
useGlobalMcp: true,
|
|
497
|
+
mcpServers: [{ name: 'test-mcp', command: 'node', args: ['local.js'] }],
|
|
498
|
+
};
|
|
499
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
500
|
+
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
501
|
+
|
|
502
|
+
const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
|
|
503
|
+
{} as unknown as any
|
|
504
|
+
);
|
|
505
|
+
const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([]);
|
|
506
|
+
|
|
507
|
+
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
508
|
+
const mockChat = mock(async () => ({
|
|
509
|
+
message: { role: 'assistant', content: 'hello' },
|
|
510
|
+
})) as any;
|
|
511
|
+
OpenAIAdapter.prototype.chat = mockChat;
|
|
512
|
+
|
|
513
|
+
const managerSpy = spyOn(manager, 'getGlobalServers');
|
|
514
|
+
|
|
515
|
+
await executeLlmStep(
|
|
516
|
+
step,
|
|
517
|
+
context,
|
|
518
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
519
|
+
console,
|
|
520
|
+
manager
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect(managerSpy).toHaveBeenCalled();
|
|
524
|
+
// It should only have 1 MCP server (the explicit one)
|
|
525
|
+
// We can check this by seeing how many times initialize was called if they were different,
|
|
526
|
+
// but here we just want to ensure it didn't push the global one again.
|
|
527
|
+
|
|
528
|
+
// Actually, initialize will be called for 'test-mcp' (explicitly listed)
|
|
529
|
+
expect(initSpy).toHaveBeenCalledTimes(1);
|
|
530
|
+
|
|
531
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
532
|
+
initSpy.mockRestore();
|
|
533
|
+
listSpy.mockRestore();
|
|
534
|
+
managerSpy.mockRestore();
|
|
535
|
+
ConfigLoader.clear();
|
|
536
|
+
});
|
|
537
|
+
});
|