mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Steer, inject, and followup now land as durable user/message events on the session surface; the steering/message event type and its ConversationNode kind are removed from the client projection. Update tests, docs, generated catalogs, and agent notes to match, and align the steering e2e fixture and prompt inventory assertions with the durable user/message landing.
189 lines
6.7 KiB
TypeScript
189 lines
6.7 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'
|
|
|
|
/** 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 {
|
|
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
|
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, excluding this plugin's pending append. */
|
|
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
|
|
}
|
|
|
|
function renderText(
|
|
now: number,
|
|
turn: number,
|
|
step: number,
|
|
previous: number | undefined,
|
|
formatter: Intl.DateTimeFormat,
|
|
timeZone: string,
|
|
): string {
|
|
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
|
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
|
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\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.
|
|
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
|
*/
|
|
export function apply(ctx: Context, config: Config): void {
|
|
const timeZone = config.timeZone
|
|
const refreshIntervalMs = config.refreshIntervalMs
|
|
validateRefreshInterval(refreshIntervalMs)
|
|
let formatter: Intl.DateTimeFormat
|
|
try {
|
|
formatter = new Intl.DateTimeFormat('en-US', {
|
|
...(timeZone === undefined ? {} : { 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) {
|
|
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 resolvedTimeZone = formatter.resolvedOptions().timeZone
|
|
|
|
ctx.on('agent/pre-step', async (
|
|
agent: Agent,
|
|
_messages,
|
|
{ turn, step, signal },
|
|
next,
|
|
): Promise<PreStepDecision> => {
|
|
const decision = await next()
|
|
if (decision.kind === 'reject' || signal.aborted) 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
|
|
}
|
|
const previous = step === 1
|
|
? precedingMessageTime(agent)
|
|
: precedingStepContextTime(agent, turn)
|
|
return {
|
|
kind: 'enter',
|
|
messages: [
|
|
...decision.messages,
|
|
createUserMessage({
|
|
content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
|
|
source: { kind: 'plugin', plugin: name },
|
|
}),
|
|
],
|
|
}
|
|
}, { prepend: true })
|
|
}
|