@sema-agent/core
Version:
Stateless, task-oriented AI agent core
419 lines (418 loc) • 15.3 kB
JavaScript
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;