@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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @studio-foundation/ralph
2
+
3
+ The retry engine. Execute → validate → retry with escalated feedback → repeat.
4
+
5
+ **RALPH** = Recursive Automated Loop for Persistent Handling.
6
+
7
+ ## Role
8
+
9
+ ralph sits between the engine (orchestration) and runner (LLM execution). It knows nothing about LLMs — it takes a generic `executor` function and a contract, and loops until the output passes or max attempts is reached.
10
+
11
+ ```
12
+ engine → ralph(executor, contract) → success | exhausted
13
+
14
+ executor = () => runner.runAgent(...)
15
+ ```
16
+
17
+ ## Key exports
18
+
19
+ ```typescript
20
+ import { ralph, RalphConfig, RalphResult } from '@studio-foundation/ralph';
21
+ import { validateSchema, validateToolCalls, validateRequiredTools, compose } from '@studio-foundation/ralph';
22
+ import { exponentialBackoff, fixedDelay, noDelay } from '@studio-foundation/ralph';
23
+
24
+ const result = await ralph({
25
+ executor: async (context) => runner.runAgent(context),
26
+ validator: compose(
27
+ (r) => validateSchema(r.output, contract),
28
+ (r) => validateToolCalls(r.tool_calls_count, requirements),
29
+ ),
30
+ maxAttempts: 3,
31
+ retryStrategy: exponentialBackoff(1000, 30000),
32
+ onRetry: async (event) => { /* observability hook */ },
33
+ signal: abortController.signal, // optional — enables cooperative cancellation
34
+ });
35
+ // result.status: 'success' | 'exhausted' | 'cancelled'
36
+ ```
37
+
38
+ ## How it works
39
+
40
+ 1. Calls `executor` with current context
41
+ 2. Validates the output using the `validator` function (composed validators)
42
+ 3. If pass → returns `{ status: 'success', result, attempts }`
43
+ 4. If fail → calls `onRetry`, waits (retry strategy), retries with failure context
44
+ 5. If max attempts reached → returns `{ status: 'exhausted', lastResult, failures, attempts }`
45
+ 6. If `signal` is aborted at any point → returns `{ status: 'cancelled', lastResult?, attempts }`
46
+
47
+ ## Validators
48
+
49
+ ralph exports composable validators that the engine uses to build per-stage validation:
50
+
51
+ | Validator | Purpose |
52
+ |-----------|---------|
53
+ | `validateSchema(output, contract)` | Check required fields are present |
54
+ | `validateToolCalls(count, reqs)` | Check minimum tool call count |
55
+ | `validateRequiredTools(calls, reqs)` | Check specific tools were called |
56
+ | `validateCountedTools(calls, reqs)` | OR semantics — any of these count toward minimum |
57
+ | `compose(...validators)` | Combine multiple validators (all must pass) |
58
+
59
+ ## Retry strategies
60
+
61
+ | Strategy | Behavior |
62
+ |----------|----------|
63
+ | `exponentialBackoff(min, max)` | Exponential backoff with jitter |
64
+ | `fixedDelay(ms)` | Fixed wait between attempts |
65
+ | `noDelay()` | No wait (prompt escalation handled by runner) |
66
+
67
+ ## Rules
68
+
69
+ - **ralph doesn't know runner.** The `executor` is `() => Promise<T>`. ralph doesn't care what's behind it.
70
+ - **ralph doesn't know engine.** It takes config, it returns a result. No pipeline state, no events.
71
+ - Validation logic is in exported validators — the engine composes them per stage.
@@ -0,0 +1,19 @@
1
+ name: analysis
2
+ version: 1
3
+
4
+ schema:
5
+ required_fields:
6
+ - summary
7
+ - recommendations
8
+ summary:
9
+ min_length: 50
10
+ recommendations:
11
+ min_items: 1
12
+
13
+ tool_calls:
14
+ minimum: 0 # Analysis doesn't require tool calls
15
+
16
+ custom_rules:
17
+ - name: actionable-recommendations
18
+ description: "Recommendations must be specific and actionable"
19
+ check: "recommendations.length > 0"
@@ -0,0 +1,21 @@
1
+ name: code-generation
2
+ version: 1
3
+
4
+ schema:
5
+ required_fields:
6
+ - summary
7
+ - files_changed
8
+ files_changed:
9
+ min_items: 1
10
+ item_schema:
11
+ required: [path, action, content]
12
+
13
+ tool_calls:
14
+ minimum: 1
15
+ required_tools:
16
+ - repo_manager.write_file
17
+
18
+ custom_rules:
19
+ - name: no-theatre
20
+ description: "Agent must actually call tools, not just describe calling them"
21
+ check: "tool_calls_count > 0 OR stage_kind != code_generation"
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@studio-foundation/ralph",
3
+ "version": "0.3.0-beta.1",
4
+ "description": "RALPH loop engine - Recursive Automated Loop for Persistent Handling",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "studio",
16
+ "ralph",
17
+ "retry",
18
+ "validation"
19
+ ],
20
+ "author": "Ariane Guay",
21
+ "license": "AGPL-3.0-only",
22
+ "dependencies": {
23
+ "js-yaml": "^4.1.1",
24
+ "@studio-foundation/contracts": "0.3.0-beta.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/js-yaml": "^4.0.9",
28
+ "@types/node": "^25.2.3",
29
+ "@vitest/coverage-v8": "^4.0.18",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^4.0.18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "dev": "tsc --watch",
36
+ "clean": "rm -rf dist",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:coverage": "vitest run --coverage"
41
+ }
42
+ }
@@ -0,0 +1,9 @@
1
+ // Enrich context between retries
2
+ import type { ExecutionContext, RetryEvent } from './loop.js';
3
+
4
+ export function buildRetryContext<T>(event: RetryEvent<T>): ExecutionContext {
5
+ return {
6
+ attempt: event.attempt + 1,
7
+ previousFailures: event.allFailures
8
+ };
9
+ }
@@ -0,0 +1,28 @@
1
+ // Load and parse output contracts from YAML
2
+ import { readFile } from 'node:fs/promises';
3
+ import yaml from 'js-yaml';
4
+ import type { OutputContract } from '@studio-foundation/contracts';
5
+
6
+ export async function loadContract(path: string): Promise<OutputContract> {
7
+ const content = await readFile(path, 'utf-8');
8
+ return parseContract(content);
9
+ }
10
+
11
+ export function parseContract(yamlContent: string): OutputContract {
12
+ const parsed = yaml.load(yamlContent) as OutputContract;
13
+
14
+ // Validation basique
15
+ if (!parsed || typeof parsed !== 'object') {
16
+ throw new Error('Invalid contract: expected object');
17
+ }
18
+
19
+ if (!parsed.name || typeof parsed.name !== 'string') {
20
+ throw new Error('Invalid contract: missing or invalid name');
21
+ }
22
+
23
+ if (parsed.version === undefined || typeof parsed.version !== 'number') {
24
+ throw new Error('Invalid contract: missing or invalid version');
25
+ }
26
+
27
+ return parsed;
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Export barrel for @studio/ralph
2
+
3
+ export * from './loop.js';
4
+ export * from './validator.js';
5
+ export * from './contracts.js';
6
+ export * from './retry-strategy.js';
7
+ export * from './context-enricher.js';
package/src/loop.ts ADDED
@@ -0,0 +1,122 @@
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
+ }
@@ -0,0 +1,23 @@
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
+ }
@@ -0,0 +1,160 @@
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
+ }