schema.ts 17 KB

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