tool-schema.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { JsonPointer, Schema } from "effect"
  2. import type { Definition, JsonSchema, SchemaType } from "./tool.js"
  3. const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
  4. const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
  5. /**
  6. * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
  7. * with dot access as a tool-path segment). Anything else must be quoted/bracketed.
  8. */
  9. export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
  10. /** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
  11. const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
  12. const effectNumberSentinel = (schema: JsonSchema) =>
  13. schema.type === "string" &&
  14. Array.isArray(schema.enum) &&
  15. schema.enum.length === 1 &&
  16. (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
  17. const intersection = (members: ReadonlyArray<string>): string => {
  18. const concrete = members.filter((member) => member !== "unknown")
  19. if (concrete.length === 0) return "unknown"
  20. if (concrete.length === 1) return concrete[0] ?? "unknown"
  21. return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
  22. }
  23. /**
  24. * Recursion ceiling for schema rendering. Object, array, and union recursion all increment
  25. * depth, so this bounds every recursion path - pathological or structurally cyclic schemas
  26. * degrade to `unknown` instead of overflowing the stack (rendering must never throw).
  27. */
  28. const MAX_RENDER_DEPTH = 8
  29. type RenderContext = {
  30. readonly definitions: Readonly<Record<string, JsonSchema>>
  31. /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
  32. readonly pretty: boolean
  33. }
  34. const hasUnresolvedRef = (
  35. schema: JsonSchema,
  36. definitions: Readonly<Record<string, JsonSchema>>,
  37. seen: ReadonlySet<string> = new Set(),
  38. visited: ReadonlySet<JsonSchema> = new Set(),
  39. ): boolean => {
  40. if (visited.has(schema)) return false
  41. const nextVisited = new Set([...visited, schema])
  42. if (schema.$ref !== undefined) {
  43. const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
  44. const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
  45. if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
  46. if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
  47. }
  48. return [
  49. ...(schema.anyOf ?? []),
  50. ...(schema.oneOf ?? []),
  51. ...(schema.allOf ?? []),
  52. ...Object.values(schema.properties ?? {}),
  53. ...(schema.items === undefined ? [] : [schema.items]),
  54. ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []),
  55. ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
  56. }
  57. /**
  58. * Schema constraints a TypeScript type cannot express natively but a model benefits from,
  59. * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
  60. */
  61. const docTags = (schema: JsonSchema): Array<string> => {
  62. const tags: Array<string> = []
  63. if (schema.deprecated === true) tags.push("@deprecated")
  64. if (schema.default !== undefined) {
  65. try {
  66. const rendered = JSON.stringify(schema.default)
  67. if (rendered !== undefined) tags.push(`@default ${rendered}`)
  68. } catch {
  69. // unserializable default: skip rather than emit a broken tag
  70. }
  71. }
  72. if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
  73. if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
  74. if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
  75. return tags
  76. }
  77. /**
  78. * Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
  79. * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
  80. * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
  81. * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
  82. * callers can prepend it directly to the field line.
  83. */
  84. const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
  85. const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
  86. line.replaceAll("*/", "* /").replace(/\s+$/, ""),
  87. )
  88. while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
  89. while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
  90. if (lines.length === 0) return ""
  91. if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
  92. const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
  93. return `${pad}/**\n${body}\n${pad} */\n`
  94. }
  95. const renderSchema = (
  96. schema: JsonSchema,
  97. ctx: RenderContext,
  98. depth = 0,
  99. seen: ReadonlySet<string> = new Set(),
  100. ): string => {
  101. if (depth > MAX_RENDER_DEPTH) return "unknown"
  102. const nested =
  103. schema.definitions === undefined && schema.$defs === undefined
  104. ? ctx
  105. : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
  106. if (schema.$ref) {
  107. const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
  108. const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
  109. if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
  110. return intersection([
  111. renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
  112. renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen),
  113. ])
  114. }
  115. if (schema.const !== undefined) return renderLiteral(schema.const)
  116. if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
  117. const alternatives = schema.anyOf ?? schema.oneOf
  118. if (alternatives) {
  119. // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
  120. // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
  121. // real JSON Schema unions such as `string | number` or `number | null` must keep
  122. // every branch.
  123. if (
  124. alternatives.some((item) => item.type === "number") &&
  125. alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
  126. )
  127. return "number"
  128. // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
  129. // (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
  130. if (
  131. alternatives.length === 2 &&
  132. alternatives[0]?.type === "object" &&
  133. alternatives[0].properties === undefined &&
  134. alternatives[1]?.type === "array" &&
  135. alternatives[1].items === undefined
  136. ) {
  137. return "{}"
  138. }
  139. const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen))
  140. if (members.some((member) => member === "unknown")) return "unknown"
  141. return intersection([
  142. members.join(" | "),
  143. renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen),
  144. ])
  145. }
  146. if (schema.allOf) {
  147. const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
  148. if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
  149. return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
  150. }
  151. if (Array.isArray(schema.type)) {
  152. return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ")
  153. }
  154. if (schema.type === "string") return "string"
  155. if (schema.type === "number" || schema.type === "integer") return "number"
  156. if (schema.type === "boolean") return "boolean"
  157. if (schema.type === "null") return "null"
  158. if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>`
  159. if (schema.type === "object" || schema.properties) {
  160. const required = new Set(schema.required ?? [])
  161. const properties = Object.entries(schema.properties ?? {})
  162. const additional = schema.additionalProperties
  163. const indexType =
  164. additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined
  165. const field = ([name, value]: readonly [string, JsonSchema]) =>
  166. `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}`
  167. if (!ctx.pretty) {
  168. const fields = properties.map(field)
  169. if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
  170. return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
  171. }
  172. // Pretty: an indented block, each described field preceded by its JSDoc comment.
  173. if (properties.length === 0 && indexType === undefined) return "{}"
  174. const pad = " ".repeat(depth + 1)
  175. const lines = properties.map(
  176. (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
  177. )
  178. if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
  179. return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
  180. }
  181. return "unknown"
  182. }
  183. export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
  184. try {
  185. const visible = decoded ? Schema.toType(schema) : schema
  186. const document = Schema.toJsonSchemaDocument(visible) as {
  187. readonly schema: JsonSchema
  188. readonly definitions?: Readonly<Record<string, JsonSchema>>
  189. }
  190. return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
  191. } catch {
  192. return "unknown"
  193. }
  194. }
  195. /** Renders a raw JSON Schema document as a TypeScript type string. */
  196. export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
  197. try {
  198. return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
  199. } catch {
  200. return "unknown"
  201. }
  202. }
  203. /** One input property of a tool, extracted best-effort from its input schema. */
  204. export type InputProperty = {
  205. readonly name: string
  206. readonly description: string | undefined
  207. readonly required: boolean
  208. }
  209. /**
  210. * The property names, descriptions, and required flags of a tool's input schema - the raw
  211. * material for search text. Best-effort: Effect Schemas go through their
  212. * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
  213. * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
  214. * Anything unresolvable yields `[]` (search falls back to path + description).
  215. */
  216. export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
  217. try {
  218. const document = isEffectSchema(definition.input)
  219. ? (Schema.toJsonSchemaDocument(definition.input) as {
  220. readonly schema: JsonSchema
  221. readonly definitions?: Readonly<Record<string, JsonSchema>>
  222. })
  223. : {
  224. schema: definition.input,
  225. definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
  226. }
  227. const definitions = document.definitions ?? {}
  228. let schema = document.schema
  229. if (schema.$ref !== undefined) {
  230. const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
  231. const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
  232. const resolved = name === undefined ? undefined : definitions[name]
  233. if (resolved === undefined) return []
  234. schema = resolved
  235. }
  236. const required = new Set(schema.required ?? [])
  237. return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
  238. name,
  239. description: typeof value.description === "string" ? value.description : undefined,
  240. required: required.has(name),
  241. }))
  242. } catch {
  243. return []
  244. }
  245. }
  246. /**
  247. * The model-visible TypeScript type of a tool's input. `pretty` renders an indented
  248. * multiline block with schema descriptions and constraints as JSDoc comments on the
  249. * fields; the default stays the compact single-line form.
  250. */
  251. export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
  252. isEffectSchema(definition.input)
  253. ? toTypeScript(definition.input, false, pretty)
  254. : jsonSchemaToTypeScript(definition.input, pretty)
  255. /**
  256. * The model-visible TypeScript type of a tool's result; tools without an output schema
  257. * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
  258. */
  259. export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
  260. definition.output === undefined
  261. ? "unknown"
  262. : isEffectSchema(definition.output)
  263. ? toTypeScript(definition.output, true, pretty)
  264. : jsonSchemaToTypeScript(definition.output, pretty)
  265. /**
  266. * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
  267. * JSON-Schema-described inputs pass through unvalidated (render-only).
  268. */
  269. export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
  270. isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
  271. /**
  272. * Decodes a tool result before it is exposed to the program. Effect Schemas validate and
  273. * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
  274. * the host value through unchanged.
  275. */
  276. export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
  277. definition.output !== undefined && isEffectSchema(definition.output)
  278. ? Schema.decodeUnknownSync(definition.output)(value)
  279. : value