@studio-foundation/ralph 0.3.0-beta.1
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/ARCHITECTURE.md +41 -0
- package/LICENSE +663 -0
- package/README.md +71 -0
- package/configs/examples/analysis.contract.yaml +19 -0
- package/configs/examples/code-generation.contract.yaml +21 -0
- package/package.json +42 -0
- package/src/context-enricher.ts +9 -0
- package/src/contracts.ts +28 -0
- package/src/index.ts +7 -0
- package/src/loop.ts +122 -0
- package/src/retry-strategy.ts +23 -0
- package/src/validator.ts +160 -0
- package/tests/loop.test.ts +355 -0
- package/tests/retry.test.ts +87 -0
- package/tests/validator.test.ts +625 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateSchema, validateToolCalls, validateRequiredTools, validateCountedTools, validateToolGroups, compose } from '../src/validator.js';
|
|
3
|
+
import type { OutputContract, ToolCall } from '@studio-foundation/contracts';
|
|
4
|
+
|
|
5
|
+
describe('validateSchema', () => {
|
|
6
|
+
it('passes when all required fields present', () => {
|
|
7
|
+
const contract: OutputContract = {
|
|
8
|
+
name: 'test',
|
|
9
|
+
version: 1,
|
|
10
|
+
schema: { required_fields: ['summary', 'status'] }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const output = { summary: 'Done', status: 'success' };
|
|
14
|
+
const result = validateSchema(output, contract);
|
|
15
|
+
|
|
16
|
+
expect(result.valid).toBe(true);
|
|
17
|
+
expect(result.errors).toHaveLength(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('fails when required field missing', () => {
|
|
21
|
+
const contract: OutputContract = {
|
|
22
|
+
name: 'test',
|
|
23
|
+
version: 1,
|
|
24
|
+
schema: { required_fields: ['summary', 'status'] }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const output = { summary: 'Done' }; // missing status
|
|
28
|
+
const result = validateSchema(output, contract);
|
|
29
|
+
|
|
30
|
+
expect(result.valid).toBe(false);
|
|
31
|
+
expect(result.errors).toContain('Missing required field: status');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('fails when multiple required fields missing', () => {
|
|
35
|
+
const contract: OutputContract = {
|
|
36
|
+
name: 'test',
|
|
37
|
+
version: 1,
|
|
38
|
+
schema: { required_fields: ['summary', 'status', 'files'] }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const output = { summary: 'Done' }; // missing status and files
|
|
42
|
+
const result = validateSchema(output, contract);
|
|
43
|
+
|
|
44
|
+
expect(result.valid).toBe(false);
|
|
45
|
+
expect(result.errors).toHaveLength(2);
|
|
46
|
+
expect(result.errors).toContain('Missing required field: status');
|
|
47
|
+
expect(result.errors).toContain('Missing required field: files');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('passes when no schema defined', () => {
|
|
51
|
+
const contract: OutputContract = {
|
|
52
|
+
name: 'test',
|
|
53
|
+
version: 1
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const output = { anything: 'goes' };
|
|
57
|
+
const result = validateSchema(output, contract);
|
|
58
|
+
|
|
59
|
+
expect(result.valid).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('passes when schema has no required_fields', () => {
|
|
63
|
+
const contract: OutputContract = {
|
|
64
|
+
name: 'test',
|
|
65
|
+
version: 1,
|
|
66
|
+
schema: {}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const output = { anything: 'goes' };
|
|
70
|
+
const result = validateSchema(output, contract);
|
|
71
|
+
|
|
72
|
+
expect(result.valid).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes when extra fields present', () => {
|
|
76
|
+
const contract: OutputContract = {
|
|
77
|
+
name: 'test',
|
|
78
|
+
version: 1,
|
|
79
|
+
schema: { required_fields: ['summary'] }
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const output = { summary: 'Done', extra: 'field', another: 'one' };
|
|
83
|
+
const result = validateSchema(output, contract);
|
|
84
|
+
|
|
85
|
+
expect(result.valid).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('validateToolCalls', () => {
|
|
90
|
+
const success = (id: string): ToolCall => ({ id, name: 'some_tool', arguments: {} });
|
|
91
|
+
const failed = (id: string): ToolCall => ({ id, name: 'some_tool', arguments: {}, error: 'ENOENT' });
|
|
92
|
+
|
|
93
|
+
it('passes when successful calls meet minimum', () => {
|
|
94
|
+
const result = validateToolCalls([success('1'), success('2'), success('3')], { minimum: 2 });
|
|
95
|
+
expect(result.valid).toBe(true);
|
|
96
|
+
expect(result.errors).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('passes when exactly at minimum', () => {
|
|
100
|
+
const result = validateToolCalls([success('1'), success('2')], { minimum: 2 });
|
|
101
|
+
expect(result.valid).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('fails when below minimum', () => {
|
|
105
|
+
const result = validateToolCalls([], { minimum: 1 });
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
expect(result.errors[0]).toContain('Expected at least 1 successful tool call');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ANTI-THÉÂTRE: fails when all calls failed', () => {
|
|
111
|
+
const result = validateToolCalls([failed('1'), failed('2'), failed('3')], { minimum: 1 });
|
|
112
|
+
expect(result.valid).toBe(false);
|
|
113
|
+
expect(result.errors[0]).toContain('got 0 successful');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('ANTI-THÉÂTRE: excludes failed calls from count', () => {
|
|
117
|
+
// 1 successful + 2 failed → only 1 counts
|
|
118
|
+
const result = validateToolCalls([success('1'), failed('2'), failed('3')], { minimum: 2 });
|
|
119
|
+
expect(result.valid).toBe(false);
|
|
120
|
+
expect(result.errors[0]).toContain('got 1 successful');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('ANTI-THÉÂTRE: passes when 1 successful + 2 failed and minimum is 1', () => {
|
|
124
|
+
const result = validateToolCalls([success('1'), failed('2'), failed('3')], { minimum: 1 });
|
|
125
|
+
expect(result.valid).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('error message mentions failed count when calls were excluded', () => {
|
|
129
|
+
const result = validateToolCalls([failed('1'), failed('2')], { minimum: 1 });
|
|
130
|
+
expect(result.errors[0]).toContain('2 failed excluded');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('error message omits excluded count when zero failed calls', () => {
|
|
134
|
+
const result = validateToolCalls([], { minimum: 1 });
|
|
135
|
+
expect(result.errors[0]).not.toContain('excluded');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('uses correct pluralization for singular minimum', () => {
|
|
139
|
+
const result = validateToolCalls([], { minimum: 1 });
|
|
140
|
+
expect(result.errors[0]).toContain('tool call,'); // singular — "tool call, got"
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('uses correct pluralization for plural minimum', () => {
|
|
144
|
+
const result = validateToolCalls([], { minimum: 3 });
|
|
145
|
+
expect(result.errors[0]).toContain('tool calls,'); // plural
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('passes when no requirements specified', () => {
|
|
149
|
+
const result = validateToolCalls([]);
|
|
150
|
+
expect(result.valid).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('passes when requirements is empty object', () => {
|
|
154
|
+
const result = validateToolCalls([], {});
|
|
155
|
+
expect(result.valid).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- maximum ---
|
|
159
|
+
|
|
160
|
+
it('passes when successful calls are below maximum', () => {
|
|
161
|
+
const result = validateToolCalls([success('1'), success('2')], { maximum: 5 });
|
|
162
|
+
expect(result.valid).toBe(true);
|
|
163
|
+
expect(result.errors).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('passes when successful calls equal maximum', () => {
|
|
167
|
+
const result = validateToolCalls([success('1'), success('2'), success('3')], { maximum: 3 });
|
|
168
|
+
expect(result.valid).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('fails when successful calls exceed maximum', () => {
|
|
172
|
+
const calls = Array.from({ length: 11 }, (_, i) => success(String(i)));
|
|
173
|
+
const result = validateToolCalls(calls, { maximum: 10 });
|
|
174
|
+
expect(result.valid).toBe(false);
|
|
175
|
+
expect(result.errors).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('error message includes actual count and maximum', () => {
|
|
179
|
+
const calls = Array.from({ length: 17 }, (_, i) => success(String(i)));
|
|
180
|
+
const result = validateToolCalls(calls, { maximum: 10 });
|
|
181
|
+
expect(result.errors[0]).toContain('17');
|
|
182
|
+
expect(result.errors[0]).toContain('10');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('error message mentions loop', () => {
|
|
186
|
+
const calls = Array.from({ length: 11 }, (_, i) => success(String(i)));
|
|
187
|
+
const result = validateToolCalls(calls, { maximum: 10 });
|
|
188
|
+
expect(result.errors[0]).toContain('loop');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('maximum counts only successful calls (failed excluded)', () => {
|
|
192
|
+
// 8 successful + 5 failed = 13 total, but only 8 count against maximum
|
|
193
|
+
const calls = [
|
|
194
|
+
...Array.from({ length: 8 }, (_, i) => success(String(i))),
|
|
195
|
+
...Array.from({ length: 5 }, (_, i) => failed(String(i + 100))),
|
|
196
|
+
];
|
|
197
|
+
const result = validateToolCalls(calls, { maximum: 9 });
|
|
198
|
+
expect(result.valid).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('maximum works independently of minimum', () => {
|
|
202
|
+
const calls = Array.from({ length: 15 }, (_, i) => success(String(i)));
|
|
203
|
+
const result = validateToolCalls(calls, { maximum: 10 });
|
|
204
|
+
expect(result.valid).toBe(false);
|
|
205
|
+
// minimum not set — only maximum error
|
|
206
|
+
expect(result.errors).toHaveLength(1);
|
|
207
|
+
expect(result.errors[0]).toContain('loop');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('minimum and maximum can both fail simultaneously', () => {
|
|
211
|
+
// 5 calls, minimum=10, maximum=3 → both fail
|
|
212
|
+
const calls = Array.from({ length: 5 }, (_, i) => success(String(i)));
|
|
213
|
+
const result = validateToolCalls(calls, { minimum: 10, maximum: 3 });
|
|
214
|
+
expect(result.valid).toBe(false);
|
|
215
|
+
expect(result.errors).toHaveLength(2);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('validateRequiredTools', () => {
|
|
220
|
+
it('passes when all required tools called', () => {
|
|
221
|
+
const toolCalls: ToolCall[] = [
|
|
222
|
+
{ id: '1', name: 'write_file', arguments: {} },
|
|
223
|
+
{ id: '2', name: 'read_file', arguments: {} }
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const result = validateRequiredTools(toolCalls, {
|
|
227
|
+
required_tools: ['write_file', 'read_file']
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result.valid).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('fails when required tool not called', () => {
|
|
234
|
+
const toolCalls: ToolCall[] = [
|
|
235
|
+
{ id: '1', name: 'read_file', arguments: {} }
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const result = validateRequiredTools(toolCalls, {
|
|
239
|
+
required_tools: ['write_file', 'read_file']
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(result.valid).toBe(false);
|
|
243
|
+
expect(result.errors).toContain("Required tool 'write_file' was not called");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('fails when multiple required tools not called', () => {
|
|
247
|
+
const toolCalls: ToolCall[] = [
|
|
248
|
+
{ id: '1', name: 'other_tool', arguments: {} }
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const result = validateRequiredTools(toolCalls, {
|
|
252
|
+
required_tools: ['write_file', 'read_file']
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(result.valid).toBe(false);
|
|
256
|
+
expect(result.errors).toHaveLength(2);
|
|
257
|
+
expect(result.errors).toContain("Required tool 'write_file' was not called");
|
|
258
|
+
expect(result.errors).toContain("Required tool 'read_file' was not called");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('passes when extra tools called', () => {
|
|
262
|
+
const toolCalls: ToolCall[] = [
|
|
263
|
+
{ id: '1', name: 'write_file', arguments: {} },
|
|
264
|
+
{ id: '2', name: 'read_file', arguments: {} },
|
|
265
|
+
{ id: '3', name: 'extra_tool', arguments: {} }
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const result = validateRequiredTools(toolCalls, {
|
|
269
|
+
required_tools: ['write_file']
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result.valid).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('passes when same tool called multiple times', () => {
|
|
276
|
+
const toolCalls: ToolCall[] = [
|
|
277
|
+
{ id: '1', name: 'write_file', arguments: { file: 'a.txt' } },
|
|
278
|
+
{ id: '2', name: 'write_file', arguments: { file: 'b.txt' } }
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const result = validateRequiredTools(toolCalls, {
|
|
282
|
+
required_tools: ['write_file']
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(result.valid).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('passes when no required tools specified', () => {
|
|
289
|
+
const toolCalls: ToolCall[] = [];
|
|
290
|
+
const result = validateRequiredTools(toolCalls);
|
|
291
|
+
expect(result.valid).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('passes when required_tools is empty array', () => {
|
|
295
|
+
const toolCalls: ToolCall[] = [];
|
|
296
|
+
const result = validateRequiredTools(toolCalls, { required_tools: [] });
|
|
297
|
+
expect(result.valid).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('ANTI-THÉÂTRE: fails when required tool called but all calls failed', () => {
|
|
301
|
+
const toolCalls: ToolCall[] = [
|
|
302
|
+
{ id: '1', name: 'write_file', arguments: {}, error: 'permission denied' },
|
|
303
|
+
{ id: '2', name: 'write_file', arguments: {}, error: 'ENOENT' },
|
|
304
|
+
];
|
|
305
|
+
const result = validateRequiredTools(toolCalls, { required_tools: ['write_file'] });
|
|
306
|
+
expect(result.valid).toBe(false);
|
|
307
|
+
expect(result.errors[0]).toContain("'write_file'");
|
|
308
|
+
expect(result.errors[0]).toContain('no successful calls');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('ANTI-THÉÂTRE: passes when required tool has at least one successful call', () => {
|
|
312
|
+
const toolCalls: ToolCall[] = [
|
|
313
|
+
{ id: '1', name: 'write_file', arguments: {}, error: 'ENOENT' },
|
|
314
|
+
{ id: '2', name: 'write_file', arguments: {} }, // success
|
|
315
|
+
];
|
|
316
|
+
const result = validateRequiredTools(toolCalls, { required_tools: ['write_file'] });
|
|
317
|
+
expect(result.valid).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('ANTI-THÉÂTRE: error distinguishes never-called from all-failed', () => {
|
|
321
|
+
const toolCalls: ToolCall[] = [
|
|
322
|
+
{ id: '1', name: 'write_file', arguments: {}, error: 'ENOENT' },
|
|
323
|
+
];
|
|
324
|
+
// write_file was called but all failed — different error than "was not called"
|
|
325
|
+
const result = validateRequiredTools(toolCalls, { required_tools: ['write_file', 'read_file'] });
|
|
326
|
+
expect(result.errors).toHaveLength(2);
|
|
327
|
+
// write_file: called but all failed
|
|
328
|
+
expect(result.errors.some(e => e.includes('write_file') && e.includes('no successful calls'))).toBe(true);
|
|
329
|
+
// read_file: never called
|
|
330
|
+
expect(result.errors.some(e => e.includes('read_file') && e.includes('was not called'))).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('validateCountedTools', () => {
|
|
335
|
+
it('passes when enough counted tool calls are made', () => {
|
|
336
|
+
const toolCalls: ToolCall[] = [
|
|
337
|
+
{ id: '1', name: 'repo_manager-apply_patch', arguments: {} },
|
|
338
|
+
];
|
|
339
|
+
const result = validateCountedTools(toolCalls, {
|
|
340
|
+
minimum: 1,
|
|
341
|
+
counted_tools: ['repo_manager.write_file', 'repo_manager.apply_patch'],
|
|
342
|
+
});
|
|
343
|
+
expect(result.valid).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('passes with mix of counted tools meeting minimum', () => {
|
|
347
|
+
const toolCalls: ToolCall[] = [
|
|
348
|
+
{ id: '1', name: 'repo_manager-write_file', arguments: {} },
|
|
349
|
+
{ id: '2', name: 'repo_manager-apply_patch', arguments: {} },
|
|
350
|
+
];
|
|
351
|
+
const result = validateCountedTools(toolCalls, {
|
|
352
|
+
minimum: 1,
|
|
353
|
+
counted_tools: ['repo_manager.write_file', 'repo_manager.apply_patch'],
|
|
354
|
+
});
|
|
355
|
+
expect(result.valid).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('fails when no counted tools are called', () => {
|
|
359
|
+
const toolCalls: ToolCall[] = [
|
|
360
|
+
{ id: '1', name: 'repo_manager-read_file', arguments: {} },
|
|
361
|
+
];
|
|
362
|
+
const result = validateCountedTools(toolCalls, {
|
|
363
|
+
minimum: 1,
|
|
364
|
+
counted_tools: ['repo_manager.write_file', 'repo_manager.apply_patch'],
|
|
365
|
+
});
|
|
366
|
+
expect(result.valid).toBe(false);
|
|
367
|
+
expect(result.errors[0]).toContain('Expected at least 1 successful call');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('passes when no counted_tools specified', () => {
|
|
371
|
+
const toolCalls: ToolCall[] = [];
|
|
372
|
+
const result = validateCountedTools(toolCalls, {});
|
|
373
|
+
expect(result.valid).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('normalizes tool names (dots vs hyphens)', () => {
|
|
377
|
+
const toolCalls: ToolCall[] = [
|
|
378
|
+
{ id: '1', name: 'repo_manager-apply_patch', arguments: {} },
|
|
379
|
+
];
|
|
380
|
+
const result = validateCountedTools(toolCalls, {
|
|
381
|
+
minimum: 1,
|
|
382
|
+
counted_tools: ['repo_manager.apply_patch'],
|
|
383
|
+
});
|
|
384
|
+
expect(result.valid).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('ANTI-THÉÂTRE: fails when counted tool calls all failed', () => {
|
|
388
|
+
const toolCalls: ToolCall[] = [
|
|
389
|
+
{ id: '1', name: 'repo_manager-write_file', arguments: {}, error: 'permission denied' },
|
|
390
|
+
{ id: '2', name: 'repo_manager-write_file', arguments: {}, error: 'ENOENT' },
|
|
391
|
+
];
|
|
392
|
+
const result = validateCountedTools(toolCalls, {
|
|
393
|
+
minimum: 1,
|
|
394
|
+
counted_tools: ['repo_manager.write_file'],
|
|
395
|
+
});
|
|
396
|
+
expect(result.valid).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('ANTI-THÉÂTRE: excludes failed calls from counted tool count', () => {
|
|
400
|
+
const toolCalls: ToolCall[] = [
|
|
401
|
+
{ id: '1', name: 'repo_manager-write_file', arguments: {} }, // success
|
|
402
|
+
{ id: '2', name: 'repo_manager-apply_patch', arguments: {}, error: 'ENOENT' }, // failed
|
|
403
|
+
];
|
|
404
|
+
// 1 successful counted, 1 failed counted → total counted successful = 1
|
|
405
|
+
const result = validateCountedTools(toolCalls, {
|
|
406
|
+
minimum: 2,
|
|
407
|
+
counted_tools: ['repo_manager.write_file', 'repo_manager.apply_patch'],
|
|
408
|
+
});
|
|
409
|
+
expect(result.valid).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('ANTI-THÉÂTRE: passes when enough successful counted calls', () => {
|
|
413
|
+
const toolCalls: ToolCall[] = [
|
|
414
|
+
{ id: '1', name: 'repo_manager-write_file', arguments: {} }, // success
|
|
415
|
+
{ id: '2', name: 'repo_manager-apply_patch', arguments: {} }, // success
|
|
416
|
+
{ id: '3', name: 'repo_manager-read_file', arguments: {}, error: 'ENOENT' }, // failed, not counted
|
|
417
|
+
];
|
|
418
|
+
const result = validateCountedTools(toolCalls, {
|
|
419
|
+
minimum: 2,
|
|
420
|
+
counted_tools: ['repo_manager.write_file', 'repo_manager.apply_patch'],
|
|
421
|
+
});
|
|
422
|
+
expect(result.valid).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('validateToolGroups', () => {
|
|
427
|
+
const success = (id: string, name: string): ToolCall => ({ id, name, arguments: {} });
|
|
428
|
+
const failed = (id: string, name: string): ToolCall => ({ id, name, arguments: {}, error: 'ENOENT' });
|
|
429
|
+
|
|
430
|
+
it('passes when required_tool_groups is absent', () => {
|
|
431
|
+
const result = validateToolGroups([], undefined);
|
|
432
|
+
expect(result.valid).toBe(true);
|
|
433
|
+
expect(result.errors).toHaveLength(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('passes when required_tool_groups is empty array', () => {
|
|
437
|
+
const result = validateToolGroups([], { required_tool_groups: [] });
|
|
438
|
+
expect(result.valid).toBe(true);
|
|
439
|
+
expect(result.errors).toHaveLength(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('passes when a group is empty', () => {
|
|
443
|
+
const result = validateToolGroups([], { required_tool_groups: [[]] });
|
|
444
|
+
expect(result.valid).toBe(true);
|
|
445
|
+
expect(result.errors).toHaveLength(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('passes when at least one tool from the group is called successfully', () => {
|
|
449
|
+
const toolCalls = [success('1', 'repo_manager-write_file')];
|
|
450
|
+
const result = validateToolGroups(toolCalls, {
|
|
451
|
+
required_tool_groups: [['repo_manager-write_file', 'repo_manager-apply_patch']]
|
|
452
|
+
});
|
|
453
|
+
expect(result.valid).toBe(true);
|
|
454
|
+
expect(result.errors).toHaveLength(0);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('fails when no tool from the group is called', () => {
|
|
458
|
+
const toolCalls = [success('1', 'repo_manager-read_file')];
|
|
459
|
+
const result = validateToolGroups(toolCalls, {
|
|
460
|
+
required_tool_groups: [['repo_manager-write_file', 'repo_manager-apply_patch']]
|
|
461
|
+
});
|
|
462
|
+
expect(result.valid).toBe(false);
|
|
463
|
+
expect(result.errors).toHaveLength(1);
|
|
464
|
+
expect(result.errors[0]).toContain('repo_manager-write_file');
|
|
465
|
+
expect(result.errors[0]).toContain('repo_manager-apply_patch');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('ANTI-THÉÂTRE: fails when group tool called but all calls failed', () => {
|
|
469
|
+
const toolCalls = [
|
|
470
|
+
failed('1', 'repo_manager-write_file'),
|
|
471
|
+
failed('2', 'repo_manager-apply_patch'),
|
|
472
|
+
];
|
|
473
|
+
const result = validateToolGroups(toolCalls, {
|
|
474
|
+
required_tool_groups: [['repo_manager-write_file', 'repo_manager-apply_patch']]
|
|
475
|
+
});
|
|
476
|
+
expect(result.valid).toBe(false);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('passes when second tool from group is called successfully (OR semantics)', () => {
|
|
480
|
+
const toolCalls = [success('1', 'repo_manager-apply_patch')];
|
|
481
|
+
const result = validateToolGroups(toolCalls, {
|
|
482
|
+
required_tool_groups: [['repo_manager-write_file', 'repo_manager-apply_patch']]
|
|
483
|
+
});
|
|
484
|
+
expect(result.valid).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('each group is independent: all must be satisfied', () => {
|
|
488
|
+
// Group 1 satisfied, group 2 not
|
|
489
|
+
const toolCalls = [success('1', 'repo_manager-write_file')];
|
|
490
|
+
const result = validateToolGroups(toolCalls, {
|
|
491
|
+
required_tool_groups: [
|
|
492
|
+
['repo_manager-write_file', 'repo_manager-apply_patch'],
|
|
493
|
+
['shell-run_command']
|
|
494
|
+
]
|
|
495
|
+
});
|
|
496
|
+
expect(result.valid).toBe(false);
|
|
497
|
+
expect(result.errors).toHaveLength(1);
|
|
498
|
+
expect(result.errors[0]).toContain('shell-run_command');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('passes when all groups are satisfied', () => {
|
|
502
|
+
const toolCalls = [
|
|
503
|
+
success('1', 'repo_manager-write_file'),
|
|
504
|
+
success('2', 'shell-run_command'),
|
|
505
|
+
];
|
|
506
|
+
const result = validateToolGroups(toolCalls, {
|
|
507
|
+
required_tool_groups: [
|
|
508
|
+
['repo_manager-write_file', 'repo_manager-apply_patch'],
|
|
509
|
+
['shell-run_command']
|
|
510
|
+
]
|
|
511
|
+
});
|
|
512
|
+
expect(result.valid).toBe(true);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('fails when multiple groups are not satisfied', () => {
|
|
516
|
+
const toolCalls = [success('1', 'repo_manager-read_file')];
|
|
517
|
+
const result = validateToolGroups(toolCalls, {
|
|
518
|
+
required_tool_groups: [
|
|
519
|
+
['repo_manager-write_file'],
|
|
520
|
+
['shell-run_command']
|
|
521
|
+
]
|
|
522
|
+
});
|
|
523
|
+
expect(result.valid).toBe(false);
|
|
524
|
+
expect(result.errors).toHaveLength(2);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('normalizes tool names (dots vs hyphens)', () => {
|
|
528
|
+
const toolCalls = [success('1', 'repo_manager-write_file')];
|
|
529
|
+
const result = validateToolGroups(toolCalls, {
|
|
530
|
+
required_tool_groups: [['repo_manager.write_file']]
|
|
531
|
+
});
|
|
532
|
+
expect(result.valid).toBe(true);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe('compose', () => {
|
|
537
|
+
it('returns valid when all validators pass', async () => {
|
|
538
|
+
const v1 = () => ({ valid: true, errors: [], warnings: [] });
|
|
539
|
+
const v2 = () => ({ valid: true, errors: [], warnings: [] });
|
|
540
|
+
|
|
541
|
+
const combined = compose(v1, v2);
|
|
542
|
+
const result = await combined('anything');
|
|
543
|
+
|
|
544
|
+
expect(result.valid).toBe(true);
|
|
545
|
+
expect(result.errors).toHaveLength(0);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('returns invalid when any validator fails', async () => {
|
|
549
|
+
const v1 = () => ({ valid: true, errors: [], warnings: [] });
|
|
550
|
+
const v2 = () => ({ valid: false, errors: ['v2 failed'], warnings: [] });
|
|
551
|
+
|
|
552
|
+
const combined = compose(v1, v2);
|
|
553
|
+
const result = await combined('anything');
|
|
554
|
+
|
|
555
|
+
expect(result.valid).toBe(false);
|
|
556
|
+
expect(result.errors).toContain('v2 failed');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('returns invalid when all validators fail', async () => {
|
|
560
|
+
const v1 = () => ({ valid: false, errors: ['v1 failed'], warnings: [] });
|
|
561
|
+
const v2 = () => ({ valid: false, errors: ['v2 failed'], warnings: [] });
|
|
562
|
+
|
|
563
|
+
const combined = compose(v1, v2);
|
|
564
|
+
const result = await combined('anything');
|
|
565
|
+
|
|
566
|
+
expect(result.valid).toBe(false);
|
|
567
|
+
expect(result.errors).toHaveLength(2);
|
|
568
|
+
expect(result.errors).toContain('v1 failed');
|
|
569
|
+
expect(result.errors).toContain('v2 failed');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('concatenates all errors', async () => {
|
|
573
|
+
const v1 = () => ({ valid: false, errors: ['error A', 'error B'], warnings: [] });
|
|
574
|
+
const v2 = () => ({ valid: false, errors: ['error C'], warnings: [] });
|
|
575
|
+
|
|
576
|
+
const combined = compose(v1, v2);
|
|
577
|
+
const result = await combined('anything');
|
|
578
|
+
|
|
579
|
+
expect(result.errors).toEqual(['error A', 'error B', 'error C']);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('concatenates all warnings', async () => {
|
|
583
|
+
const v1 = () => ({ valid: true, errors: [], warnings: ['warn A'] });
|
|
584
|
+
const v2 = () => ({ valid: true, errors: [], warnings: ['warn B', 'warn C'] });
|
|
585
|
+
|
|
586
|
+
const combined = compose(v1, v2);
|
|
587
|
+
const result = await combined('anything');
|
|
588
|
+
|
|
589
|
+
expect(result.warnings).toEqual(['warn A', 'warn B', 'warn C']);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('supports async validators', async () => {
|
|
593
|
+
const v1 = async () => {
|
|
594
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
595
|
+
return { valid: true, errors: [], warnings: [] };
|
|
596
|
+
};
|
|
597
|
+
const v2 = () => ({ valid: true, errors: [], warnings: [] });
|
|
598
|
+
|
|
599
|
+
const combined = compose(v1, v2);
|
|
600
|
+
const result = await combined('anything');
|
|
601
|
+
|
|
602
|
+
expect(result.valid).toBe(true);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('works with single validator', async () => {
|
|
606
|
+
const v1 = () => ({ valid: true, errors: [], warnings: [] });
|
|
607
|
+
|
|
608
|
+
const combined = compose(v1);
|
|
609
|
+
const result = await combined('anything');
|
|
610
|
+
|
|
611
|
+
expect(result.valid).toBe(true);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('works with three or more validators', async () => {
|
|
615
|
+
const v1 = () => ({ valid: true, errors: [], warnings: [] });
|
|
616
|
+
const v2 = () => ({ valid: false, errors: ['error 2'], warnings: [] });
|
|
617
|
+
const v3 = () => ({ valid: true, errors: [], warnings: [] });
|
|
618
|
+
|
|
619
|
+
const combined = compose(v1, v2, v3);
|
|
620
|
+
const result = await combined('anything');
|
|
621
|
+
|
|
622
|
+
expect(result.valid).toBe(false);
|
|
623
|
+
expect(result.errors).toEqual(['error 2']);
|
|
624
|
+
});
|
|
625
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"noImplicitReturns": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true
|
|
21
|
+
},
|
|
22
|
+
"include": ["src/**/*"],
|
|
23
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
24
|
+
}
|