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.
- package/README.md +326 -59
- package/package.json +1 -1
- package/src/cli.ts +90 -81
- package/src/db/workflow-db.ts +0 -7
- package/src/expression/evaluator.test.ts +42 -0
- package/src/expression/evaluator.ts +28 -0
- package/src/parser/agent-parser.test.ts +10 -0
- package/src/parser/agent-parser.ts +2 -1
- package/src/parser/config-schema.ts +13 -5
- package/src/parser/workflow-parser.ts +0 -5
- package/src/runner/llm-adapter.test.ts +0 -8
- package/src/runner/llm-adapter.ts +33 -10
- package/src/runner/llm-executor.test.ts +59 -18
- package/src/runner/llm-executor.ts +1 -1
- package/src/runner/mcp-client.test.ts +166 -88
- package/src/runner/mcp-client.ts +156 -22
- package/src/runner/mcp-manager.test.ts +73 -15
- package/src/runner/mcp-manager.ts +44 -18
- package/src/runner/mcp-server.test.ts +4 -1
- package/src/runner/mcp-server.ts +25 -11
- package/src/runner/shell-executor.ts +3 -3
- package/src/runner/step-executor.ts +10 -9
- package/src/runner/tool-integration.test.ts +21 -14
- package/src/runner/workflow-runner.ts +25 -5
- package/src/templates/agents/explore.md +54 -0
- package/src/templates/agents/general.md +8 -0
- package/src/templates/agents/keystone-architect.md +54 -0
- package/src/templates/agents/my-agent.md +3 -0
- package/src/templates/agents/summarizer.md +28 -0
- package/src/templates/agents/test-agent.md +10 -0
- package/src/templates/approval-process.yaml +36 -0
- package/src/templates/basic-inputs.yaml +19 -0
- package/src/templates/basic-shell.yaml +20 -0
- package/src/templates/batch-processor.yaml +43 -0
- package/src/templates/cleanup-finally.yaml +22 -0
- package/src/templates/composition-child.yaml +13 -0
- package/src/templates/composition-parent.yaml +14 -0
- package/src/templates/data-pipeline.yaml +38 -0
- package/src/templates/full-feature-demo.yaml +64 -0
- package/src/templates/human-interaction.yaml +12 -0
- package/src/templates/invalid.yaml +5 -0
- package/src/templates/llm-agent.yaml +8 -0
- package/src/templates/loop-parallel.yaml +37 -0
- package/src/templates/retry-policy.yaml +36 -0
- package/src/templates/scaffold-feature.yaml +48 -0
- package/src/templates/state.db +0 -0
- package/src/templates/state.db-shm +0 -0
- package/src/templates/state.db-wal +0 -0
- package/src/templates/stop-watch.yaml +17 -0
- package/src/templates/workflow.db +0 -0
- 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(
|
|
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
|
|
507
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
581
|
+
} catch (e) {
|
|
582
|
+
console.error('\n✗ Failed to verify token:', e instanceof Error ? e.message : e);
|
|
575
583
|
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
console.
|
|
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
|
-
.
|
|
624
|
-
.
|
|
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({
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
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:
|
|
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
|
|
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:
|
|
390
|
-
if (options.tools?.some((t:
|
|
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
|
|
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
|
|
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.
|
|
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);
|