json-schema.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /**
  2. * Structured-output JSON Schema subset for subagents and workflows. It supports
  3. * one scalar `type`; object `properties`/`required`/boolean
  4. * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
  5. * annotations. Unsupported or misplaced keywords reject rather than being
  6. * accepted without enforcement, and structured-output roots must be objects.
  7. * @module dsh-tools/json-schema
  8. */
  9. import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
  10. /** The scalar values `enum`/`const` may carry (finite numbers only). */
  11. export type StructuredScalar = string | number | boolean | null
  12. /** The `type` keywords the subset accepts. */
  13. export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
  14. /**
  15. * One node of the structured-output schema subset. Recursive via `properties`
  16. * and `items`; see the module doc for the exact keyword semantics.
  17. */
  18. export interface StructuredSchemaNode {
  19. type: StructuredSchemaType
  20. /** Nested property schemas (`type: 'object'` only). */
  21. properties?: Record<string, StructuredSchemaNode>
  22. /** Required property names; each must appear in `properties`. */
  23. required?: string[]
  24. /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
  25. additionalProperties?: boolean
  26. /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
  27. items?: StructuredSchemaNode
  28. /** Allowed values (scalar types only). */
  29. enum?: StructuredScalar[]
  30. /** The single allowed value (scalar types only). */
  31. const?: StructuredScalar
  32. /** Annotation, ignored for validation. */
  33. description?: string
  34. /** Annotation, ignored for validation. */
  35. title?: string
  36. /** Annotation, ignored for validation (must still be JSON data). */
  37. default?: unknown
  38. /** Annotation, ignored for validation (must still be JSON data). */
  39. examples?: unknown
  40. }
  41. /** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
  42. export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
  43. /**
  44. * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
  45. * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
  46. * so seam code and tool results can route on it; `violations` lists every
  47. * offending path, not just the first.
  48. */
  49. export class OutputSchemaError extends HarnessError {
  50. /** The individual violation messages, in walk order. */
  51. readonly violations: string[]
  52. constructor(violations: string[]) {
  53. super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
  54. this.name = 'OutputSchemaError'
  55. this.violations = violations
  56. }
  57. }
  58. /** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
  59. const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
  60. const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
  61. const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
  62. /**
  63. * Whether a value is a PLAIN JSON object — non-null, non-array, and with a
  64. * prototype chain of at most one link (`null`-proto, or any realm's
  65. * `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
  66. * purpose: a schema materialized in another realm carries THAT realm's
  67. * `Object.prototype`, which an identity check would wrongly reject. Exotic
  68. * hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
  69. * they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
  70. * failing loud.
  71. */
  72. function isObjectLike(value: unknown): value is Record<string, unknown> {
  73. if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
  74. const proto: unknown = Object.getPrototypeOf(value)
  75. return proto === null || Object.getPrototypeOf(proto) === null
  76. }
  77. /** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
  78. function isStructuredScalar(value: unknown): value is StructuredScalar {
  79. return value === null || typeof value === 'string' || typeof value === 'boolean'
  80. || (typeof value === 'number' && Number.isFinite(value))
  81. }
  82. /**
  83. * Whether a value is JSON data (annotation payloads only): scalars, arrays, and
  84. * object-likes of such values. Realm-agnostic on purpose (no prototype check) —
  85. * the schema may have been materialized from another realm; structural JSON-ness
  86. * is what the wire needs. Cycles are rejected via `seen`.
  87. */
  88. function isJsonData(value: unknown, seen: Set<object>): boolean {
  89. if (isStructuredScalar(value)) return true
  90. // The scalar check above already returned for null, so `object` here is a real object.
  91. if (typeof value !== 'object') return false
  92. if (seen.has(value)) return false
  93. seen.add(value)
  94. try {
  95. if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
  96. // A non-plain object (Date, Map, class instance) is NOT JSON data even when
  97. // it has no enumerable values — it would serialize lossily, not loudly.
  98. if (!isObjectLike(value)) return false
  99. return Object.values(value).every(entry => isJsonData(entry, seen))
  100. } finally {
  101. seen.delete(value)
  102. }
  103. }
  104. /** Collect subset violations for one schema node (recursive walk). */
  105. function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
  106. if (!isObjectLike(node)) {
  107. violations.push(`${path} must be a schema object`)
  108. return
  109. }
  110. if (seen.has(node)) {
  111. violations.push(`${path} is circular`)
  112. return
  113. }
  114. seen.add(node)
  115. for (const key of Object.keys(node)) {
  116. if (CONSTRAINT_KEYWORDS.has(key)) continue
  117. if (ANNOTATION_KEYWORDS.has(key)) {
  118. if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
  119. continue
  120. }
  121. violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
  122. }
  123. if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
  124. violations.push(`${path}.description must be a string`)
  125. }
  126. if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
  127. violations.push(`${path}.title must be a string`)
  128. }
  129. const type = node.type
  130. if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
  131. violations.push(Array.isArray(type)
  132. ? `${path}.type must be a single type string (type arrays are not supported)`
  133. : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
  134. seen.delete(node)
  135. return
  136. }
  137. const schemaType = type as StructuredSchemaType
  138. // Keywords that only make sense on one type are rejected elsewhere — an
  139. // `items` on an object (or `properties` on a string) is a schema-author bug
  140. // the subset surfaces rather than ignores.
  141. const allowedFor: Record<string, StructuredSchemaType[]> = {
  142. properties: ['object'],
  143. required: ['object'],
  144. additionalProperties: ['object'],
  145. items: ['array'],
  146. enum: ['string', 'number', 'integer', 'boolean', 'null'],
  147. const: ['string', 'number', 'integer', 'boolean', 'null'],
  148. }
  149. for (const [key, types] of Object.entries(allowedFor)) {
  150. if (key in node && !types.includes(schemaType)) {
  151. violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
  152. }
  153. }
  154. switch (schemaType) {
  155. case 'object': {
  156. const properties = node.properties
  157. if (properties !== undefined) {
  158. if (!isObjectLike(properties)) {
  159. violations.push(`${path}.properties must be an object of schemas`)
  160. } else {
  161. for (const [key, child] of Object.entries(properties)) {
  162. checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
  163. }
  164. }
  165. }
  166. const required = node.required
  167. if (required !== undefined) {
  168. if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
  169. violations.push(`${path}.required must be an array of strings`)
  170. } else {
  171. const declared = isObjectLike(properties) ? properties : {}
  172. // The guard above proved every entry is a string.
  173. for (const key of required as string[]) {
  174. // Own-property check: `in` would let inherited names (`toString`)
  175. // satisfy the declared-in-properties contract via the prototype.
  176. if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
  177. }
  178. }
  179. }
  180. if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
  181. violations.push(`${path}.additionalProperties must be a boolean`)
  182. }
  183. break
  184. }
  185. case 'array': {
  186. if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
  187. break
  188. }
  189. case 'string':
  190. case 'number':
  191. case 'integer':
  192. case 'boolean':
  193. case 'null': {
  194. const allowed = node.enum
  195. if (allowed !== undefined) {
  196. if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
  197. violations.push(`${path}.enum must be a non-empty array of scalars`)
  198. }
  199. }
  200. if ('const' in node && !isStructuredScalar(node.const)) {
  201. violations.push(`${path}.const must be a scalar`)
  202. }
  203. break
  204. }
  205. /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
  206. default:
  207. assertNever(schemaType, 'assertSupportedOutputSchema')
  208. /* v8 ignore stop */
  209. }
  210. seen.delete(node)
  211. }
  212. /**
  213. * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
  214. * and entirely within the enforced subset. Throws {@link OutputSchemaError}
  215. * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
  216. * success. Call this at the seam boundary, before any child is created.
  217. * @param schema - the caller-supplied schema (unknown until asserted).
  218. * @returns nothing — the assertion signature narrows `schema` to
  219. * {@link StructuredOutputSchema} in the caller's scope on normal return.
  220. */
  221. export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
  222. const violations: string[] = []
  223. checkSchemaNode(schema, 'schema', violations, new Set())
  224. if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
  225. violations.push('schema.type must be "object" (structured output is object-rooted)')
  226. }
  227. if (violations.length > 0) throw new OutputSchemaError(violations)
  228. }
  229. /** Collect violations for one value against an (already asserted) schema node. */
  230. function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
  231. switch (node.type) {
  232. case 'object': {
  233. if (!isObjectLike(value)) return [`"${path}" must be an object`]
  234. const violations: string[] = []
  235. const properties = node.properties ?? {}
  236. // Own-property discipline throughout: JSON carries own enumerable
  237. // properties only, so an inherited `toString` must not satisfy
  238. // `required`, dodge `additionalProperties: false`, or be validated as if
  239. // the value carried it.
  240. for (const key of node.required ?? []) {
  241. if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
  242. }
  243. for (const [key, child] of Object.entries(properties)) {
  244. if (!Object.hasOwn(value, key) || value[key] === undefined) continue
  245. violations.push(...checkValue(child, value[key], `${path}.${key}`))
  246. }
  247. if (node.additionalProperties === false) {
  248. for (const key of Object.keys(value)) {
  249. if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
  250. }
  251. }
  252. return violations
  253. }
  254. case 'array': {
  255. if (!Array.isArray(value)) return [`"${path}" must be an array`]
  256. if (!node.items) return []
  257. const items = node.items
  258. return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
  259. }
  260. case 'string': {
  261. if (typeof value !== 'string') return [`"${path}" must be a string`]
  262. break
  263. }
  264. case 'number': {
  265. if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
  266. break
  267. }
  268. case 'integer': {
  269. if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
  270. break
  271. }
  272. case 'boolean': {
  273. if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
  274. break
  275. }
  276. case 'null': {
  277. if (value !== null) return [`"${path}" must be null`]
  278. break
  279. }
  280. default:
  281. return assertNever(node.type, 'validateStructuredValue')
  282. }
  283. // Scalar constraint checks, shared by every scalar branch above.
  284. if (node.enum && !node.enum.includes(value)) {
  285. return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
  286. }
  287. if ('const' in node && value !== node.const) {
  288. return [`"${path}" must be ${JSON.stringify(node.const)}`]
  289. }
  290. return []
  291. }
  292. /**
  293. * Validate a value against an (already {@link assertSupportedOutputSchema}-
  294. * asserted) schema. Returns human-readable, path-qualified violation messages
  295. * — empty means valid. Total: never throws, however malformed the value.
  296. * @param schema - the asserted schema to check against.
  297. * @param value - the candidate value (e.g. parsed tool-call arguments).
  298. * @returns every violation found, in walk order (empty = valid).
  299. */
  300. export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
  301. return checkValue(schema, value, 'value')
  302. }