UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

419 lines (418 loc) • 15.3 kB
import { Compile } from "typebox/compile"; import { Value } from "typebox/value"; import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js"; const validatorCache = new WeakMap(); const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); function isRecord(value) { return typeof value === "object" && value !== null; } function isJsonSchemaObject(value) { return isRecord(value); } function hasTypeBoxMetadata(schema) { return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND); } function getSchemaTypes(schema) { if (typeof schema.type === "string") { return [schema.type]; } if (Array.isArray(schema.type)) { return schema.type.filter((type) => typeof type === "string"); } return []; } function matchesJsonType(value, type) { switch (type) { case "number": return typeof value === "number"; case "integer": return typeof value === "number" && Number.isInteger(value); case "boolean": return typeof value === "boolean"; case "string": return typeof value === "string"; case "null": return value === null; case "array": return Array.isArray(value); case "object": return isRecord(value) && !Array.isArray(value); default: return false; } } function isValidatorSchema(value) { return isRecord(value); } const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu; function parseJsonNumberString(value) { const trimmed = value.trim(); if (!trimmed || !JSON_NUMBER_TOKEN_RE.test(trimmed)) { return undefined; } const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : undefined; } function parseJsonIntegerString(value) { const parsed = parseJsonNumberString(value); return parsed !== undefined && Number.isSafeInteger(parsed) ? parsed : undefined; } function parseJsonContainerString(value, type) { const trimmed = value.trim(); if (type === "array" && !trimmed.startsWith("[")) { return undefined; } if (type === "object" && !trimmed.startsWith("{")) { return undefined; } let parsed; try { parsed = JSON.parse(trimmed); } catch { return undefined; } const matchesType = type === "array" ? Array.isArray(parsed) : isRecord(parsed) && !Array.isArray(parsed); return matchesType ? parsed : undefined; } function getSubSchemaValidator(schema) { if (!isValidatorSchema(schema)) { return undefined; } try { return getValidator(schema); } catch { return undefined; } } function coercePrimitiveByType(value, type) { switch (type) { case "number": { if (typeof value === "string" && value.trim() !== "") { const parsed = parseJsonNumberString(value); if (parsed !== undefined) { return parsed; } } if (typeof value === "boolean") { return value ? 1 : 0; } return value; } case "integer": { if (typeof value === "string" && value.trim() !== "") { const parsed = parseJsonIntegerString(value); if (parsed !== undefined) { return parsed; } } if (typeof value === "boolean") { return value ? 1 : 0; } return value; } case "boolean": { if (typeof value === "string") { if (value === "true") { return true; } if (value === "false") { return false; } } if (typeof value === "number") { if (value === 1) { return true; } if (value === 0) { return false; } } return value; } case "string": { if (typeof value === "number" || typeof value === "boolean") { return String(value); } return value; } case "array": case "object": { if (typeof value === "string") { const parsed = parseJsonContainerString(value, type); if (parsed !== undefined) { return parsed; } } return value; } case "null": { return value; } default: return value; } } function applySchemaObjectCoercion(value, schema) { const properties = schema.properties; const definedKeys = new Set(properties ? Object.keys(properties) : []); if (properties) { for (const [key, propertySchema] of Object.entries(properties)) { if (key in value) { value[key] = coerceWithJsonSchema(value[key], propertySchema); } } } if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) { for (const [key, propertyValue] of Object.entries(value)) { if (!definedKeys.has(key)) { value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties); } } } } function applySchemaArrayCoercion(value, schema) { if (Array.isArray(schema.items)) { for (let index = 0; index < value.length; index++) { const itemSchema = schema.items[index]; if (itemSchema) { value[index] = coerceWithJsonSchema(value[index], itemSchema); } } return; } if (isJsonSchemaObject(schema.items)) { for (let index = 0; index < value.length; index++) { value[index] = coerceWithJsonSchema(value[index], schema.items); } } } function coerceWithUnionSchema(value, schemas) { for (const schema of schemas) { const candidate = structuredClone(value); const coerced = coerceWithJsonSchema(candidate, schema); const validator = getSubSchemaValidator(schema); if (validator?.Check(coerced)) { return coerced; } } return value; } function coerceWithJsonSchema(value, schema) { let nextValue = value; if (Array.isArray(schema.allOf)) { for (const nested of schema.allOf) { nextValue = coerceWithJsonSchema(nextValue, nested); } } if (Array.isArray(schema.anyOf)) { nextValue = coerceWithUnionSchema(nextValue, schema.anyOf); } if (Array.isArray(schema.oneOf)) { nextValue = coerceWithUnionSchema(nextValue, schema.oneOf); } const schemaTypes = getSchemaTypes(schema); const matchesUnionMember = schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType)); if (schemaTypes.length > 0 && !matchesUnionMember) { for (const schemaType of schemaTypes) { const candidate = coercePrimitiveByType(nextValue, schemaType); if (candidate !== nextValue) { nextValue = candidate; break; } } } if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) { applySchemaObjectCoercion(nextValue, schema); } if (schemaTypes.includes("array") && Array.isArray(nextValue)) { applySchemaArrayCoercion(nextValue, schema); } return nextValue; } function getValidator(schema) { const key = schema; const cached = validatorCache.get(key); if (cached) { return cached; } const validator = Compile(schema); validatorCache.set(key, validator); return validator; } function formatValidationPath(error) { if (error.keyword === "required") { const requiredProperty = error.params .requiredProperties?.[0]; if (requiredProperty) { const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; } } const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); return path || "root"; } const INSTANCE_PATH_MAX_BRANCHES = 256; function resolveInstancePath(root, instancePath) { if (instancePath === "") return { found: true, value: root, segments: [] }; const body = instancePath.startsWith("/") ? instancePath.slice(1) : instancePath; const found = []; let budget = INSTANCE_PATH_MAX_BRANCHES; const walk = (cursor, remaining, segments) => { if (found.length > 1 || budget <= 0) return; if (cursor === null || typeof cursor !== "object") return; const container = cursor; for (let cut = remaining.indexOf("/");; cut = remaining.indexOf("/", cut + 1)) { const key = cut === -1 ? remaining : remaining.slice(0, cut); if (budget <= 0) return; budget--; if (Object.prototype.hasOwnProperty.call(container, key)) { if (cut === -1) found.push({ value: container[key], segments: [...segments, key] }); else walk(container[key], remaining.slice(key.length + 1), [...segments, key]); if (found.length > 1) return; } if (cut === -1) return; } }; walk(root, body, []); if (found.length !== 1 || budget <= 0) return { found: false }; return { found: true, value: found[0].value, segments: found[0].segments }; } function accessorFrom(segments, property) { const all = property === undefined ? [...segments] : [...segments, property]; return all.join("."); } function jsonTypeNameOf(value) { if (value === null) return "null"; if (value === undefined) return "undefined"; if (Array.isArray(value)) return "array"; const t = typeof value; return t === "object" ? "object" : t; } function synthesizeValidationIssues(errors, checkedValue) { const sentences = []; const rest = []; for (const error of errors) { const resolved = resolveInstancePath(checkedValue, error.instancePath); const segments = resolved.found ? resolved.segments : undefined; if (segments !== undefined && error.keyword === "required") { const properties = error.params.requiredProperties ?? []; for (const property of properties) { sentences.push(`The required parameter \`${accessorFrom(segments, property)}\` is missing`); } continue; } if (segments !== undefined && error.keyword === "additionalProperties") { const properties = error.params.additionalProperties ?? []; for (const property of properties) { sentences.push(`An unexpected parameter \`${accessorFrom(segments, property)}\` was provided`); } continue; } if (segments !== undefined && error.keyword === "type" && resolved.found) { const expected = error.params.type; const expectedText = Array.isArray(expected) ? expected.join(" | ") : (expected ?? "the declared type"); const provided = jsonTypeNameOf(resolved.value); const accessor = accessorFrom(segments) || "root"; sentences.push(`The parameter \`${accessor}\` type is expected as \`${expectedText}\` but provided as \`${provided}\``); continue; } rest.push(` - ${formatValidationPath(error)}: ${error.message}`); } let restText = ""; if (rest.length > 0) { const marker = (n) => ` … ${n} more issue${n === 1 ? "" : "s"} omitted`; const kept = []; let used = 0; for (const line of rest) { const cost = kept.length === 0 ? line.length : line.length + 1; if (used + cost > VALIDATION_ERROR_ISSUES_MAX_CHARS) break; kept.push(line); used += cost; } if (kept.length === rest.length) { restText = kept.join("\n"); } else { while (kept.length > 0 && used + marker(rest.length - kept.length).length + 1 > VALIDATION_ERROR_ISSUES_MAX_CHARS) { const removed = kept.pop(); used -= kept.length === 0 ? removed.length : removed.length + 1; } if (kept.length === 0) { const clip = "… (issue truncated)"; const others = rest.length - 1; const markerCost = others > 0 ? marker(others).length + 1 : 0; const room = VALIDATION_ERROR_ISSUES_MAX_CHARS - markerCost - clip.length; if (room > 0) { kept.push(`${sliceHeadSafe(rest[0], room)}${clip}`); } } const omitted = rest.length - kept.length; if (omitted > 0) kept.push(marker(omitted)); restText = kept.join("\n"); } } const all = [...sentences, ...(restText ? [restText] : [])]; return all.join("\n") || "Unknown validation error"; } export function validateToolCall(tools, toolCall) { const tool = findToolByName(tools, toolCall.name); if (!tool) { throw new Error(`Tool "${toolCall.name}" not found`); } return validateToolArguments(tool, toolCall); } export function findToolByName(tools, name) { return tools?.find((t) => t.name === name) ?? tools?.find((t) => t.aliases?.includes(name)); } export function validateToolArguments(tool, toolCall) { const args = structuredClone(toolCall.arguments); Value.Convert(tool.parameters, args); const validator = getValidator(tool.parameters); if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { const coerced = coerceWithJsonSchema(args, tool.parameters); if (coerced !== args) { if (isRecord(args) && isRecord(coerced)) { for (const key of Object.keys(args)) { delete args[key]; } Object.assign(args, coerced); } else { return validator.Check(coerced) ? coerced : args; } } } if (validator.Check(args)) { return args; } const errors = synthesizeValidationIssues([...validator.Errors(args)], args); const schemaJson = (() => { try { const s = JSON.stringify(tool.parameters); return s.length > VALIDATION_ERROR_SCHEMA_MAX_CHARS ? `${s.slice(0, VALIDATION_ERROR_SCHEMA_MAX_CHARS)}… (schema truncated)` : s; } catch { return "(schema not serializable)"; } })(); throw new Error(`Validation failed for tool "${toolCall.name}":\n${errors}\n\nExpected parameter schema:\n${schemaJson}`); } const VALIDATION_ERROR_SCHEMA_MAX_CHARS = 4_000; const VALIDATION_ERROR_ISSUES_MAX_CHARS = 2_000;