input.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * Model argument schemas, normalization, and filter construction.
  3. *
  4. * @module @deepseek-ai/dsh-tool-session-query/input
  5. */
  6. import {
  7. type SessionEventType,
  8. type SessionId as SessionIdValue,
  9. } from '@deepseek-ai/dsh-session'
  10. import { brandString } from '@deepseek-ai/dsh-brand'
  11. import {
  12. SessionQueryError,
  13. type SessionAvailability,
  14. type SessionEventMetadataFilter,
  15. type SessionEventSurface,
  16. type SessionResultFilter,
  17. } from '@deepseek-ai/dsh-session-query'
  18. interface SessionSearchArgs {
  19. query: string
  20. session_ids?: string[]
  21. created_at_from?: string
  22. created_at_to?: string
  23. parent_session_ids?: string[]
  24. include_root_sessions?: boolean
  25. availability?: SessionAvailability[]
  26. event_seq_from?: number
  27. event_seq_to?: number
  28. event_time_from?: string
  29. event_time_to?: string
  30. event_types?: string[]
  31. event_surfaces?: SessionEventSurface[]
  32. }
  33. interface EventFilterInput {
  34. readonly seqFrom?: number | undefined
  35. readonly seqTo?: number | undefined
  36. readonly timeFrom?: string | undefined
  37. readonly timeTo?: string | undefined
  38. readonly eventTypes?: string[] | undefined
  39. readonly surfaces?: SessionEventSurface[] | undefined
  40. }
  41. const sessionSearchParameters = {
  42. query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' },
  43. session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' },
  44. created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' },
  45. created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' },
  46. parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' },
  47. include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' },
  48. availability: {
  49. type: 'array',
  50. items: { type: 'string', enum: ['live', 'persisted'] },
  51. description: 'Require at least one selected source availability.',
  52. },
  53. event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' },
  54. event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' },
  55. event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' },
  56. event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' },
  57. event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' },
  58. event_surfaces: {
  59. type: 'array',
  60. items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] },
  61. description: 'Event surfaces to include.',
  62. },
  63. } as const
  64. const eventSearchParameters = {
  65. session_id: { type: 'string', description: 'Target session id. Omit for the current session.' },
  66. query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' },
  67. seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' },
  68. seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' },
  69. time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' },
  70. time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' },
  71. event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' },
  72. surfaces: {
  73. type: 'array',
  74. items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] },
  75. description: 'Event surfaces to include.',
  76. },
  77. } as const
  78. const targetSessionParameter = {
  79. session_id: { type: 'string', description: 'Target session id. Omit for the current session.' },
  80. } as const
  81. function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] {
  82. const filters: SessionResultFilter[] = []
  83. if (args.session_ids !== undefined) {
  84. assertNonEmptyArray('session_ids', args.session_ids)
  85. filters.push({ kind: 'id', values: args.session_ids.map(value => brandString<SessionIdValue>(value)) })
  86. }
  87. const created = timestampRange('created_at', args.created_at_from, args.created_at_to)
  88. if (created !== undefined) filters.push({ kind: 'created-at', ...created })
  89. if (args.availability !== undefined) {
  90. assertNonEmptyArray('availability', args.availability)
  91. filters.push({ kind: 'availability', values: args.availability })
  92. }
  93. return filters
  94. }
  95. function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined {
  96. if (values === undefined) return undefined
  97. assertNonEmptyArray('parent_session_ids', values)
  98. return [...new Set(values.map(value => brandString<SessionIdValue>(value)))]
  99. }
  100. function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] {
  101. const filters: SessionEventMetadataFilter[] = []
  102. const seq = sequenceRange(input.seqFrom, input.seqTo)
  103. if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq })
  104. const time = timestampRange('time', input.timeFrom, input.timeTo)
  105. if (time !== undefined) filters.push({ kind: 'time', ...time })
  106. if (input.eventTypes !== undefined) {
  107. assertNonEmptyArray('event_types', input.eventTypes)
  108. filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] })
  109. }
  110. if (input.surfaces !== undefined) {
  111. assertNonEmptyArray('surfaces', input.surfaces)
  112. filters.push({ kind: 'surface', values: input.surfaces })
  113. }
  114. return filters
  115. }
  116. function normalizeQuery(value: string): string {
  117. const query = value.trim().replace(/\s+/gu, ' ')
  118. if (query.length === 0) {
  119. throw new SessionQueryError(
  120. 'session-search query must contain non-whitespace text',
  121. 'SESSION_QUERY_INVALID_QUERY',
  122. )
  123. }
  124. if (query.includes('\0')) {
  125. throw new SessionQueryError(
  126. 'session-search query must not contain NUL',
  127. 'SESSION_QUERY_INVALID_QUERY',
  128. )
  129. }
  130. return query
  131. }
  132. function sequenceRange(
  133. from: number | undefined,
  134. to: number | undefined,
  135. ): { from?: number; to?: number } {
  136. if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from)
  137. if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to)
  138. if (from !== undefined && to !== undefined && from > to) {
  139. throw invalidRange('sequence', 'from must be less than or equal to to')
  140. }
  141. return {
  142. ...from === undefined ? {} : { from },
  143. ...to === undefined ? {} : { to },
  144. }
  145. }
  146. function timestampRange(
  147. name: string,
  148. from: string | undefined,
  149. to: string | undefined,
  150. ): { from?: number; to?: number } | undefined {
  151. if (from === undefined && to === undefined) return undefined
  152. const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from)
  153. const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to)
  154. if (
  155. fromTimestamp !== undefined
  156. && toTimestamp !== undefined
  157. && compareTimestamps(fromTimestamp, toTimestamp) > 0
  158. ) {
  159. throw invalidRange(name, 'from must be less than or equal to to')
  160. }
  161. return {
  162. ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) },
  163. ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) },
  164. }
  165. }
  166. const ISO_TIMESTAMP =
  167. /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/
  168. interface ExactTimestamp {
  169. readonly millisecond: number
  170. /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */
  171. readonly remainder: string
  172. }
  173. function parseIsoTimestamp(name: string, value: string): ExactTimestamp {
  174. const match = ISO_TIMESTAMP.exec(value)
  175. if (match === null) {
  176. throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset')
  177. }
  178. const year = Number(match[1])
  179. const month = Number(match[2])
  180. const day = Number(match[3])
  181. const hour = Number(match[4])
  182. const minute = Number(match[5])
  183. const second = Number(match[6] ?? 0)
  184. const offsetHour = Number(match[10] ?? 0)
  185. const offsetMinute = Number(match[11] ?? 0)
  186. if (
  187. month < 1 || month > 12
  188. || day < 1 || day > daysInMonth(year, month)
  189. || hour > 23 || minute > 59 || second > 59
  190. || offsetHour > 23 || offsetMinute > 59
  191. ) {
  192. throw invalidRange(name, 'must be a valid ISO 8601 timestamp')
  193. }
  194. const fraction = match[7] ?? ''
  195. const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0')
  196. const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`
  197. + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}`
  198. const timestamp = Date.parse(normalized)
  199. if (!Number.isSafeInteger(timestamp)) {
  200. throw invalidRange(name, 'must be a valid ISO 8601 timestamp')
  201. }
  202. return {
  203. millisecond: timestamp,
  204. remainder: fraction.slice(3).replace(/0+$/u, ''),
  205. }
  206. }
  207. function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number {
  208. if (left.millisecond !== right.millisecond) {
  209. return left.millisecond < right.millisecond ? -1 : 1
  210. }
  211. const length = Math.max(left.remainder.length, right.remainder.length)
  212. for (let index = 0; index < length; index += 1) {
  213. const leftDigit = left.remainder[index] ?? '0'
  214. const rightDigit = right.remainder[index] ?? '0'
  215. if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1
  216. }
  217. return 0
  218. }
  219. function timestampLowerBound(timestamp: ExactTimestamp): number {
  220. return timestamp.remainder.length === 0
  221. ? timestamp.millisecond
  222. : nextUpFinite(timestamp.millisecond)
  223. }
  224. function timestampUpperBound(timestamp: ExactTimestamp): number {
  225. return timestamp.remainder.length === 0
  226. ? timestamp.millisecond
  227. : nextDownFinite(timestamp.millisecond + 1)
  228. }
  229. function nextUpFinite(value: number): number {
  230. if (value === 0) return Number.MIN_VALUE
  231. const view = new DataView(new ArrayBuffer(8))
  232. view.setFloat64(0, value)
  233. const bits = view.getBigUint64(0)
  234. view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n)
  235. return view.getFloat64(0)
  236. }
  237. function nextDownFinite(value: number): number {
  238. if (value === 0) return -Number.MIN_VALUE
  239. const view = new DataView(new ArrayBuffer(8))
  240. view.setFloat64(0, value)
  241. const bits = view.getBigUint64(0)
  242. view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n)
  243. return view.getFloat64(0)
  244. }
  245. function daysInMonth(year: number, month: number): number {
  246. if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
  247. return [4, 6, 9, 11].includes(month) ? 30 : 31
  248. }
  249. function invalidRange(name: string, detail: string): SessionQueryError {
  250. return new SessionQueryError(
  251. `session ${name} range ${detail}`,
  252. 'SESSION_QUERY_INVALID_FILTER',
  253. )
  254. }
  255. function assertNonNegativeSafeInteger(name: string, value: number): void {
  256. if (!Number.isSafeInteger(value) || value < 0) {
  257. throw new SessionQueryError(
  258. `${name} must be a non-negative safe integer`,
  259. 'SESSION_QUERY_INVALID_FILTER',
  260. )
  261. }
  262. }
  263. function assertNonEmptyArray(name: string, values: readonly unknown[]): void {
  264. if (values.length === 0) {
  265. throw new SessionQueryError(
  266. `${name} must contain at least one value when supplied`,
  267. 'SESSION_QUERY_INVALID_FILTER',
  268. )
  269. }
  270. }
  271. /** Model schemas and model-owned value normalization shared by tool operations. */
  272. export const toolInput = {
  273. sessionSearchParameters,
  274. eventSearchParameters,
  275. targetSessionParameter,
  276. buildSessionFilters,
  277. materializeParentSessionIds,
  278. buildEventFilters,
  279. normalizeQuery,
  280. sequenceRange,
  281. assertNonNegativeSafeInteger,
  282. }