@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
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
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -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
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
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -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
|
+
}
|