schema.ts 16 KB

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