Files
deepseek-harness/packages/context/time-context/src/index.ts
T

267 lines
9.5 KiB
TypeScript

/**
* Opt-in request clock context. Eligible steps add durable,
* source-attributed time readings to the request history.
*
* @module @deepseek-ai/dsh-time-context
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import {
deriveClientTimeZoneContext,
renderTimeZoneContext,
} from './request-zone.ts'
export type { ClientTimeZoneContext } from './request-zone.ts'
export { deriveClientTimeZoneContext } from './request-zone.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'time-context'
/** The agent registry that owns pre-step processing. */
export const inject = ['agents']
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
export interface Config {
/** Fallback display zone for headerless Sessions. Omit to use the process zone. */
timeZone?: string
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
refreshIntervalMs?: number
}
/** Schemastery validation for {@link Config}. */
export const Config: z<Config> = z.object({
timeZone: z.string(),
refreshIntervalMs: z.number(),
})
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
const parts = Object.fromEntries(
formatter.formatToParts(now).map(part => [part.type, part.value]),
) as Record<TimestampPart, string>
const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
}
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
function formatDuration(elapsedMs: number): string {
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
const days = Math.floor(seconds / 86_400)
seconds %= 86_400
const hours = Math.floor(seconds / 3600)
seconds %= 3600
const minutes = Math.floor(seconds / 60)
seconds %= 60
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
parts.push(`${seconds}s`)
return parts.join(' ')
}
/** Find the latest model-visible event before the current proposal. */
function precedingMessageTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
return event.time
default:
// Merge-extensible session events: non-surface records are not messages.
break
}
}
return undefined
}
/** Find the preceding time-context event within the open turn. */
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
/** Collect already-entered and proposed messages belonging to one open turn. */
function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
const start = agent.session.events.findLastIndex(
event => event.type === 'turn/start' && event.data.turn === turn,
)
const entered = start < 0
? []
: agent.session.events.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : [])
return [...entered, ...proposed]
}
/** Render one durable time reading. */
function renderText(
now: number,
turn: number,
step: number,
previous: number | undefined,
formatter: Intl.DateTimeFormat,
displayTimeZone: string,
sessionTimeZone: string | undefined,
messages: readonly UserMessage[],
): string {
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
const baseline = step === 1 ? 'model-visible message' : 'step context'
const client = deriveClientTimeZoneContext(messages)
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, displayTimeZone)}\n`
+ `${renderTimeZoneContext(sessionTimeZone, client)}\n`
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register a prepended pre-step listener for the lifetime of `ctx`.
* @param ctx - Plugin context; the listener is disposed with it.
* @param config - Time zone and durable refresh scheduling configuration.
* @returns A disposer that prevents an in-flight listener from contributing.
* @throws When the refresh interval or configured/process time zone is invalid.
*/
export function apply(ctx: Context, config: Config): () => void {
const timeZone = config.timeZone
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
const createFormatter = (selectedTimeZone?: string): Intl.DateTimeFormat => new Intl.DateTimeFormat('en-US', {
...(selectedTimeZone === undefined ? {} : { timeZone: selectedTimeZone }),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'longOffset',
})
let fallbackFormatter: Intl.DateTimeFormat
try {
fallbackFormatter = createFormatter(timeZone)
} catch (error: unknown) {
const message = timeZone === undefined
? 'time-context: failed to resolve the system time zone'
: `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`
throw new Error(message, { cause: error })
}
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
let disposed = false
/** Resolve one Session-owned formatter without making the process zone authoritative. */
const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
const existing = formatters.get(selectedTimeZone)
if (existing !== undefined) return existing
let created: Intl.DateTimeFormat
try {
created = createFormatter(selectedTimeZone)
} catch (error: unknown) {
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
}
formatters.set(selectedTimeZone, created)
return created
}
/** Build one current reading after downstream pre-step transforms settle. */
const readingFor = (
agent: Agent,
turn: number,
step: number,
messages: readonly UserMessage[],
): UserMessage => {
const now = Date.now()
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
const sessionTimeZone = agent.session.header.timeZone
const displayTimeZone = sessionTimeZone ?? fallbackTimeZone
const formatter = sessionTimeZone === undefined
? fallbackFormatter
: formatterFor(sessionTimeZone)
return createUserMessage({
content: [{
type: 'text',
text: renderText(
now,
turn,
step,
previous,
formatter,
displayTimeZone,
sessionTimeZone,
requestMessages(agent, turn, messages),
),
}],
source: { kind: 'plugin', plugin: name },
})
}
ctx.on('agent/pre-step', async (
{ agent, turn, step, signal },
next,
): Promise<PreStepDecision> => {
const wasDisposed = (): boolean => disposed
const wasAborted = (): boolean => signal.aborted
if (wasDisposed()) return next()
const decision = await next()
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|| decision.messages.length === 0) {
return decision
}
const now = Date.now()
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
const lastInjection = latestInjectionTime(agent)
if (lastInjection !== undefined
&& now >= lastInjection
&& now - lastInjection < refreshIntervalMs) return decision
}
return {
kind: 'enter',
messages: [
...decision.messages,
readingFor(agent, turn, step, decision.messages),
],
}
}, { prepend: true })
return () => {
disposed = true
}
}