feat: add optional time context plugin
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Optional temporal context for model requests. The plugin contributes one
|
||||
* dynamic system-prompt section that reports the current zoned time and the
|
||||
* elapsed duration since the last model-visible message before the current
|
||||
* turn. A turn always gets a fresh reading on its first request; later steps
|
||||
* refresh only when the configured maximum age is reached.
|
||||
*
|
||||
* The section is request state, not retained conversation history. The agent
|
||||
* loop records each rendered value through its existing `request/header` or
|
||||
* `request/header-delta` event, preserving the model-visible/logged invariant
|
||||
* without accumulating stale `context/message` entries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-time-context
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
|
||||
/** The system-prompt registry that owns the dynamic request section. */
|
||||
export const inject = ['systemPrompt']
|
||||
|
||||
/** Configuration for the request-time clock section. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp (default `UTC`). */
|
||||
timeZone?: string
|
||||
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validation and defaults for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
timeZone: z.string().default('UTC'),
|
||||
refreshIntervalMs: z.number().default(60_000),
|
||||
})
|
||||
|
||||
/** The open turn currently being assembled, including its log boundary. */
|
||||
interface OpenTurn {
|
||||
turn: number
|
||||
startSeq: number
|
||||
}
|
||||
|
||||
/** One agent's last rendered block and its fixed previous-turn baseline. */
|
||||
interface RenderState {
|
||||
turn: number
|
||||
renderedAt: number
|
||||
previousMessageTime: number | undefined
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Date-time fields required from the fixed formatter below. */
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
|
||||
/** Find the open turn at the tail of an agent's balanced session log. */
|
||||
function openTurn(agent: Agent): OpenTurn | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
case 'turn/end':
|
||||
return undefined
|
||||
case 'turn/start':
|
||||
return { turn: event.data.turn, startSeq: event.seq }
|
||||
default:
|
||||
// Merge-extensible session events: only turn boundaries matter here.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Timestamp of the last model-visible message before one turn opened. */
|
||||
function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.seq >= turnStartSeq) continue
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
// Merge-extensible session events: non-surface records are not messages.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 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(' ')
|
||||
}
|
||||
|
||||
/** Build the exact two-line model-facing section. */
|
||||
function renderText(
|
||||
now: number,
|
||||
previous: number | undefined,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const elapsed = previous === undefined
|
||||
? 'unavailable (no earlier message in this session)'
|
||||
: formatDuration(now - previous)
|
||||
return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dynamic temporal system-prompt section.
|
||||
* @param ctx - plugin context; the section registration is disposed with it.
|
||||
* @param config - validated time zone and intra-turn refresh interval.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone as string
|
||||
const refreshIntervalMs = config.refreshIntervalMs as number
|
||||
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
|
||||
throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`)
|
||||
}
|
||||
|
||||
let formatter: Intl.DateTimeFormat
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZoneName: 'longOffset',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`, { cause: error })
|
||||
}
|
||||
const resolvedTimeZone = formatter.resolvedOptions().timeZone
|
||||
const states = new WeakMap<Agent, RenderState>()
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'context:time',
|
||||
order: 10,
|
||||
text(context: AssembleContext): string {
|
||||
const agent = context.agent
|
||||
if (agent === undefined) return ''
|
||||
const currentTurn = openTurn(agent)
|
||||
if (currentTurn === undefined) return ''
|
||||
|
||||
const now = Date.now()
|
||||
const prior = states.get(agent)
|
||||
if (prior !== undefined
|
||||
&& prior.turn === currentTurn.turn
|
||||
&& now >= prior.renderedAt
|
||||
&& now - prior.renderedAt < refreshIntervalMs) {
|
||||
return prior.text
|
||||
}
|
||||
|
||||
const previous = prior?.turn === currentTurn.turn
|
||||
? prior.previousMessageTime
|
||||
: previousMessageTime(agent, currentTurn.startSeq)
|
||||
const text = renderText(now, previous, formatter, resolvedTimeZone)
|
||||
states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text })
|
||||
return text
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user