Files
package-agent-runtime/src/validation.ts
T

196 lines
7.7 KiB
TypeScript

import { QxReject, canonicalJson, type Json } from "@quixos/camino-replica-core";
export function boundedText(value: string, limit = 65536, allowEmpty = true): string {
if (typeof value !== "string" || (!allowEmpty && !value.trim()) || new TextEncoder().encode(value).length > limit)
throw new QxReject("AGENT_INVALID_TEXT");
return value;
}
export function json(value: string, limit = 65536): Json {
boundedText(value, limit, false);
let parsed: Json;
try {
parsed = JSON.parse(value);
} catch {
throw new QxReject("AGENT_INVALID_JSON");
}
let nodes = 0;
function walk(v: Json, depth: number) {
if (++nodes > 8192 || depth > 24) throw new QxReject("AGENT_JSON_LIMIT");
if (Array.isArray(v)) for (const child of v) walk(child, depth + 1);
else if (v && typeof v === "object") for (const child of Object.values(v)) walk(child, depth + 1);
}
walk(parsed, 0);
return parsed;
}
export function object(v: Json): Record<string, Json> {
if (!v || Array.isArray(v) || typeof v !== "object") throw new QxReject("AGENT_EXPECTED_OBJECT");
return v as Record<string, Json>;
}
export function timestamp(v: string) {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(v) || !Number.isFinite(Date.parse(v)))
throw new QxReject("AGENT_INVALID_TIME");
}
const schemaKeys = new Set([
"type",
"properties",
"required",
"items",
"enum",
"additionalProperties",
"minimum",
"maximum",
"minLength",
"maxLength",
"minItems",
"maxItems",
"description",
"title",
]);
export function schema(v: Json, depth = 0, budget = { nodes: 0 }): void {
if (++budget.nodes > 256 || depth > 12) throw new QxReject("AGENT_SCHEMA_LIMIT");
const s = object(v);
for (const k of Object.keys(s)) if (!schemaKeys.has(k)) throw new QxReject("AGENT_SCHEMA_UNSUPPORTED");
if (!["string", "number", "integer", "boolean", "null", "array", "object"].includes(String(s.type)))
throw new QxReject("AGENT_SCHEMA_TYPE");
if (s.enum !== undefined && (!Array.isArray(s.enum) || !s.enum.length || s.enum.length > 128))
throw new QxReject("AGENT_SCHEMA_ENUM");
for (const k of ["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"]) {
if (s[k] !== undefined && (typeof s[k] !== "number" || !Number.isFinite(s[k])))
throw new QxReject("AGENT_SCHEMA_BOUND");
if (
k !== "minimum" &&
k !== "maximum" &&
s[k] !== undefined &&
(Number(s[k]) < 0 || !Number.isInteger(s[k]) || Number(s[k]) > 65536)
)
throw new QxReject("AGENT_SCHEMA_BOUND");
}
for (const [min, max] of [
["minimum", "maximum"],
["minLength", "maxLength"],
["minItems", "maxItems"],
])
if (s[min] !== undefined && s[max] !== undefined && Number(s[min]) > Number(s[max]))
throw new QxReject("AGENT_SCHEMA_BOUND");
if (s.type === "object") {
const p = object(s.properties ?? {});
if (Object.keys(p).length > 64) throw new QxReject("AGENT_SCHEMA_LIMIT");
if (s.additionalProperties !== undefined && typeof s.additionalProperties !== "boolean")
throw new QxReject("AGENT_SCHEMA_UNSUPPORTED");
if (
s.required !== undefined &&
(!Array.isArray(s.required) ||
s.required.some((k) => typeof k !== "string" || !Object.hasOwn(p, k)) ||
new Set(s.required).size !== s.required.length)
)
throw new QxReject("AGENT_SCHEMA_REQUIRED");
for (const child of Object.values(p)) schema(child, depth + 1, budget);
}
if (s.type === "array") schema(s.items ?? null, depth + 1, budget);
}
export function validateResult(supplied: Json, value: Json): void {
schema(supplied);
let nodes = 0;
function check(shape: Json, v: Json, depth: number) {
if (++nodes > 8192 || depth > 24) throw new QxReject("AGENT_RESULT_LIMIT");
const s = object(shape),
type = s.type;
const actual = v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
if (!(type === actual || (type === "integer" && typeof v === "number" && Number.isInteger(v))))
throw new QxReject("AGENT_RESULT_TYPE");
if (s.enum && !(s.enum as Json[]).some((e) => canonicalJson(e) === canonicalJson(v)))
throw new QxReject("AGENT_RESULT_ENUM");
if (
typeof v === "number" &&
((s.minimum !== undefined && v < Number(s.minimum)) || (s.maximum !== undefined && v > Number(s.maximum)))
)
throw new QxReject("AGENT_RESULT_BOUND");
if (typeof v === "string") {
const n = Array.from(v).length;
if (n < Number(s.minLength ?? 0) || n > Number(s.maxLength ?? 65536)) throw new QxReject("AGENT_RESULT_BOUND");
}
if (Array.isArray(v)) {
if (v.length < Number(s.minItems ?? 0) || v.length > Number(s.maxItems ?? 8192))
throw new QxReject("AGENT_RESULT_BOUND");
for (const e of v) check(s.items, e, depth + 1);
}
if (type === "object") {
const p = object(s.properties ?? {}),
o = object(v);
for (const k of (s.required ?? []) as string[])
if (!Object.hasOwn(o, k)) throw new QxReject("AGENT_RESULT_REQUIRED");
for (const [k, e] of Object.entries(o)) {
if (Object.hasOwn(p, k)) check(p[k], e, depth + 1);
else if (s.additionalProperties === false) throw new QxReject("AGENT_RESULT_PROPERTY");
}
}
}
check(supplied, value, 0);
}
export function settings(value: string): Record<string, Json> {
const s = object(json(value));
const keys = new Set([
"profile",
"response",
"capabilities",
"notifications",
"retry",
"limits",
"interactive",
"systemInstructions",
]);
for (const k of Object.keys(s)) if (!keys.has(k)) throw new QxReject("AGENT_SETTINGS_UNKNOWN");
if (s.systemInstructions !== undefined) boundedText(s.systemInstructions as string);
if (s.profile !== undefined) {
if (typeof s.profile !== "string") throw new QxReject("AGENT_SETTINGS_TYPE");
boundedText(s.profile, 80, false);
}
const response = object(s.response ?? { kind: "text" });
for (const k of Object.keys(response))
if (!["kind", "schema"].includes(k)) throw new QxReject("AGENT_RESPONSE_UNKNOWN");
if (response.kind !== "json" && response.schema !== undefined) throw new QxReject("AGENT_UNEXPECTED_SCHEMA");
if (!["text", "json", "unit"].includes(String(response.kind))) throw new QxReject("AGENT_RESPONSE_MODE");
if (response.kind === "json") schema(response.schema ?? null);
if (
s.capabilities !== undefined &&
(!Array.isArray(s.capabilities) ||
s.capabilities.length > 6 ||
new Set(s.capabilities).size !== s.capabilities.length ||
s.capabilities.some(
(c) => !["web-search", "graph-read", "graph-write", "eval", "source-read", "source-write"].includes(String(c)),
))
)
throw new QxReject("AGENT_CAPABILITY");
if (s.retry !== undefined && !["never", "inference-only"].includes(String(s.retry)))
throw new QxReject("AGENT_RETRY");
if (
s.retry === "inference-only" &&
Array.isArray(s.capabilities) &&
s.capabilities.some((c) => ["graph-write", "eval", "source-write"].includes(String(c)))
)
throw new QxReject("AGENT_UNSAFE_RETRY");
for (const k of ["interactive", "notifications"])
if (s[k] !== undefined && typeof s[k] !== "boolean") throw new QxReject("AGENT_SETTINGS_TYPE");
if (s.limits !== undefined) {
const limits = object(s.limits);
for (const [k, v] of Object.entries(limits))
if (
!Object.hasOwn({ toolSteps: 128, activeMs: 1800000, outputBytes: 262144, retries: 1 }, k) ||
!Number.isInteger(v) ||
Number(v) < 0 ||
Number(v) >
({ toolSteps: 128, activeMs: 1800000, outputBytes: 262144, retries: 1 } as Record<string, number>)[k]
)
throw new QxReject("AGENT_LIMIT");
}
return {
profile: "balanced",
response: { kind: "text" },
capabilities: [],
notifications: false,
retry: "never",
interactive: false,
...s,
};
}