import { Schema } from 'effect';
import type { Tool } from './tool';
export interface ToolCoercion {
path: string;
before: unknown;
after: unknown;
}
export interface ToolValidationMetadata {
coerced: boolean;
coercions: ToolCoercion[];
}
export interface ToolValidationSuccess {
ok: true;
value: Input;
metadata: ToolValidationMetadata;
}
export interface ToolValidationFailure {
ok: false;
error: ToolValidationError;
}
/**
* Field-level validation error kinds
*/
export type FieldErrorKind = 'missing' | 'invalid' | 'wrong_type';
/**
* Structured field-level validation error
* Format: field name + error type (e.g., "email: invalid")
*/
export interface FieldValidationError {
/** Field path (e.g., "email" or "address.city") */
field: string;
/** Error kind: missing, invalid, or wrong_type */
kind: FieldErrorKind;
/** Human-readable error message */
message: string;
/** Expected type or format (for wrong_type/invalid) */
expected?: string;
/** Actual value or type received (for wrong_type) */
actual?: string;
}
export interface ToolValidationError {
message: string;
/** Legacy string-based issues for backward compatibility */
issues: string[];
/** Structured field-level errors for model self-correction */
fieldErrors?: FieldValidationError[];
stack?: string;
coercions?: ToolCoercion[];
}
export interface ToolValidationResult {
result: ToolValidationSuccess | ToolValidationFailure;
}
export interface ToolValidationOutcome {
output: Input;
metadata: ToolValidationMetadata;
}
const defaultMetadata: ToolValidationMetadata = {
coerced: false,
coercions: [],
};
/**
* Convert null values to undefined for OpenAI compatibility.
*
* OpenAI strict mode requires all fields in `required` array, with optional
* fields using `anyOf: [type, null]`. When OpenAI doesn't want to provide
* an optional field, it sends `null`. But Effect Schema expects `undefined`.
*
* This function recursively converts all `null` values to `undefined`.
*/
function convertNullToUndefined(value: T): T {
if (value === null) {
return undefined as unknown as T;
}
if (Array.isArray(value)) {
return value.map(convertNullToUndefined) as unknown as T;
}
if (value && typeof value === 'object') {
const result: Record = {};
for (const [key, val] of Object.entries(value)) {
result[key] = convertNullToUndefined(val);
}
return result as T;
}
return value;
}
function stripUnknownFields(input: Input, schema: Schema.Schema): Input {
const decoded = Schema.decodeUnknownSync(schema, { errors: 'all' })(input);
const encoded = Schema.encodeSync(schema)(decoded);
return Schema.decodeUnknownSync(schema, { errors: 'all', onExcessProperty: 'ignore' })(encoded);
}
function detectCoercions(input: Input, decoded: Input): ToolValidationMetadata {
const coercions: ToolCoercion[] = [];
const inspect = (before: unknown, after: unknown, path: string): void => {
if (before === after) {
return;
}
if (typeof before !== typeof after) {
coercions.push({ path, before, after });
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
const length = Math.max(before.length, after.length);
for (let i = 0; i < length; i += 1) {
inspect(before[i], after[i], `${path}[${i}]`);
}
return;
}
if (before && after && typeof before === 'object' && typeof after === 'object') {
const beforeRecord = before as Record;
const afterRecord = after as Record;
const keys = new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)]);
for (const key of keys) {
inspect(beforeRecord[key], afterRecord[key], path ? `${path}.${key}` : key);
}
}
};
inspect(input, decoded, '');
return {
coerced: coercions.length > 0,
coercions,
};
}
/**
* Parse Effect Schema error message to extract field errors
* Effect Schema formats errors like:
* { readonly name: string; readonly email: string }
* └─ ["email"]
* └─ is missing
*
* Or for type errors:
* { readonly age: number }
* └─ ["age"]
* └─ Expected number, actual "twenty"
*/
function parseEffectSchemaMessage(message: string): FieldValidationError[] {
const fieldErrors: FieldValidationError[] = [];
const lines = message.split('\n');
let currentField = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Match field path: ["fieldName"] or [0]
const fieldMatch = line.match(/\["?([^"\]]+)"?\]/);
if (fieldMatch) {
currentField = fieldMatch[1];
continue;
}
// Match error description
if (line.startsWith('└─')) {
const errorText = line.replace(/^└─\s*/, '').trim();
// Determine error kind
let kind: FieldErrorKind = 'invalid';
let expected: string | undefined;
let actual: string | undefined;
if (errorText.toLowerCase().includes('is missing')) {
kind = 'missing';
} else if (errorText.toLowerCase().includes('expected')) {
kind = 'wrong_type';
// Parse "Expected X, actual Y"
const expectedMatch = errorText.match(/Expected\s+(\S+)/i);
const actualMatch = errorText.match(/actual\s+"?([^"]+)"?$/i);
if (expectedMatch) expected = expectedMatch[1].replace(/,\s*$/, '');
if (actualMatch) actual = actualMatch[1];
}
if (currentField) {
fieldErrors.push({
field: currentField,
kind,
message: `${currentField}: ${kind}`,
expected,
actual,
});
}
}
}
return fieldErrors;
}
/**
* Recursively collect field errors from Effect Schema ParseError
*/
function collectFieldErrors(error: unknown): FieldValidationError[] {
const fieldErrors: FieldValidationError[] = [];
// Handle Effect Schema ParseError structure
if (error && typeof error === 'object') {
const err = error as any;
// Effect Schema ParseError has a formatted message property
if (err._tag === 'ParseError' && typeof err.message === 'string') {
return parseEffectSchemaMessage(err.message);
}
// Handle nested error structures
if (Array.isArray(err.errors)) {
for (const nestedError of err.errors) {
fieldErrors.push(...collectFieldErrors(nestedError));
}
}
if (Array.isArray(err.issues)) {
for (const issue of err.issues) {
fieldErrors.push(...collectFieldErrors(issue));
}
}
}
return fieldErrors;
}
/**
* Collect legacy string-based issues for backward compatibility
*/
function collectIssues(error: unknown): string[] {
if (error && typeof error === 'object' && '_tag' in error) {
return [String((error as { _tag: string })._tag)];
}
if (error instanceof Error) {
return [error.message];
}
return ['Unknown validation error'];
}
/**
* Build structured validation error from Effect Schema ParseError
*/
function buildValidationError(toolId: string, error: unknown): ToolValidationError {
const issues = collectIssues(error);
const fieldErrors = collectFieldErrors(error);
// Build field-level formatted issues: "field: kind"
const formattedIssues = fieldErrors.length > 0
? fieldErrors.map(fe => `${fe.field}: ${fe.kind}${fe.expected ? ` (expected ${fe.expected})` : ''}`)
: issues;
return {
message: `Tool "${toolId}" input validation failed`,
issues: formattedIssues,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined,
};
}
export function decodeToolInputs(tool: Tool, input: unknown): ToolValidationResult {
const schema = tool.schema?.input;
// Convert null values to undefined for OpenAI compatibility
// OpenAI sends null for optional fields, but Effect Schema expects undefined
const normalizedInput = convertNullToUndefined(input);
if (!schema) {
if (tool.strict) {
return {
result: {
ok: false,
error: {
message: `Tool "${tool.id}" is missing an input schema`,
issues: ['schema_missing'],
},
},
};
}
return {
result: {
ok: true,
value: normalizedInput as Input,
metadata: defaultMetadata,
},
};
}
try {
const decoded = stripUnknownFields(normalizedInput as Input, schema);
const metadata = detectCoercions(normalizedInput as Input, decoded);
return {
result: {
ok: true,
value: decoded,
metadata,
},
};
} catch (error) {
return {
result: {
ok: false,
error: buildValidationError(tool.id, error),
},
};
}
}
export function getDecodedToolInputs(tool: Tool, input: unknown): ToolValidationOutcome {
const { result } = decodeToolInputs(tool, input);
if (!result.ok) {
throw new Error(JSON.stringify(result.error));
}
return {
output: result.value,
metadata: result.metadata,
};
}
export function validateToolSchema(
tool: Pick & {
readonly schema?: {
readonly input?: unknown;
};
},
): void {
if (tool.strict && !tool.schema?.input) {
throw new Error(`Tool "${tool.id}" requires an input schema when strict mode is enabled`);
}
}
export function wrapToolExecution(
tool: Tool,
execute: (args: Input) => Promise