schema.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. /** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
  2. import { HarnessError } from '@deepseek-ai/dsh-llm'
  3. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  4. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  5. import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './index.ts'
  6. import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
  7. import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
  8. import type { ToolCallView, ToolResultView } from './presentation.ts'
  9. /** Annotation keywords shared by every author-facing schema node. */
  10. export interface ValueSchemaAnnotations {
  11. /** Human-readable description projected into JSON Schema and generated types. */
  12. description?: string
  13. /** Human-readable title projected into JSON Schema. */
  14. title?: string
  15. /** Non-validating default annotation; it must be lossless JSON data. */
  16. default?: JsonValue
  17. /** Non-validating examples annotation; it must be lossless JSON data. */
  18. examples?: JsonValue
  19. }
  20. /** String value schema with type-correct literal constraints. */
  21. export interface StringValueSchemaSpec extends ValueSchemaAnnotations {
  22. type: 'string'
  23. enum?: readonly string[]
  24. const?: string
  25. }
  26. /** Finite JSON-number schema with type-correct literal constraints. */
  27. export interface NumberValueSchemaSpec extends ValueSchemaAnnotations {
  28. type: 'number'
  29. enum?: readonly number[]
  30. const?: number
  31. }
  32. /** Integer schema with type-correct literal constraints. */
  33. export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations {
  34. type: 'integer'
  35. enum?: readonly number[]
  36. const?: number
  37. }
  38. /** Boolean value schema with type-correct literal constraints. */
  39. export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations {
  40. type: 'boolean'
  41. enum?: readonly boolean[]
  42. const?: boolean
  43. }
  44. /** Null value schema with type-correct literal constraints. */
  45. export interface NullValueSchemaSpec extends ValueSchemaAnnotations {
  46. type: 'null'
  47. enum?: readonly null[]
  48. const?: null
  49. }
  50. /** Array value schema; omitted `items` accepts any lossless JSON item. */
  51. export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations {
  52. type: 'array'
  53. items?: ValueSchemaSpec
  54. }
  55. /**
  56. * Explicit object value schema. Openness is mandatory so a nested or output
  57. * object never acquires an accidental JSON Schema default.
  58. */
  59. export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations {
  60. type: 'object'
  61. properties?: ParameterSchemaSpec
  62. additionalProperties: boolean
  63. }
  64. /** Author-only unconstrained lossless JSON node. */
  65. export interface JsonValueSchemaSpec extends ValueSchemaAnnotations {
  66. type: 'json'
  67. }
  68. /** Exact-one union schema; at least two branches are required. */
  69. export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations {
  70. oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]]
  71. }
  72. /** One author-facing schema for any lossless JSON value root. */
  73. export type ValueSchemaSpec =
  74. | StringValueSchemaSpec
  75. | NumberValueSchemaSpec
  76. | IntegerValueSchemaSpec
  77. | BooleanValueSchemaSpec
  78. | NullValueSchemaSpec
  79. | ArrayValueSchemaSpec
  80. | ObjectValueSchemaSpec
  81. | JsonValueSchemaSpec
  82. | OneOfValueSchemaSpec
  83. /** One implicit parameter-root property, optionally required. */
  84. export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
  85. /**
  86. * Tool parameter schema. The map itself is an implicit open object root;
  87. * requiredness remains a per-property `required: true` annotation.
  88. */
  89. export type ParameterSchemaSpec = {
  90. [key: string]: ParameterPropertySpec
  91. [key: symbol]: never
  92. }
  93. /** Raw JSON Schema projection of the implicit parameter object. */
  94. export interface ParameterJsonSchema extends ObjectJsonSchema {
  95. properties: Record<string, JsonSchemaNode>
  96. }
  97. /** Flatten an intersection into one object type for readable hovers. */
  98. type Simplify<T> = { [K in keyof T]: T[K] } & {}
  99. /** String keys of one property map; runtime compilation rejects symbol keys. */
  100. type StringKeyOf<S> = Extract<keyof S, string>
  101. /** Keys of a property map marked `required: true`. */
  102. type RequiredKeys<S> = {
  103. [K in StringKeyOf<S>]: S[K] extends { required: true } ? K : never
  104. }[StringKeyOf<S>]
  105. /** Infer the declared value of one parameter property without key optionality. */
  106. type InferProperty<P, Depth extends unknown[]> = InferValueAt<P, Depth>
  107. /** Infer an implicit property map into required and optional object keys. */
  108. type InferProperties<S, Depth extends unknown[]> = Simplify<
  109. & { [K in RequiredKeys<S>]: InferProperty<S[K], Depth> }
  110. & { [K in Exclude<StringKeyOf<S>, RequiredKeys<S>>]?: InferProperty<S[K], Depth> }
  111. >
  112. /** Infer an explicit object node, including its declared openness. */
  113. type InferObject<S extends { additionalProperties: boolean }, Depth extends unknown[]> =
  114. S extends { properties: infer P }
  115. ? S['additionalProperties'] extends true
  116. ? InferProperties<P, Depth> & Record<string, JsonValue>
  117. : InferProperties<P, Depth>
  118. : S['additionalProperties'] extends true
  119. ? Record<string, JsonValue>
  120. : Record<string, never>
  121. /** Infer a scalar node's literal constraint before its broad primitive type. */
  122. type InferScalar<S, Fallback> =
  123. S extends { const: infer C } ? C :
  124. S extends { enum: readonly (infer E)[] } ? E :
  125. Fallback
  126. /** Add one schema-container level to bounded compile-time inference. */
  127. type NextInferenceDepth<Depth extends unknown[]> = [unknown, ...Depth]
  128. /** Infer one node without recursively checking it against the full author union. */
  129. type InferValueAt<S, Depth extends unknown[]> =
  130. Depth['length'] extends 16 ? JsonValue :
  131. S extends { type: 'string' } ? InferScalar<S, string> :
  132. S extends { type: 'number' | 'integer' } ? InferScalar<S, number> :
  133. S extends { type: 'boolean' } ? InferScalar<S, boolean> :
  134. S extends { type: 'null' } ? null :
  135. S extends { type: 'array' }
  136. ? S extends { items: infer I } ? InferValueAt<I, NextInferenceDepth<Depth>>[] : JsonValue[]
  137. : S extends { type: 'object'; additionalProperties: boolean }
  138. ? InferObject<S, NextInferenceDepth<Depth>>
  139. : S extends { type: 'json' } ? JsonValue :
  140. S extends { oneOf: readonly unknown[] }
  141. ? InferValueAt<S['oneOf'][number], NextInferenceDepth<Depth>>
  142. : never
  143. /**
  144. * Infer the TypeScript value accepted by an author-facing value schema. Exact
  145. * inference is bounded to 16 container levels, then falls back to `JsonValue`.
  146. */
  147. export type InferValue<S> = InferValueAt<S, []>
  148. /** Infer the TypeScript argument object for an implicit parameter schema. */
  149. export type InferArgs<S> = InferProperties<S, []>
  150. const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
  151. /** Throw one author-schema violation through the shared schema error type. */
  152. function authorError(message: string): never {
  153. throw new JsonSchemaError([message])
  154. }
  155. /** Copy own annotation fields for validation by the raw-schema boundary. */
  156. function copyAnnotations(source: Record<string, unknown>, target: JsonSchemaNode): void {
  157. if (Object.hasOwn(source, 'description')) target.description = source.description as string
  158. if (Object.hasOwn(source, 'title')) target.title = source.title as string
  159. if (Object.hasOwn(source, 'default')) target.default = source.default as JsonValue
  160. if (Object.hasOwn(source, 'examples')) target.examples = source.examples as JsonValue
  161. }
  162. /** Reject author-only keys outside one node's declared vocabulary. */
  163. function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed: readonly string[]): void {
  164. for (const key of Object.keys(source)) {
  165. if (!allowed.includes(key)) authorError(`${path}.${key} is not supported by the value schema DSL`)
  166. }
  167. }
  168. /** Compiled form of one implicit property map. */
  169. interface CompiledPropertyMap {
  170. properties: Record<string, JsonSchemaNode>
  171. required?: string[]
  172. }
  173. /** Mutable holder used only while an iterative compilation root is unresolved. */
  174. interface CompileRoot<T> {
  175. value?: T
  176. }
  177. /** Where one compiled value node is installed. */
  178. type NodeDestination =
  179. | { kind: 'root'; holder: CompileRoot<JsonSchemaNode> }
  180. | { kind: 'property'; target: Record<string, JsonSchemaNode>; key: string }
  181. | { kind: 'item'; target: JsonSchemaNode }
  182. | { kind: 'one-of'; target: JsonSchemaNode[]; index: number }
  183. /** Where one compiled property map is installed. */
  184. type PropertyMapDestination =
  185. | { kind: 'root'; holder: CompileRoot<CompiledPropertyMap> }
  186. | { kind: 'object'; target: JsonSchemaNode }
  187. /** Deferred work for stack-safe author-schema compilation. */
  188. type CompileTask =
  189. | { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination }
  190. | { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination }
  191. | {
  192. kind: 'property'
  193. property: unknown
  194. path: string
  195. key: string
  196. properties: Record<string, JsonSchemaNode>
  197. required: string[]
  198. }
  199. | {
  200. kind: 'property-map-tail'
  201. compiled: CompiledPropertyMap
  202. required: string[]
  203. destination: PropertyMapDestination
  204. }
  205. | { kind: 'leave'; input: object }
  206. /** Install a compiled node without giving `__proto__` assignment semantics. */
  207. function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void {
  208. switch (destination.kind) {
  209. case 'root':
  210. destination.holder.value = node
  211. break
  212. case 'property':
  213. Object.defineProperty(destination.target, destination.key, {
  214. value: node,
  215. enumerable: true,
  216. configurable: true,
  217. writable: true,
  218. })
  219. break
  220. case 'item':
  221. destination.target.items = node
  222. break
  223. case 'one-of':
  224. destination.target[destination.index] = node
  225. break
  226. }
  227. }
  228. /** Install a compiled property map at its root or containing object node. */
  229. function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void {
  230. if (destination.kind === 'root') {
  231. destination.holder.value = compiled
  232. } else {
  233. destination.target.properties = compiled.properties
  234. }
  235. }
  236. /** Execute an author-schema compilation task graph without recursive descent. */
  237. function runSchemaCompiler(initial: CompileTask): void {
  238. const seen = new Set<object>()
  239. const tasks: CompileTask[] = [initial]
  240. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  241. if (task.kind === 'leave') {
  242. seen.delete(task.input)
  243. continue
  244. }
  245. if (task.kind === 'property-map-tail') {
  246. if (task.required.length > 0) {
  247. task.compiled.required = task.required
  248. if (task.destination.kind === 'object') task.destination.target.required = task.required
  249. }
  250. continue
  251. }
  252. if (task.kind === 'property') {
  253. if (!isJsonSchemaRecord(task.property)) authorError(`${task.path} must be a value schema object`)
  254. if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
  255. authorError(`${task.path}.required must be true when present`)
  256. }
  257. if (Object.hasOwn(task.property, 'required') && task.property.required === true) task.required.push(task.key)
  258. tasks.push({
  259. kind: 'value',
  260. input: task.property,
  261. path: task.path,
  262. allowRequired: true,
  263. destination: { kind: 'property', target: task.properties, key: task.key },
  264. })
  265. continue
  266. }
  267. if (task.kind === 'property-map') {
  268. if (!isJsonSchemaRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
  269. if (seen.has(task.input)) authorError(`${task.path} is circular`)
  270. seen.add(task.input)
  271. const compiled: CompiledPropertyMap = { properties: {} }
  272. const required: string[] = []
  273. assignCompiledPropertyMap(task.destination, compiled)
  274. tasks.push({ kind: 'leave', input: task.input })
  275. tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination })
  276. const entries = Object.entries(task.input)
  277. for (let index = entries.length - 1; index >= 0; index--) {
  278. const entry = entries[index]
  279. /* v8 ignore next -- the loop is bounded by the captured entry count. */
  280. if (entry === undefined) continue
  281. tasks.push({
  282. kind: 'property',
  283. property: entry[1],
  284. path: `${task.path}.${entry[0]}`,
  285. key: entry[0],
  286. properties: compiled.properties,
  287. required,
  288. })
  289. }
  290. continue
  291. }
  292. const { input, path } = task
  293. if (!isJsonSchemaRecord(input)) authorError(`${path} must be a value schema object`)
  294. if (seen.has(input)) authorError(`${path} is circular`)
  295. seen.add(input)
  296. const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
  297. const node: JsonSchemaNode = {}
  298. assignCompiledNode(task.destination, node)
  299. tasks.push({ kind: 'leave', input })
  300. if (Object.hasOwn(input, 'oneOf')) {
  301. assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
  302. if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
  303. if (!isPlainJsonArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
  304. const branches: JsonSchemaNode[] = []
  305. node.oneOf = branches
  306. copyAnnotations(input, node)
  307. for (let index = input.oneOf.length - 1; index >= 0; index--) {
  308. tasks.push({
  309. kind: 'value',
  310. input: input.oneOf[index],
  311. path: `${path}.oneOf[${index}]`,
  312. allowRequired: false,
  313. destination: { kind: 'one-of', target: branches, index },
  314. })
  315. }
  316. continue
  317. }
  318. const inputType = Object.hasOwn(input, 'type') ? input.type : undefined
  319. switch (inputType) {
  320. case 'json':
  321. assertAuthorKeys(input, path, [...authorKeys, 'type'])
  322. copyAnnotations(input, node)
  323. break
  324. case 'object':
  325. assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties'])
  326. if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') {
  327. authorError(`${path}.additionalProperties must be explicitly true or false`)
  328. }
  329. node.type = 'object'
  330. copyAnnotations(input, node)
  331. node.additionalProperties = input.additionalProperties
  332. if (Object.hasOwn(input, 'properties')) {
  333. tasks.push({
  334. kind: 'property-map',
  335. input: input.properties,
  336. path: `${path}.properties`,
  337. destination: { kind: 'object', target: node },
  338. })
  339. }
  340. break
  341. case 'array':
  342. assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
  343. node.type = 'array'
  344. copyAnnotations(input, node)
  345. if (Object.hasOwn(input, 'items')) {
  346. tasks.push({
  347. kind: 'value',
  348. input: input.items,
  349. path: `${path}.items`,
  350. allowRequired: false,
  351. destination: { kind: 'item', target: node },
  352. })
  353. }
  354. break
  355. case 'string':
  356. case 'number':
  357. case 'integer':
  358. case 'boolean':
  359. case 'null':
  360. assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
  361. node.type = inputType
  362. copyAnnotations(input, node)
  363. if (Object.hasOwn(input, 'enum')) {
  364. if (!isPlainJsonArray(input.enum)) authorError(`${path}.enum must be a non-empty array of scalar values`)
  365. node.enum = Array.from(input.enum, entry => entry as JsonSchemaScalar)
  366. }
  367. if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
  368. break
  369. default:
  370. authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
  371. }
  372. }
  373. }
  374. /** Compile one implicit property map, collecting per-property requiredness. */
  375. function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap {
  376. const holder: CompileRoot<CompiledPropertyMap> = {}
  377. runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } })
  378. /* v8 ignore next -- the root task assigns before scheduling any descendants. */
  379. return holder.value ?? authorError(`${path} did not compile`)
  380. }
  381. /** Compile one author node without applying any consumer root restriction. */
  382. function compileValueSchema(input: unknown, path: string): JsonSchemaNode {
  383. const holder: CompileRoot<JsonSchemaNode> = {}
  384. runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } })
  385. /* v8 ignore next -- the root task assigns before scheduling any descendants. */
  386. return holder.value ?? authorError(`${path} did not compile`)
  387. }
  388. /**
  389. * Compile one author-facing value schema to the enforced raw JSON Schema
  390. * subset. The author-only `json` node becomes an annotation-only schema.
  391. * @param spec - schema for any JSON-value root.
  392. * @returns The asserted raw schema projection.
  393. */
  394. export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
  395. const schema = compileValueSchema(spec, 'schema')
  396. assertSupportedJsonSchema(schema)
  397. return schema
  398. }
  399. /**
  400. * Compile the implicit open parameter object into raw JSON Schema.
  401. * @param spec - per-property parameter definitions.
  402. * @returns An object-rooted raw schema with no implicit-root openness override.
  403. */
  404. export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
  405. const compiled = compilePropertyMap(spec, 'parameters')
  406. const schema: ParameterJsonSchema = {
  407. type: 'object',
  408. properties: compiled.properties,
  409. ...(compiled.required === undefined ? {} : { required: compiled.required }),
  410. }
  411. assertSupportedJsonSchema(schema)
  412. return schema
  413. }
  414. /** Invalid model-generated arguments for a typed tool. */
  415. export class ToolArgsError extends HarnessError {
  416. /** Individual violations in schema-walk order. */
  417. readonly violations: string[]
  418. constructor(violations: string[]) {
  419. super(`invalid arguments: ${violations.join('; ')}`, 'INVALID_ARGS')
  420. this.name = 'ToolArgsError'
  421. this.violations = violations
  422. }
  423. }
  424. /**
  425. * Validate model-generated arguments against an implicit parameter schema.
  426. * @param spec - declared parameter schema.
  427. * @param args - candidate arguments, however malformed.
  428. * @returns Path-qualified violations; empty means valid.
  429. */
  430. export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] {
  431. return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '')
  432. }
  433. /** Options for {@link defineTool}. */
  434. export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
  435. /** Tool name (must be unique). */
  436. readonly name: string
  437. /** Human-readable description sent to the model. */
  438. readonly description: string
  439. /** Per-property parameter schema compiled to an implicit open object root. */
  440. readonly parameters: S
  441. /** Canonical output schema plus pure Native and presentation projections. */
  442. readonly output: {
  443. /** Schema enforced against every successful body or policy-replaced value. */
  444. readonly schema: O
  445. /** Pure Native/model rendering of one validated canonical value. */
  446. render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
  447. /** Pure replayable presentation metadata for direct top-level calls. */
  448. presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
  449. }
  450. /** Optional positive cooperative timeout budget in milliseconds. */
  451. readonly timeoutMs?: number
  452. /**
  453. * Pure classifier for sibling overlap.
  454. * @param args - typed validated arguments.
  455. * @returns Whether the call may join a parallel group.
  456. */
  457. isConcurrencySafe?(args: InferArgs<S>): boolean
  458. /**
  459. * Execute the tool after argument validation.
  460. * @param args - typed validated arguments.
  461. * @param exec - execution identity, caller, cancellation, and nesting data.
  462. * @returns The canonical value declared by `output.schema`.
  463. */
  464. execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
  465. /**
  466. * Optional last-mile content transform for every normalized outcome. Unlike
  467. * `execute`, arguments remain `unknown` because invalid-input failures also
  468. * reach this callback. See {@link ToolDefinition.finalizeContent}.
  469. * @param exec - immutable execution identity and arguments.
  470. * @param result - complete normalized outcome before materialization.
  471. * @returns replacement content, or `undefined` to preserve it.
  472. */
  473. finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
  474. /**
  475. * Pure pending-state presenter.
  476. * @param args - typed validated arguments.
  477. * @returns Tool-owned render intent, or `undefined` for the generic card.
  478. */
  479. presentCall?(args: InferArgs<S>): ToolCallView | undefined
  480. /**
  481. * Pure completed-state presenter.
  482. * @param args - typed validated arguments.
  483. * @param result - final model-facing tool result.
  484. * @returns Tool-owned render intent, or `undefined` for the generic card.
  485. */
  486. presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
  487. }
  488. /**
  489. * Define a first-party tool with inferred arguments and strict execution
  490. * validation. Replay-only presenters validate softly and fall back to generic
  491. * rendering for obsolete logged arguments.
  492. * @param options - typed definition and optional finalizer and presenters.
  493. * @returns A registry-ready definition.
  494. */
  495. export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
  496. options: DefineToolOptions<S, O>,
  497. ): ToolDefinition {
  498. // Object-literal methods do not use `this`; retaining references is safe.
  499. // oxlint-disable-next-line typescript/unbound-method
  500. const userExecute = options.execute
  501. // oxlint-disable-next-line typescript/unbound-method
  502. const userFinalizeContent = options.finalizeContent
  503. // oxlint-disable-next-line typescript/unbound-method
  504. const userRender = options.output.render
  505. // oxlint-disable-next-line typescript/unbound-method
  506. const userPresentationMeta = options.output.presentationMeta
  507. // oxlint-disable-next-line typescript/unbound-method
  508. const userPresentCall = options.presentCall
  509. // oxlint-disable-next-line typescript/unbound-method
  510. const userPresentResult = options.presentResult
  511. // oxlint-disable-next-line typescript/unbound-method
  512. const userIsConcurrencySafe = options.isConcurrencySafe
  513. if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
  514. throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
  515. }
  516. const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
  517. const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
  518. const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
  519. const tool: ToolDefinition = {
  520. name: options.name,
  521. description: options.description,
  522. parameters: parameters as unknown as Record<string, unknown>,
  523. output: {
  524. schema: outputSchema,
  525. render(args: unknown, value: JsonValue): ContentBlock[] {
  526. return userRender(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
  527. },
  528. ...userPresentationMeta !== undefined ? {
  529. presentationMeta(args: unknown, value: JsonValue): JsonValue {
  530. return userPresentationMeta(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
  531. },
  532. } : {},
  533. },
  534. ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
  535. async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
  536. const violations = validate(args)
  537. if (violations.length > 0) throw new ToolArgsError(violations)
  538. return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
  539. },
  540. }
  541. if (userFinalizeContent) {
  542. tool.finalizeContent = (exec, result) => userFinalizeContent(exec, result)
  543. }
  544. // Presentation is display-only and may run on REPLAY of arbitrary logged args
  545. // (possibly from an older schema), so it must never throw: validate softly and
  546. // fall back to `undefined` (a generic UI presentation) on any mismatch, rather
  547. // than the hard `ToolArgsError` the execute path raises.
  548. if (userPresentCall) {
  549. tool.presentCall = (args: unknown): ToolCallView | undefined => {
  550. if (validate(args).length > 0) return undefined
  551. return userPresentCall(args as InferArgs<S>)
  552. }
  553. }
  554. if (userPresentResult) {
  555. tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
  556. if (validate(args).length > 0) return undefined
  557. return userPresentResult(args as InferArgs<S>, result)
  558. }
  559. }
  560. if (userIsConcurrencySafe) {
  561. tool.isConcurrencySafe = (args: unknown): boolean => {
  562. if (validate(args).length > 0) return false
  563. return userIsConcurrencySafe(args as InferArgs<S>)
  564. }
  565. }
  566. return tool
  567. }