refactor(schedule): make absolute times explicit
This commit is contained in:
@@ -11,14 +11,11 @@ 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,
|
||||
deriveBrowserTimeZoneContext,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.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'
|
||||
|
||||
@@ -27,7 +24,7 @@ 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. */
|
||||
/** Fallback display zone when the open turn has no unique browser zone. 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
|
||||
@@ -56,7 +53,7 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Find the latest model-visible event before the current proposal. */
|
||||
/** 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) {
|
||||
@@ -97,33 +94,32 @@ function latestInjectionTime(agent: Agent): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Collect already-entered and proposed messages belonging to one open turn. */
|
||||
/** Collect already-entered and proposed user 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] : [])
|
||||
: 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,
|
||||
timeZone: string,
|
||||
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`
|
||||
const browserContext = renderBrowserTimeZoneContext(deriveBrowserTimeZoneContext(messages))
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
+ `${browserContext}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
@@ -141,12 +137,11 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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 {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
@@ -161,66 +156,22 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
}
|
||||
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. */
|
||||
/** Resolve and cache one request-local timestamp formatter. */
|
||||
const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
|
||||
const existing = formatters.get(selectedTimeZone)
|
||||
if (existing !== undefined) return existing
|
||||
let created: Intl.DateTimeFormat
|
||||
try {
|
||||
created = createTimestampFormatter(selectedTimeZone)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
|
||||
}
|
||||
const created = createTimestampFormatter(selectedTimeZone)
|
||||
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)
|
||||
const text = renderText(
|
||||
now,
|
||||
turn,
|
||||
step,
|
||||
previous,
|
||||
formatter,
|
||||
displayTimeZone,
|
||||
sessionTimeZone,
|
||||
requestMessages(agent, turn, messages),
|
||||
)
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (decision.kind === 'reject' || signal.aborted) return decision
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
@@ -228,16 +179,30 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return decision
|
||||
}
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
const messages = requestMessages(agent, turn, decision.messages)
|
||||
const browser = deriveBrowserTimeZoneContext(messages)
|
||||
const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
|
||||
const text = renderText(
|
||||
now,
|
||||
turn,
|
||||
step,
|
||||
previous,
|
||||
formatterFor(selectedTimeZone),
|
||||
selectedTimeZone,
|
||||
messages,
|
||||
)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: [
|
||||
...decision.messages,
|
||||
readingFor(agent, turn, step, decision.messages),
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
}),
|
||||
],
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
import {
|
||||
deriveBrowserTimeZoneContext,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
@@ -11,8 +14,7 @@ const SOURCE_NAME = 'time-context'
|
||||
const READING = new RegExp(
|
||||
'^Time sampled while preparing turn (\\d+), step (\\d+): '
|
||||
+ '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n'
|
||||
+ 'Session time zone: ([^.]+)\\.\\n'
|
||||
+ 'Client time zone for this request: (.+)\\.\\n'
|
||||
+ '(Browser time zone for this request: .+)\\n'
|
||||
+ 'Elapsed since the preceding (model-visible message|step context): '
|
||||
+ '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$',
|
||||
)
|
||||
@@ -22,7 +24,7 @@ export const name = 'time-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Derive the open step owned by a time-context reading. */
|
||||
/** Derive the open step boundary at which a time-context reading may append. */
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
let openTurn: number | undefined
|
||||
let openStep: number | undefined
|
||||
@@ -68,12 +70,12 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
|
||||
/** Collect the entered user messages belonging to one open turn. */
|
||||
function requestMessages(history: readonly SessionEvent[], turn: number) {
|
||||
const start = history.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
|
||||
return history.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return history.slice(start + 1)
|
||||
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
}
|
||||
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
session: Session,
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'user/message'>,
|
||||
fail: InvariantFailure,
|
||||
@@ -121,15 +123,13 @@ function validateReading(
|
||||
|| section.text !== blockText) {
|
||||
fail('time-context source must carry only the exact snapshot text, not request authority')
|
||||
}
|
||||
const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.`
|
||||
const expectedAuthority = renderTimeZoneContext(
|
||||
session.header.timeZone,
|
||||
deriveClientTimeZoneContext(requestMessages(history, turn)),
|
||||
)
|
||||
if (renderedAuthority !== expectedAuthority) {
|
||||
fail('time-context text does not match the Session and current request zones')
|
||||
const renderedBrowserContext = match[4]
|
||||
const browserContext = deriveBrowserTimeZoneContext(requestMessages(history, turn))
|
||||
const expectedBrowserContext = renderBrowserTimeZoneContext(browserContext)
|
||||
if (renderedBrowserContext !== expectedBrowserContext) {
|
||||
fail('time-context browser-zone text does not match current-turn user messages')
|
||||
}
|
||||
const baseline = match[6]
|
||||
const baseline = match[5]
|
||||
if ((step === 1) !== (baseline === 'model-visible message')) {
|
||||
fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
@@ -141,20 +141,19 @@ function validateReading(
|
||||
|| event.time < renderedTime) {
|
||||
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||
}
|
||||
const sessionTimeZone = session.header.timeZone
|
||||
if (sessionTimeZone !== undefined) {
|
||||
if (browserContext.kind === 'resolved') {
|
||||
let expectedTimestamp: string
|
||||
try {
|
||||
expectedTimestamp = formatTimestamp(
|
||||
renderedTime,
|
||||
createTimestampFormatter(sessionTimeZone),
|
||||
sessionTimeZone,
|
||||
createTimestampFormatter(browserContext.timeZone),
|
||||
browserContext.timeZone,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`)
|
||||
fail(`time-context browser zone cannot format its durable timestamp: ${String(error)}`)
|
||||
}
|
||||
if (rendered !== expectedTimestamp) {
|
||||
fail('time-context rendered timestamp does not match the Session time zone')
|
||||
fail('time-context rendered timestamp does not match the unique browser zone')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +165,7 @@ function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session, session.events.slice(0, index), event, fail)
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session, session.events, event, fail)
|
||||
validateReading(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/** Request-zone derivation shared by time-context rendering and Schedule tools. */
|
||||
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Client-zone facts derived from the user-rpc messages in one open turn. */
|
||||
export type ClientTimeZoneContext =
|
||||
/** Browser-zone facts derived from user-rpc messages in one open turn. */
|
||||
export type BrowserTimeZoneContext =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: string[] }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: readonly string[] }
|
||||
| { readonly kind: 'missing' }
|
||||
|
||||
/** Read the Host-validated client zone from one ordinary user-rpc message. */
|
||||
function clientTimeZone(message: UserMessage): string | undefined {
|
||||
/** Read a Host-validated browser zone from one ordinary user-rpc message. */
|
||||
function browserTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
return source.kind === 'user'
|
||||
&& 'rpcId' in source
|
||||
@@ -21,13 +22,15 @@ function clientTimeZone(message: UserMessage): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the unique, mixed, or missing client zone from entered request input.
|
||||
* @param messages - User messages belonging to the current open turn.
|
||||
* @returns A sorted, duplicate-free request-zone context.
|
||||
* Derive the unique, mixed, or missing browser zone for one open turn.
|
||||
* @param messages - Entered and proposed user messages belonging to the turn.
|
||||
* @returns Sorted, duplicate-free browser-zone facts.
|
||||
*/
|
||||
export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): ClientTimeZoneContext {
|
||||
export function deriveBrowserTimeZoneContext(
|
||||
messages: readonly UserMessage[],
|
||||
): BrowserTimeZoneContext {
|
||||
const timeZones = [...new Set(messages.flatMap((message) => {
|
||||
const timeZone = clientTimeZone(message)
|
||||
const timeZone = browserTimeZone(message)
|
||||
return timeZone === undefined ? [] : [timeZone]
|
||||
}))].sort()
|
||||
const [timeZone, ...remaining] = timeZones
|
||||
@@ -37,20 +40,23 @@ export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): C
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Session and request-zone facts for the model-visible time reading.
|
||||
* @param sessionTimeZone - Immutable Session zone, or `undefined` for legacy Sessions.
|
||||
* @param client - Client zones derived from the current open turn.
|
||||
* @returns The two policy lines appended to a time-context reading.
|
||||
* Render the model instruction for one browser-zone context.
|
||||
* @param context - Browser-zone facts for the open turn.
|
||||
* @returns One durable policy line.
|
||||
*/
|
||||
export function renderTimeZoneContext(
|
||||
sessionTimeZone: string | undefined,
|
||||
client: ClientTimeZoneContext,
|
||||
): string {
|
||||
const session = sessionTimeZone ?? 'unavailable'
|
||||
const request = client.kind === 'resolved'
|
||||
? client.timeZone
|
||||
: client.kind === 'mixed'
|
||||
? `mixed ${JSON.stringify(client.timeZones)}`
|
||||
: 'missing'
|
||||
return `Session time zone: ${session}.\nClient time zone for this request: ${request}.`
|
||||
export function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string {
|
||||
switch (context.kind) {
|
||||
case 'resolved':
|
||||
return `Browser time zone for this request: ${context.timeZone}. `
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.'
|
||||
case 'mixed':
|
||||
return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. `
|
||||
+ 'Ask the user to clarify otherwise-unqualified dates and times.'
|
||||
case 'missing':
|
||||
return 'Browser time zone for this request: unavailable. '
|
||||
+ 'Ask the user to clarify otherwise-unqualified dates and times.'
|
||||
/* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
|
||||
default:
|
||||
return assertNever(context, 'BrowserTimeZoneContext')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user