index.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. /**
  2. * Opt-in request clock context. Eligible steps add durable,
  3. * source-attributed time readings to the request history.
  4. *
  5. * @module @deepseek-ai/dsh-time-context
  6. */
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import z from '@deepseek-ai/schemastery'
  9. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  10. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  11. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  12. import {
  13. deriveBrowserTimeZoneContext,
  14. renderBrowserTimeZoneContext,
  15. } from './request-zone.ts'
  16. import type { BrowserTimeZoneContext } from './request-zone.ts'
  17. import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
  18. /** Cordis plugin name used by loader diagnostics. */
  19. export const name = 'time-context'
  20. /** The agent registry that owns pre-step processing. */
  21. export const inject = ['agents']
  22. /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
  23. export interface Config {
  24. /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
  25. timeZone?: string
  26. /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
  27. refreshIntervalMs?: number
  28. }
  29. /** Schemastery validation for {@link Config}. */
  30. export const Config: z<Config> = z.object({
  31. timeZone: z.string(),
  32. refreshIntervalMs: z.number(),
  33. })
  34. /** Format a non-negative elapsed millisecond count as compact whole-second units. */
  35. function formatDuration(elapsedMs: number): string {
  36. let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
  37. const days = Math.floor(seconds / 86_400)
  38. seconds %= 86_400
  39. const hours = Math.floor(seconds / 3600)
  40. seconds %= 3600
  41. const minutes = Math.floor(seconds / 60)
  42. seconds %= 60
  43. const parts: string[] = []
  44. if (days > 0) parts.push(`${days}d`)
  45. if (hours > 0) parts.push(`${hours}h`)
  46. if (minutes > 0) parts.push(`${minutes}m`)
  47. parts.push(`${seconds}s`)
  48. return parts.join(' ')
  49. }
  50. /** Find the latest model-visible event, excluding this plugin's pending append. */
  51. function precedingMessageTime(agent: Agent): number | undefined {
  52. for (const event of [...agent.session.events].reverse()) {
  53. switch (event.type) {
  54. case 'user/message':
  55. case 'assistant/message':
  56. case 'tool/result':
  57. return event.time
  58. default:
  59. // Merge-extensible session events: non-surface records are not messages.
  60. break
  61. }
  62. }
  63. return undefined
  64. }
  65. /** Find the preceding time-context event within the open turn. */
  66. function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
  67. for (const event of [...agent.session.events].reverse()) {
  68. if (event.type === 'turn/start' && event.data.turn === turn) return undefined
  69. if (event.type === 'user/message'
  70. && event.data.source.kind === 'plugin'
  71. && event.data.source.plugin === name) {
  72. return event.time
  73. }
  74. }
  75. return undefined
  76. }
  77. /** Find this plugin's latest durable injection, including a shadowed surface event. */
  78. function latestInjectionTime(agent: Agent): number | undefined {
  79. for (const event of [...agent.session.events].reverse()) {
  80. if (event.type === 'user/message'
  81. && event.data.source.kind === 'plugin'
  82. && event.data.source.plugin === name) {
  83. return event.time
  84. }
  85. }
  86. return undefined
  87. }
  88. /** Collect already-entered and proposed user messages belonging to one open turn. */
  89. function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
  90. const start = agent.session.events.findLastIndex(
  91. event => event.type === 'turn/start' && event.data.turn === turn,
  92. )
  93. const entered = start < 0
  94. ? []
  95. : agent.session.events.slice(start + 1)
  96. .flatMap(event => event.type === 'user/message' ? [event.data] : [])
  97. return [...entered, ...proposed]
  98. }
  99. function renderText(
  100. now: number,
  101. turn: number,
  102. step: number,
  103. previous: number | undefined,
  104. formatter: Intl.DateTimeFormat,
  105. timeZone: string,
  106. browserContext: BrowserTimeZoneContext,
  107. ): string {
  108. const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
  109. const baseline = step === 1 ? 'model-visible message' : 'step context'
  110. const browserText = renderBrowserTimeZoneContext(browserContext)
  111. return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
  112. + `${browserText}\n`
  113. + `Elapsed since the preceding ${baseline}: ${elapsed}.`
  114. }
  115. /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
  116. function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
  117. if (refreshIntervalMs !== undefined && (
  118. !Number.isSafeInteger(refreshIntervalMs)
  119. || refreshIntervalMs < 0
  120. )) {
  121. throw new TypeError(
  122. `time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
  123. )
  124. }
  125. }
  126. /**
  127. * Register a prepended pre-step listener for the lifetime of `ctx`.
  128. * @param ctx - plugin context; the listener is disposed with it.
  129. * @param config - time zone and durable refresh scheduling configuration.
  130. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
  131. */
  132. export function apply(ctx: Context, config: Config): void {
  133. const timeZone = config.timeZone
  134. const refreshIntervalMs = config.refreshIntervalMs
  135. validateRefreshInterval(refreshIntervalMs)
  136. let fallbackFormatter: Intl.DateTimeFormat
  137. try {
  138. fallbackFormatter = createTimestampFormatter(timeZone)
  139. } catch (error: unknown) {
  140. const message = timeZone === undefined
  141. ? 'time-context: failed to resolve the system time zone'
  142. : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`
  143. throw new Error(message, { cause: error })
  144. }
  145. const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
  146. const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
  147. /** Resolve and cache one request-local timestamp formatter. */
  148. const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
  149. const existing = formatters.get(selectedTimeZone)
  150. if (existing !== undefined) return existing
  151. const created = createTimestampFormatter(selectedTimeZone)
  152. formatters.set(selectedTimeZone, created)
  153. return created
  154. }
  155. ctx.on('agent/pre-step', async (
  156. { agent, turn, step, signal },
  157. next,
  158. ): Promise<PreStepDecision> => {
  159. const decision = await next()
  160. if (decision.kind === 'reject' || signal.aborted) return decision
  161. const now = Date.now()
  162. if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
  163. const lastInjection = latestInjectionTime(agent)
  164. if (lastInjection !== undefined
  165. && now >= lastInjection
  166. && now - lastInjection < refreshIntervalMs) return decision
  167. }
  168. const previous = step === 1
  169. ? precedingMessageTime(agent)
  170. : precedingStepContextTime(agent, turn)
  171. const messages = requestMessages(agent, turn, decision.messages)
  172. const browser = deriveBrowserTimeZoneContext(messages)
  173. const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
  174. const text = renderText(
  175. now,
  176. turn,
  177. step,
  178. previous,
  179. formatterFor(selectedTimeZone),
  180. selectedTimeZone,
  181. browser,
  182. )
  183. return {
  184. kind: 'enter',
  185. messages: [
  186. ...decision.messages,
  187. createUserMessage({
  188. content: [{ type: 'text', text }],
  189. source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
  190. }),
  191. ],
  192. }
  193. }, { prepend: true })
  194. }