@studio-foundation/ralph 0.3.0-beta.1 → 0.3.0-beta.6

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/src/loop.ts DELETED
@@ -1,122 +0,0 @@
1
- // RALPH loop - main function
2
- import type { ValidationResult } from '@studio-foundation/contracts';
3
-
4
- export interface ExecutionContext {
5
- attempt: number;
6
- previousFailures: string[];
7
- }
8
-
9
- export interface RetryEvent<T> {
10
- attempt: number;
11
- result: T;
12
- validation: ValidationResult;
13
- allFailures: string[];
14
- }
15
-
16
- export interface RetryStrategy {
17
- getDelay(attempt: number): number;
18
- }
19
-
20
- export interface RalphConfig<T> {
21
- executor: (context: ExecutionContext) => Promise<T>;
22
- validator: (result: T) => ValidationResult | Promise<ValidationResult>;
23
- maxAttempts: number;
24
- retryStrategy: RetryStrategy;
25
- onRetry?: (event: RetryEvent<T>) => void | Promise<void>;
26
- onSuccess?: (result: T, attempts: number) => void | Promise<void>;
27
- onExhausted?: (lastResult: T, allFailures: string[]) => void | Promise<void>;
28
- signal?: AbortSignal;
29
- }
30
-
31
- export type RalphResult<T> =
32
- | { status: 'success'; result: T; attempts: number }
33
- | { status: 'exhausted'; lastResult: T; failures: string[]; attempts: number }
34
- | { status: 'cancelled'; lastResult?: T; attempts: number };
35
-
36
- export async function ralph<T>(config: RalphConfig<T>): Promise<RalphResult<T>> {
37
- const { executor, validator, maxAttempts, retryStrategy, onRetry, onSuccess, onExhausted, signal } = config;
38
-
39
- let attempt = 1;
40
- const allFailures: string[] = [];
41
- let lastResult: T | undefined;
42
-
43
- while (attempt <= maxAttempts) {
44
- // Check cancellation before each attempt
45
- if (signal?.aborted) {
46
- return { status: 'cancelled', lastResult, attempts: attempt };
47
- }
48
-
49
- // 1. Execute avec contexte
50
- const context: ExecutionContext = {
51
- attempt,
52
- previousFailures: [...allFailures]
53
- };
54
-
55
- let result: T;
56
- try {
57
- result = await executor(context);
58
- } catch (err) {
59
- // If signal was aborted, the executor likely threw an AbortError
60
- if (signal?.aborted) {
61
- return { status: 'cancelled', lastResult, attempts: attempt };
62
- }
63
- throw err; // Re-throw non-abort errors
64
- }
65
-
66
- lastResult = result;
67
-
68
- // 2. Validate
69
- const validation = await Promise.resolve(validator(result));
70
-
71
- // 3. Si valide → SUCCESS
72
- if (validation.valid) {
73
- await onSuccess?.(result, attempt);
74
- return { status: 'success', result, attempts: attempt };
75
- }
76
-
77
- // 4. Si invalide → accumuler erreurs
78
- allFailures.push(...validation.errors);
79
-
80
- // 5. Si dernière tentative → EXHAUSTED
81
- if (attempt >= maxAttempts) {
82
- await onExhausted?.(result, allFailures);
83
- return { status: 'exhausted', lastResult: result, attempts: attempt, failures: allFailures };
84
- }
85
-
86
- // Check cancellation before retry
87
- if (signal?.aborted) {
88
- return { status: 'cancelled', lastResult: result, attempts: attempt };
89
- }
90
-
91
- // 6. Callback + delay + retry
92
- const retryEvent: RetryEvent<T> = {
93
- attempt,
94
- result,
95
- validation,
96
- allFailures: [...allFailures]
97
- };
98
- await onRetry?.(retryEvent);
99
-
100
- const delay = retryStrategy.getDelay(attempt);
101
- if (delay > 0) {
102
- await abortableDelay(delay, signal);
103
- }
104
-
105
- attempt++;
106
- }
107
-
108
- // Unreachable mais TypeScript est content
109
- throw new Error('ralph loop should have returned');
110
- }
111
-
112
- /** Sleep that resolves immediately if signal is aborted */
113
- function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
114
- if (signal?.aborted) return Promise.resolve();
115
- return new Promise((resolve) => {
116
- const timer = setTimeout(resolve, ms);
117
- signal?.addEventListener('abort', () => {
118
- clearTimeout(timer);
119
- resolve();
120
- }, { once: true });
121
- });
122
- }
@@ -1,23 +0,0 @@
1
- // Retry strategies
2
- import type { RetryStrategy } from './loop.js';
3
-
4
- export function noDelay(): RetryStrategy {
5
- return {
6
- getDelay: () => 0,
7
- };
8
- }
9
-
10
- export function fixedDelay(ms: number): RetryStrategy {
11
- return {
12
- getDelay: () => ms,
13
- };
14
- }
15
-
16
- export function exponentialBackoff(baseMs: number, maxMs: number): RetryStrategy {
17
- return {
18
- getDelay: (attempt: number) => {
19
- const delay = baseMs * Math.pow(2, attempt - 1);
20
- return Math.min(delay, maxMs);
21
- },
22
- };
23
- }
package/src/validator.ts DELETED
@@ -1,160 +0,0 @@
1
- // Validation engine
2
- import type { ValidationResult, OutputContract, ToolCall, ToolCallRequirements } from '@studio-foundation/contracts';
3
-
4
- export type { ToolCallRequirements } from '@studio-foundation/contracts';
5
-
6
- export type Validator<T> = (result: T) => ValidationResult | Promise<ValidationResult>;
7
-
8
- export interface AgentRunResult {
9
- output: unknown;
10
- tool_calls: ToolCall[];
11
- }
12
-
13
- export function validateSchema(output: unknown, contract: OutputContract): ValidationResult {
14
- const errors: string[] = [];
15
- const warnings: string[] = [];
16
-
17
- // If no schema defined, consider it valid
18
- if (!contract.schema || !contract.schema.required_fields) {
19
- return { valid: true, errors, warnings };
20
- }
21
-
22
- const requiredFields = contract.schema.required_fields as string[];
23
-
24
- // Output must be an object to check fields
25
- if (output === null || typeof output !== 'object') {
26
- errors.push(`Expected object output, got ${output === null ? 'null' : typeof output}`);
27
- return { valid: false, errors, warnings };
28
- }
29
-
30
- const outputObj = output as Record<string, unknown>;
31
-
32
- // Check each required field
33
- for (const field of requiredFields) {
34
- if (!(field in outputObj)) {
35
- errors.push(`Missing required field: ${field}`);
36
- }
37
- }
38
-
39
- return {
40
- valid: errors.length === 0,
41
- errors,
42
- warnings
43
- };
44
- }
45
-
46
- function isSuccessfulToolCall(tc: ToolCall): boolean {
47
- return !tc.error;
48
- }
49
-
50
- export function validateToolCalls(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
51
- const errors: string[] = [];
52
- const warnings: string[] = [];
53
-
54
- const successfulCount = toolCalls.filter(isSuccessfulToolCall).length;
55
- const failedCount = toolCalls.length - successfulCount;
56
-
57
- if (requirements?.minimum !== undefined) {
58
- if (successfulCount < requirements.minimum) {
59
- const plural = requirements.minimum === 1 ? '' : 's';
60
- const excluded = failedCount > 0 ? ` (${failedCount} failed excluded)` : '';
61
- errors.push(
62
- `Expected at least ${requirements.minimum} successful tool call${plural}, got ${successfulCount} successful${excluded}`
63
- );
64
- }
65
- }
66
-
67
- if (requirements?.maximum !== undefined) {
68
- if (successfulCount > requirements.maximum) {
69
- const plural = successfulCount === 1 ? '' : 's';
70
- errors.push(
71
- `Tool call limit exceeded: made ${successfulCount} successful call${plural}, maximum is ${requirements.maximum}. ` +
72
- `This may indicate a loop. Check that the agent is not repeating the same operation.`
73
- );
74
- }
75
- }
76
-
77
- return { valid: errors.length === 0, errors, warnings };
78
- }
79
-
80
- /** Normalize tool name: dots → hyphens so both conventions match */
81
- function normalizeToolName(name: string): string {
82
- return name.replace(/\./g, '-');
83
- }
84
-
85
- export function validateRequiredTools(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
86
- const errors: string[] = [];
87
- const warnings: string[] = [];
88
-
89
- if (requirements?.required_tools && requirements.required_tools.length > 0) {
90
- for (const requiredTool of requirements.required_tools) {
91
- const normalizedRequired = normalizeToolName(requiredTool);
92
- const matchingCalls = toolCalls.filter(tc => normalizeToolName(tc.name) === normalizedRequired);
93
-
94
- if (matchingCalls.length === 0) {
95
- errors.push(`Required tool '${requiredTool}' was not called`);
96
- } else if (!matchingCalls.some(isSuccessfulToolCall)) {
97
- errors.push(`Required tool '${requiredTool}' has no successful calls (called ${matchingCalls.length} time${matchingCalls.length === 1 ? '' : 's'}, all failed)`);
98
- }
99
- }
100
- }
101
-
102
- return { valid: errors.length === 0, errors, warnings };
103
- }
104
-
105
- export function validateCountedTools(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
106
- const errors: string[] = [];
107
- const warnings: string[] = [];
108
-
109
- if (requirements?.counted_tools && requirements.counted_tools.length > 0 && requirements?.minimum !== undefined) {
110
- const countedSet = new Set(requirements.counted_tools.map(normalizeToolName));
111
- const count = toolCalls.filter(
112
- tc => countedSet.has(normalizeToolName(tc.name)) && isSuccessfulToolCall(tc)
113
- ).length;
114
-
115
- if (count < requirements.minimum) {
116
- const toolNames = requirements.counted_tools.join(', ');
117
- errors.push(
118
- `Expected at least ${requirements.minimum} successful call${requirements.minimum === 1 ? '' : 's'} to counted tools [${toolNames}], got ${count}`
119
- );
120
- }
121
- }
122
-
123
- return { valid: errors.length === 0, errors, warnings };
124
- }
125
-
126
- export function validateToolGroups(toolCalls: ToolCall[], requirements?: ToolCallRequirements): ValidationResult {
127
- const errors: string[] = [];
128
- const warnings: string[] = [];
129
-
130
- if (requirements?.required_tool_groups) {
131
- for (const group of requirements.required_tool_groups) {
132
- if (group.length === 0) continue;
133
- const normalizedGroup = new Set(group.map(normalizeToolName));
134
- const satisfied = toolCalls.some(
135
- tc => normalizedGroup.has(normalizeToolName(tc.name)) && isSuccessfulToolCall(tc)
136
- );
137
- if (!satisfied) {
138
- errors.push(
139
- `Expected at least one successful call from [${group.join(', ')}], got none`
140
- );
141
- }
142
- }
143
- }
144
-
145
- return { valid: errors.length === 0, errors, warnings };
146
- }
147
-
148
- export function compose<T>(...validators: Validator<T>[]): Validator<T> {
149
- return async (result: T): Promise<ValidationResult> => {
150
- const results = await Promise.all(
151
- validators.map(v => Promise.resolve(v(result)))
152
- );
153
-
154
- return {
155
- valid: results.every(r => r.valid),
156
- errors: results.flatMap(r => r.errors),
157
- warnings: results.flatMap(r => r.warnings)
158
- };
159
- };
160
- }
@@ -1,355 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { ralph, type ExecutionContext } from '../src/loop.js';
3
- import { noDelay } from '../src/retry-strategy.js';
4
-
5
- describe('ralph loop', () => {
6
- it('returns success on first attempt when valid', async () => {
7
- const executor = vi.fn().mockResolvedValue('result');
8
- const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
9
-
10
- const result = await ralph({
11
- executor,
12
- validator,
13
- maxAttempts: 3,
14
- retryStrategy: noDelay()
15
- });
16
-
17
- expect(result.status).toBe('success');
18
- expect(result.attempts).toBe(1);
19
- expect(executor).toHaveBeenCalledTimes(1);
20
- if (result.status === 'success') {
21
- expect(result.result).toBe('result');
22
- }
23
- });
24
-
25
- it('retries and succeeds on second attempt', async () => {
26
- const validator = vi.fn()
27
- .mockReturnValueOnce({ valid: false, errors: ['fail 1'], warnings: [] })
28
- .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
29
-
30
- const result = await ralph({
31
- executor: async () => 'result',
32
- validator,
33
- maxAttempts: 3,
34
- retryStrategy: noDelay()
35
- });
36
-
37
- expect(result.status).toBe('success');
38
- expect(result.attempts).toBe(2);
39
- expect(validator).toHaveBeenCalledTimes(2);
40
- });
41
-
42
- it('returns exhausted after max attempts', async () => {
43
- const validator = vi.fn().mockReturnValue({
44
- valid: false,
45
- errors: ['always fails'],
46
- warnings: []
47
- });
48
-
49
- const result = await ralph({
50
- executor: async () => 'result',
51
- validator,
52
- maxAttempts: 3,
53
- retryStrategy: noDelay()
54
- });
55
-
56
- expect(result.status).toBe('exhausted');
57
- expect(result.attempts).toBe(3);
58
- if (result.status === 'exhausted') {
59
- expect(result.failures).toHaveLength(3);
60
- expect(result.failures).toEqual(['always fails', 'always fails', 'always fails']);
61
- }
62
- });
63
-
64
- it('passes execution context with previousFailures', async () => {
65
- const executorCalls: ExecutionContext[] = [];
66
-
67
- const executor = vi.fn(async (ctx: ExecutionContext) => {
68
- executorCalls.push({ ...ctx });
69
- return 'result';
70
- });
71
-
72
- const validator = vi.fn()
73
- .mockReturnValueOnce({ valid: false, errors: ['error 1'], warnings: [] })
74
- .mockReturnValueOnce({ valid: false, errors: ['error 2'], warnings: [] })
75
- .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
76
-
77
- await ralph({
78
- executor,
79
- validator,
80
- maxAttempts: 5,
81
- retryStrategy: noDelay()
82
- });
83
-
84
- // First call: no previous failures
85
- expect(executorCalls[0]).toEqual({ attempt: 1, previousFailures: [] });
86
-
87
- // Second call: has first error
88
- expect(executorCalls[1]).toEqual({ attempt: 2, previousFailures: ['error 1'] });
89
-
90
- // Third call: has both errors
91
- expect(executorCalls[2]).toEqual({ attempt: 3, previousFailures: ['error 1', 'error 2'] });
92
- });
93
-
94
- it('calls onRetry callback on each retry', async () => {
95
- const onRetry = vi.fn();
96
- const validator = vi.fn()
97
- .mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
98
- .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
99
-
100
- await ralph({
101
- executor: async () => 'result',
102
- validator,
103
- maxAttempts: 3,
104
- retryStrategy: noDelay(),
105
- onRetry
106
- });
107
-
108
- expect(onRetry).toHaveBeenCalledTimes(1);
109
- expect(onRetry.mock.calls[0][0]).toMatchObject({
110
- attempt: 1,
111
- result: 'result',
112
- validation: { valid: false, errors: ['fail'], warnings: [] },
113
- allFailures: ['fail']
114
- });
115
- });
116
-
117
- it('calls onSuccess on successful completion', async () => {
118
- const onSuccess = vi.fn();
119
-
120
- await ralph({
121
- executor: async () => 'result',
122
- validator: () => ({ valid: true, errors: [], warnings: [] }),
123
- maxAttempts: 3,
124
- retryStrategy: noDelay(),
125
- onSuccess
126
- });
127
-
128
- expect(onSuccess).toHaveBeenCalledWith('result', 1);
129
- });
130
-
131
- it('calls onExhausted when max attempts reached', async () => {
132
- const onExhausted = vi.fn();
133
-
134
- await ralph({
135
- executor: async () => 'result',
136
- validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
137
- maxAttempts: 2,
138
- retryStrategy: noDelay(),
139
- onExhausted
140
- });
141
-
142
- expect(onExhausted).toHaveBeenCalledWith('result', ['fail', 'fail']);
143
- });
144
-
145
- it('does not call onRetry on last failed attempt', async () => {
146
- const onRetry = vi.fn();
147
-
148
- await ralph({
149
- executor: async () => 'result',
150
- validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
151
- maxAttempts: 2,
152
- retryStrategy: noDelay(),
153
- onRetry
154
- });
155
-
156
- // Should only retry once (between attempt 1 and 2)
157
- expect(onRetry).toHaveBeenCalledTimes(1);
158
- });
159
-
160
- it('accumulates errors from multiple validation failures', async () => {
161
- const validator = vi.fn()
162
- .mockReturnValueOnce({ valid: false, errors: ['error A', 'error B'], warnings: [] })
163
- .mockReturnValueOnce({ valid: false, errors: ['error C'], warnings: [] });
164
-
165
- const result = await ralph({
166
- executor: async () => 'result',
167
- validator,
168
- maxAttempts: 2,
169
- retryStrategy: noDelay()
170
- });
171
-
172
- if (result.status === 'exhausted') {
173
- expect(result.failures).toEqual(['error A', 'error B', 'error C']);
174
- }
175
- });
176
-
177
- it('supports async validators', async () => {
178
- const validator = vi.fn(async () => {
179
- await new Promise(resolve => setTimeout(resolve, 1));
180
- return { valid: true, errors: [], warnings: [] };
181
- });
182
-
183
- const result = await ralph({
184
- executor: async () => 'result',
185
- validator,
186
- maxAttempts: 3,
187
- retryStrategy: noDelay()
188
- });
189
-
190
- expect(result.status).toBe('success');
191
- });
192
-
193
- it('supports async callbacks', async () => {
194
- const onRetry = vi.fn(async () => {
195
- await new Promise(resolve => setTimeout(resolve, 1));
196
- });
197
-
198
- const validator = vi.fn()
199
- .mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
200
- .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
201
-
202
- await ralph({
203
- executor: async () => 'result',
204
- validator,
205
- maxAttempts: 3,
206
- retryStrategy: noDelay(),
207
- onRetry
208
- });
209
-
210
- expect(onRetry).toHaveBeenCalled();
211
- });
212
-
213
- it('returns cancelled immediately when signal is already aborted', async () => {
214
- const controller = new AbortController();
215
- controller.abort();
216
-
217
- const executor = vi.fn().mockResolvedValue('result');
218
- const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
219
-
220
- const result = await ralph({
221
- executor,
222
- validator,
223
- maxAttempts: 3,
224
- retryStrategy: noDelay(),
225
- signal: controller.signal,
226
- });
227
-
228
- expect(result.status).toBe('cancelled');
229
- expect(executor).not.toHaveBeenCalled();
230
- });
231
-
232
- it('returns cancelled when signal aborts between attempts', async () => {
233
- const controller = new AbortController();
234
-
235
- const executor = vi.fn().mockResolvedValue('result');
236
- const validator = vi.fn().mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] });
237
-
238
- // Abort after first validation
239
- validator.mockImplementationOnce(() => {
240
- controller.abort();
241
- return { valid: false, errors: ['fail 2'], warnings: [] };
242
- });
243
-
244
- const result = await ralph({
245
- executor,
246
- validator,
247
- maxAttempts: 5,
248
- retryStrategy: noDelay(),
249
- signal: controller.signal,
250
- });
251
-
252
- expect(result.status).toBe('cancelled');
253
- expect(executor).toHaveBeenCalledTimes(2);
254
- });
255
-
256
- it('returns cancelled when executor throws AbortError', async () => {
257
- const controller = new AbortController();
258
-
259
- const executor = vi.fn().mockImplementation(async () => {
260
- controller.abort();
261
- const err = new DOMException('The operation was aborted', 'AbortError');
262
- throw err;
263
- });
264
- const validator = vi.fn();
265
-
266
- const result = await ralph({
267
- executor,
268
- validator,
269
- maxAttempts: 3,
270
- retryStrategy: noDelay(),
271
- signal: controller.signal,
272
- });
273
-
274
- expect(result.status).toBe('cancelled');
275
- expect(validator).not.toHaveBeenCalled();
276
- });
277
-
278
- it('cancellation resolves pending retry delay immediately', async () => {
279
- const controller = new AbortController();
280
-
281
- let attempt = 0;
282
- const executor = vi.fn().mockImplementation(async () => {
283
- attempt++;
284
- if (attempt === 1) {
285
- // After first attempt, schedule abort in 5ms (well before any real delay)
286
- setTimeout(() => controller.abort(), 5);
287
- }
288
- return 'result';
289
- });
290
- const validator = vi.fn().mockReturnValue({ valid: false, errors: ['fail'], warnings: [] });
291
-
292
- const start = Date.now();
293
- const result = await ralph({
294
- executor,
295
- validator,
296
- maxAttempts: 5,
297
- retryStrategy: { getDelay: () => 60_000 }, // 60 second delay — should NOT wait
298
- signal: controller.signal,
299
- });
300
-
301
- const elapsed = Date.now() - start;
302
- expect(result.status).toBe('cancelled');
303
- expect(elapsed).toBeLessThan(5000); // Way less than 60s
304
- });
305
-
306
- it('works with object result type (generic T)', async () => {
307
- type Output = { value: number; label: string };
308
-
309
- const result = await ralph<Output>({
310
- executor: async () => ({ value: 42, label: 'ok' }),
311
- validator: () => ({ valid: true, errors: [], warnings: [] }),
312
- maxAttempts: 3,
313
- retryStrategy: noDelay()
314
- });
315
-
316
- expect(result.status).toBe('success');
317
- if (result.status === 'success') {
318
- expect(result.result.value).toBe(42);
319
- expect(result.result.label).toBe('ok');
320
- }
321
- });
322
-
323
- it('exhausts immediately with maxAttempts=1 on first failure', async () => {
324
- const validator = vi.fn().mockReturnValue({
325
- valid: false,
326
- errors: ['always fails'],
327
- warnings: []
328
- });
329
-
330
- const result = await ralph({
331
- executor: async () => 'result',
332
- validator,
333
- maxAttempts: 1,
334
- retryStrategy: noDelay()
335
- });
336
-
337
- expect(result.status).toBe('exhausted');
338
- expect(result.attempts).toBe(1);
339
- if (result.status === 'exhausted') {
340
- expect(result.failures).toEqual(['always fails']);
341
- }
342
- expect(validator).toHaveBeenCalledTimes(1);
343
- });
344
-
345
- it('propagates executor exceptions that are not abort errors', async () => {
346
- const error = new Error('unexpected failure');
347
-
348
- await expect(ralph({
349
- executor: async () => { throw error; },
350
- validator: () => ({ valid: true, errors: [], warnings: [] }),
351
- maxAttempts: 3,
352
- retryStrategy: noDelay()
353
- })).rejects.toThrow('unexpected failure');
354
- });
355
- });