schema.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. /** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
  2. import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
  3. import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
  4. import type { ToolCallView, ToolResultView } from './presentation.ts'
  5. // ---------------------------------------------------------------------------
  6. // SchemaSpec — the author-facing per-property type
  7. // ---------------------------------------------------------------------------
  8. /** Valid JSON Schema primitive types for tool parameters. */
  9. export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'
  10. /** One schema-spec property entry. */
  11. export interface SchemaProp {
  12. type: SchemaType
  13. /** Per-property required flag (NOT the JSON Schema top-level required array). */
  14. required?: true
  15. /** Human-readable description, surfaced in the JSON Schema as well. */
  16. description?: string
  17. /** Enum of allowed values (strings only). */
  18. enum?: string[]
  19. /**
  20. * Default value, emitted into the JSON Schema only (validation never applies
  21. * it — see the validator note below).
  22. *
  23. * XXX(unused-default): no tool definition in the repo sets `default`; it rides
  24. * into the wire schema for a model that no tool surfaces it to. Drop the field
  25. * and its converter line unless a real tool needs a model-visible default.
  26. */
  27. default?: unknown
  28. /** Nested properties for type: 'object'. */
  29. properties?: SchemaSpec
  30. /** Items schema for type: 'array'. */
  31. items?: SchemaProp
  32. }
  33. /**
  34. * The author-facing parameter schema: a shallow map of property name to
  35. * {@link SchemaProp}. Required-ness is a per-property boolean (`required:
  36. * true`), not a separate array.
  37. */
  38. export type SchemaSpec = Record<string, SchemaProp>
  39. // ---------------------------------------------------------------------------
  40. // InferArgs — type-level mapping from SchemaSpec to TS argument type
  41. // ---------------------------------------------------------------------------
  42. /** Map a {@link SchemaType} to its TS primitive type. */
  43. type TypeOf<T extends SchemaType> =
  44. T extends 'string' ? string :
  45. T extends 'number' ? number :
  46. T extends 'boolean' ? boolean :
  47. T extends 'object' ? Record<string, unknown> :
  48. T extends 'array' ? unknown[] :
  49. never
  50. /** Flatten an intersection into one object type for readable hovers. */
  51. type Simplify<T> = { [K in keyof T]: T[K] } & {}
  52. /** Keys of `S` whose prop is marked `required: true`. */
  53. type RequiredKeys<S extends SchemaSpec> =
  54. { [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S]
  55. /**
  56. * The VALUE type of one {@link SchemaProp} — optionality is handled at the
  57. * key level by {@link InferArgs}, never here.
  58. * - `properties` on 'object' → recurse into the nested SchemaSpec
  59. * - `items` on 'array' → recurse into the item prop (arrays of objects work)
  60. * - otherwise → the primitive for `type`
  61. */
  62. type InferPropValue<P extends SchemaProp> =
  63. P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
  64. P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
  65. TypeOf<P['type']>
  66. /**
  67. * Infer the TS argument type for a complete {@link SchemaSpec}.
  68. *
  69. * Properties marked `required: true` are required keys; all others are
  70. * genuinely optional keys (`?`), so callers may omit them entirely.
  71. *
  72. * Example:
  73. * ```ts
  74. * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
  75. * // → { path: string; limit?: number }
  76. * ```
  77. */
  78. export type InferArgs<S extends SchemaSpec> = Simplify<
  79. & { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
  80. & { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
  81. >
  82. // ---------------------------------------------------------------------------
  83. // Runtime conversion: SchemaSpec → JSON Schema
  84. // ---------------------------------------------------------------------------
  85. /**
  86. * Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
  87. * The per-property `required` flag is collected; the caller builds the
  88. * top-level `required` array.
  89. */
  90. function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
  91. const result: Record<string, unknown> = { type: prop.type }
  92. if (prop.description) result.description = prop.description
  93. if (prop.enum) result.enum = prop.enum
  94. if (prop.default !== undefined) result.default = prop.default
  95. const required = prop.required === true
  96. if (prop.type === 'object' && prop.properties) {
  97. const nested = schemaSpecToJsonSchema(prop.properties)
  98. result.properties = nested.properties
  99. if (nested.required && nested.required.length > 0) {
  100. result.required = nested.required
  101. }
  102. }
  103. if (prop.type === 'array' && prop.items) {
  104. const { schema: itemsSchema } = propToJsonSchema(prop.items)
  105. result.items = itemsSchema
  106. }
  107. return { schema: result, required }
  108. }
  109. /** The return type of {@link schemaSpecToJsonSchema}. */
  110. export interface JsonSchemaObject {
  111. type: 'object'
  112. properties: Record<string, unknown>
  113. required?: string[]
  114. }
  115. /**
  116. * Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
  117. * `properties`, `required` array).
  118. *
  119. * This is a plain function — no schemastery or other framework dependency.
  120. * @param spec - the author-facing per-property schema to convert.
  121. * @returns the wire-format JSON Schema; the top-level `required` array is
  122. * omitted entirely when no property is marked required.
  123. */
  124. export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
  125. const properties: Record<string, unknown> = {}
  126. const required: string[] = []
  127. for (const [key, prop] of Object.entries(spec)) {
  128. const { schema, required: isRequired } = propToJsonSchema(prop)
  129. properties[key] = schema
  130. if (isRequired) required.push(key)
  131. }
  132. const result: JsonSchemaObject = {
  133. type: 'object',
  134. properties,
  135. }
  136. if (required.length > 0) result.required = required
  137. return result
  138. }
  139. // ---------------------------------------------------------------------------
  140. // Runtime validation: model-generated args ↔ SchemaSpec
  141. // ---------------------------------------------------------------------------
  142. /**
  143. * Thrown by a {@link defineTool} tool when the model-generated arguments don't
  144. * match the declared {@link SchemaSpec}. Extends {@link HarnessError}
  145. * (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
  146. * returns an `isError` ToolExecutionResult carrying the structured error, so
  147. * the model can self-correct and downstream plugins can route on the code.
  148. */
  149. export class ToolArgsError extends HarnessError {
  150. /** The individual violation messages, in declaration order. */
  151. readonly violations: string[]
  152. constructor(violations: string[]) {
  153. super(`invalid arguments: ${violations.join('; ')}`, 'INVALID_ARGS')
  154. this.name = 'ToolArgsError'
  155. this.violations = violations
  156. }
  157. }
  158. /** Whether a value is a non-null, non-array object (a JSON Schema `object`). */
  159. function isPlainObject(value: unknown): value is Record<string, unknown> {
  160. return typeof value === 'object' && value !== null && !Array.isArray(value)
  161. }
  162. /** Collect violations for one property value against its {@link SchemaProp}. */
  163. function checkValue(prop: SchemaProp, value: unknown, path: string): string[] {
  164. switch (prop.type) {
  165. case 'string': {
  166. if (typeof value !== 'string') return [`"${path}" must be a string`]
  167. break
  168. }
  169. case 'number': {
  170. if (typeof value !== 'number') return [`"${path}" must be a number`]
  171. break
  172. }
  173. case 'boolean': {
  174. if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
  175. break
  176. }
  177. case 'object': {
  178. if (!isPlainObject(value)) return [`"${path}" must be an object`]
  179. // Mirror the converter: an object without `properties` only type-checks.
  180. return prop.properties ? checkSpec(prop.properties, value, path) : []
  181. }
  182. case 'array': {
  183. if (!Array.isArray(value)) return [`"${path}" must be an array`]
  184. // Mirror the converter: an array without `items` only type-checks.
  185. if (!prop.items) return []
  186. const items = prop.items
  187. return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`))
  188. }
  189. default: return assertNever(prop.type, 'validateArgs')
  190. }
  191. // Enum membership, checked uniformly: the converter emits `enum` for any
  192. // type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a
  193. // non-string value can never be a member — it falls out here, consistent
  194. // with the schema the model was given.
  195. if (prop.enum && !(prop.enum as unknown[]).includes(value)) {
  196. return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`]
  197. }
  198. return []
  199. }
  200. /** Collect violations for an object value against a {@link SchemaSpec}. */
  201. function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
  202. if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`]
  203. const violations: string[] = []
  204. for (const [key, prop] of Object.entries(spec)) {
  205. const propPath = path ? `${path}.${key}` : key
  206. const v = value[key]
  207. if (v === undefined) {
  208. // A required key absent OR present-but-undefined is a violation; an
  209. // optional absent key is fine. `default` is NOT applied (validation only).
  210. if (prop.required === true) violations.push(`missing required property "${propPath}"`)
  211. continue
  212. }
  213. violations.push(...checkValue(prop, v, propPath))
  214. }
  215. return violations
  216. }
  217. /**
  218. * Validate model-generated `args` against a {@link SchemaSpec}, returning a
  219. * list of human-readable violation messages (empty = valid). Total — never
  220. * throws, regardless of how malformed `args` is.
  221. *
  222. * Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
  223. * be a non-array object; required keys come only from `required: true`; extra
  224. * keys are allowed (no `additionalProperties: false`); `default` is not
  225. * applied; an `object`/`array` prop without `properties`/`items` only
  226. * type-checks; `enum` is membership (strings only).
  227. * @param spec - the declared parameter schema to validate against.
  228. * @param args - the model-generated arguments, however malformed.
  229. * @returns the violation messages in declaration order; empty means valid.
  230. */
  231. export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
  232. return checkSpec(spec, args, '')
  233. }
  234. // ---------------------------------------------------------------------------
  235. // defineTool — typed helper for first-party plugin authors
  236. // ---------------------------------------------------------------------------
  237. /** Options for {@link defineTool}. */
  238. export interface DefineToolOptions<S extends SchemaSpec> {
  239. /** Tool name (must be unique). */
  240. readonly name: string
  241. /** Human-readable description sent to the model. */
  242. readonly description: string
  243. /**
  244. * Parameter schema using the per-property-required DSL. Converted to
  245. * standard JSON Schema at runtime.
  246. */
  247. readonly parameters: S
  248. /**
  249. * Optional cooperative tool-call timeout budget in milliseconds. When given it
  250. * must be a positive finite number; it is attached to the produced
  251. * {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
  252. * is never sent to the model.
  253. */
  254. readonly timeoutMs?: number
  255. /**
  256. * Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
  257. * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
  258. * content only) or a `{ content, meta }` object to also attach a tool-private
  259. * presentation payload (see {@link ToolExecuteReturn}).
  260. */
  261. execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
  262. /**
  263. * Optional: how to present the PENDING state of one call in a UI (an editor
  264. * tool-call card, a CLI log line). `args` is the typed, schema-validated
  265. * argument shape — zero casts. Pure and side-effect-free: a UI may call it
  266. * during live streaming AND a session-log replay, so depend only on `args`.
  267. * The tool owns its presentation so a UI never special-cases tool names. See
  268. * {@link ToolCallView}.
  269. */
  270. presentCall?(args: InferArgs<S>): ToolCallView | undefined
  271. /**
  272. * Optional: how to present the COMPLETED state, given the typed `args` and the
  273. * `result`. Use it to reformat result content for a UI distinctly from the
  274. * model-facing text (e.g. a fenced ```console block). Pure and side-effect-
  275. * free for the same replay reason. See {@link ToolResultView}.
  276. */
  277. presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
  278. }
  279. /**
  280. * Define a first-party tool whose execution and presentation arguments are
  281. * inferred from its per-property schema. Raw JSON-Schema definitions remain
  282. * valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
  283. * @param options - the tool's name, description, typed parameter schema,
  284. * execute body, and optional presenters.
  285. * @returns a registry-ready definition with strict execution validation and
  286. * soft presenter validation for replay compatibility.
  287. */
  288. export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
  289. // Object-literal execute methods don't use `this`; the reference is safe.
  290. // eslint-disable-next-line @typescript-eslint/unbound-method
  291. const userExecute = options.execute
  292. // eslint-disable-next-line @typescript-eslint/unbound-method
  293. const userPresentCall = options.presentCall
  294. // eslint-disable-next-line @typescript-eslint/unbound-method
  295. const userPresentResult = options.presentResult
  296. if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
  297. throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
  298. }
  299. const tool: ToolDefinition = {
  300. name: options.name,
  301. description: options.description,
  302. parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
  303. ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
  304. async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
  305. // Validate the model-generated args before the typed body runs. On
  306. // mismatch we throw ToolArgsError; the registry turns it into an
  307. // isError result so the model can self-correct. After this guard, the
  308. // cast to InferArgs<S> reflects the validated shape.
  309. const violations = validateArgs(options.parameters, args)
  310. if (violations.length > 0) throw new ToolArgsError(violations)
  311. return userExecute(args as InferArgs<S>, exec)
  312. },
  313. }
  314. // Presentation is display-only and may run on REPLAY of arbitrary logged args
  315. // (possibly from an older schema), so it must never throw: validate softly and
  316. // fall back to `undefined` (a generic UI presentation) on any mismatch, rather
  317. // than the hard `ToolArgsError` the execute path raises.
  318. if (userPresentCall) {
  319. tool.presentCall = (args: unknown): ToolCallView | undefined => {
  320. if (validateArgs(options.parameters, args).length > 0) return undefined
  321. return userPresentCall(args as InferArgs<S>)
  322. }
  323. }
  324. if (userPresentResult) {
  325. tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
  326. if (validateArgs(options.parameters, args).length > 0) return undefined
  327. return userPresentResult(args as InferArgs<S>, result)
  328. }
  329. }
  330. return tool
  331. }