refactor(schedule): simplify request zone authority
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Opt-in durable context with the current zoned time, Session and request-zone authority, and elapsed time sampled during model-request preparation. Default compositions do not mount it; the opt-in Schedule Web overlay does. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
Opt-in durable context with the current zoned time, immutable Session zone, request-bound browser zones, and elapsed time sampled during model-request preparation. Default compositions do not mount it; the opt-in Schedule Web overlay does. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -11,28 +11,28 @@ Opt-in durable context with the current zoned time, Session and request-zone aut
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional fallback for headerless Sessions; omit for the process zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every entered request step
|
||||
```
|
||||
|
||||
When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats its readings. A headerless Session instead uses the configured fallback; when `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the fallback. An explicit `timeZone` is validated at plugin load but does not override a Session-owned zone.
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible request preparation whose final pre-step decision contains input and whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every entered request step whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin opens a narrow authority envelope in `system-prompt/assemble` and closes it around `agent/pre-step`. It captures already-claimed input, and each user steering message admitted during asynchronous assembly is followed synchronously by a superseding same-step authority. AgentLoop includes the envelope's non-authority messages in the downstream pre-step proposal; after downstream edits, discards, or filtering settle, time-context derives the final authority from that decision.
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step, time-context derives client zones from the decision's final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
|
||||
An entering step records its downstream messages followed by exactly one final time-context `UserMessage` after `step/start`. Its source is `{ kind: 'plugin', plugin: 'time-context', authority }`, where `authority` identifies the proposed turn and step, the Session zone as `resolved` or `unavailable`, and the current request's client zones as `resolved`, `mixed`, or `missing`. An empty downstream decision consumes the envelope without opening a step or request.
|
||||
An entering step records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A first-step decision rewritten to empty opens no step and adds no reading. An empty tool continuation can still enter a later step and receives a reading.
|
||||
|
||||
If preparation exits before `step/start`, AgentLoop removes the envelope before closing the turn. It may settle an appendable final authority inside that failed turn, but an append failure drops the authority instead of leaving it pending. Cancellation cannot generate another authority after it wins; plugin disposal removes pending authorities and an in-flight listener contributes nothing after disposal. Steering and unrelated inbox work retain their ordinary cancellation policy, and no authority for an old turn or step can leak into a later request.
|
||||
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
|
||||
Step 1 measures from the latest durable model-visible message before the current proposal; the prompt entering that same step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
|
||||
|
||||
A time reading records request preparation, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, and a no-step failure can settle an already-sampled authority inside its failed turn.
|
||||
A time reading records an entered request step, not a completed or successfully transmitted request. A later request-preparation failure can therefore leave the reading in history, while a failure before `step/start` cannot.
|
||||
|
||||
The separately published `./invariant` companion strictly decodes each plugin-attributed authority and checks it against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
The separately published `./invariant` companion checks the simple plugin source, open turn and step, elapsed baseline, and durable event time. It also re-derives Session and client zones from the Session header and current turn's original user-rpc messages, so duplicated source authority or mismatched rendered policy fails. The rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
@@ -42,7 +42,7 @@ The time reading stays in derived conversation history until a later compaction
|
||||
|
||||
#### What the model sees
|
||||
|
||||
On each preparation attempt that injects, one source-tagged context message contains the four lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. Positive intervals can leave an attempted step without a new reading.
|
||||
On each entered step that injects, one source-tagged context message contains the four lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. Positive intervals can let an entered step reuse prior history without a new reading.
|
||||
|
||||
##### First step
|
||||
|
||||
@@ -64,7 +64,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every entered request step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -74,6 +74,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
|
||||
- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp.
|
||||
- **Headerless fallback zone** — a Session without `SessionHeader.timeZone` renders through the configured or process fallback but reports Session authority as `unavailable`; consumers that require unambiguous local-time interpretation must request an explicit zone.
|
||||
- **Immutable Session zone** — a Session zone does not change when another browser resumes it. The per-request client authority reports disagreement instead of silently changing the displayed default.
|
||||
- **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost.
|
||||
- **Headerless fallback zone** — a Session without `SessionHeader.timeZone` renders through the configured or process fallback but reports its Session zone as `unavailable`; consumers that require unambiguous local-time interpretation must request an explicit zone.
|
||||
- **Immutable Session zone** — a Session zone does not change when another browser resumes it. The request-bound browser sources expose disagreement instead of silently changing the displayed default.
|
||||
- **History cost between compactions** — omission or `0` retains one reading for every entered request step, including steps whose later request preparation fails; a positive interval reduces but does not eliminate this cost.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/request-zone-*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/** Machine-readable Session and request-zone authority carried by time-context messages. */
|
||||
|
||||
/** Session-owned zone authority included in each time-context reading. */
|
||||
export type SessionTimeZoneAuthority =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'unavailable' }
|
||||
|
||||
/** Client-zone provenance of the messages entering one proposed step. */
|
||||
export type ClientTimeZoneAuthority =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: string[] }
|
||||
| { readonly kind: 'missing' }
|
||||
|
||||
/** Machine-readable time authority shared by model context and Schedule tools. */
|
||||
export interface TimeContextAuthority {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly session: SessionTimeZoneAuthority
|
||||
readonly client: ClientTimeZoneAuthority
|
||||
}
|
||||
|
||||
/** Source shape owned by the time-context plugin. */
|
||||
export interface TimeContextMessageSource {
|
||||
kind: 'plugin'
|
||||
plugin: 'time-context'
|
||||
authority: TimeContextAuthority
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'time-context': TimeContextMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an unknown value is one ordinary JSON object. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Require one object to carry exactly the named keys. */
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort()
|
||||
const wanted = [...expected].sort()
|
||||
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index])
|
||||
}
|
||||
|
||||
/** Decode one non-empty zone name without re-owning Host canonicalization. */
|
||||
function zone(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new TypeError('time-context authority time zone must be a non-empty string')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Decode the Session branch of one authority value. */
|
||||
function sessionAuthority(value: unknown): SessionTimeZoneAuthority {
|
||||
if (!isRecord(value)) throw new TypeError('time-context Session authority must be an object')
|
||||
if (value['kind'] === 'unavailable' && hasExactKeys(value, ['kind'])) return { kind: 'unavailable' }
|
||||
if (value['kind'] === 'resolved' && hasExactKeys(value, ['kind', 'timeZone'])) {
|
||||
return { kind: 'resolved', timeZone: zone(value['timeZone']) }
|
||||
}
|
||||
throw new TypeError('time-context Session authority has an invalid shape')
|
||||
}
|
||||
|
||||
/** Decode the request-client branch of one authority value. */
|
||||
function clientAuthority(value: unknown): ClientTimeZoneAuthority {
|
||||
if (!isRecord(value)) throw new TypeError('time-context client authority must be an object')
|
||||
if (value['kind'] === 'missing' && hasExactKeys(value, ['kind'])) return { kind: 'missing' }
|
||||
if (value['kind'] === 'resolved' && hasExactKeys(value, ['kind', 'timeZone'])) {
|
||||
return { kind: 'resolved', timeZone: zone(value['timeZone']) }
|
||||
}
|
||||
if (value['kind'] === 'mixed' && hasExactKeys(value, ['kind', 'timeZones'])) {
|
||||
const values = value['timeZones']
|
||||
if (!Array.isArray(values)
|
||||
|| !values.every((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
|| values.length < 2) {
|
||||
throw new TypeError('time-context mixed client authority must contain at least two zones')
|
||||
}
|
||||
const timeZones = [...new Set(values)].sort()
|
||||
if (timeZones.length !== values.length || timeZones.some((item, index) => item !== values[index])) {
|
||||
throw new TypeError('time-context mixed client zones must be unique and sorted')
|
||||
}
|
||||
return { kind: 'mixed', timeZones }
|
||||
}
|
||||
throw new TypeError('time-context client authority has an invalid shape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the strict durable source attached to a time-context message.
|
||||
* @param value - Untrusted message source.
|
||||
* @returns Detached machine authority and its fixed plugin discriminator.
|
||||
*/
|
||||
export function decodeTimeContextSource(value: unknown): TimeContextMessageSource {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['kind', 'plugin', 'authority'])
|
||||
|| value['kind'] !== 'plugin' || value['plugin'] !== 'time-context') {
|
||||
throw new TypeError('time-context message source has an invalid shape')
|
||||
}
|
||||
const authority = value['authority']
|
||||
if (!isRecord(authority) || !hasExactKeys(authority, ['turn', 'step', 'session', 'client'])) {
|
||||
throw new TypeError('time-context authority has an invalid shape')
|
||||
}
|
||||
const turn = authority['turn']
|
||||
const step = authority['step']
|
||||
if (!Number.isSafeInteger(turn) || (turn as number) < 1
|
||||
|| !Number.isSafeInteger(step) || (step as number) < 1) {
|
||||
throw new TypeError('time-context authority turn and step must be positive safe integers')
|
||||
}
|
||||
return {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: {
|
||||
turn: turn as number,
|
||||
step: step as number,
|
||||
session: sessionAuthority(authority['session']),
|
||||
client: clientAuthority(authority['client']),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the machine authority as concise model-visible policy.
|
||||
* @param authority - Session and request-zone authority for one proposed step.
|
||||
* @returns The two policy lines appended to the time-context reading.
|
||||
*/
|
||||
export function renderTimeContextAuthority(authority: TimeContextAuthority): string {
|
||||
const session = authority.session.kind === 'resolved'
|
||||
? authority.session.timeZone
|
||||
: 'unavailable'
|
||||
const client = authority.client.kind === 'resolved'
|
||||
? authority.client.timeZone
|
||||
: authority.client.kind === 'mixed'
|
||||
? `mixed ${JSON.stringify(authority.client.timeZones)}`
|
||||
: 'missing'
|
||||
return `Session time zone: ${session}.\nClient time zone for this request: ${client}.`
|
||||
}
|
||||
@@ -10,19 +10,13 @@ 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 { renderTimeContextAuthority } from './authority.ts'
|
||||
import type {
|
||||
ClientTimeZoneAuthority,
|
||||
TimeContextAuthority,
|
||||
} from './authority.ts'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
|
||||
export type {
|
||||
ClientTimeZoneAuthority,
|
||||
SessionTimeZoneAuthority,
|
||||
TimeContextAuthority,
|
||||
TimeContextMessageSource,
|
||||
} from './authority.ts'
|
||||
export { decodeTimeContextSource, renderTimeContextAuthority } from './authority.ts'
|
||||
export type { ClientTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
@@ -44,7 +38,6 @@ export const Config: z<Config> = z.object({
|
||||
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. */
|
||||
@@ -73,7 +66,7 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Find the latest model-visible event, excluding this plugin's pending append. */
|
||||
/** 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) {
|
||||
@@ -114,40 +107,18 @@ function latestInjectionTime(agent: Agent): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Read the Host-validated client zone from one ordinary user-rpc message. */
|
||||
function clientTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
return source.kind === 'user'
|
||||
&& 'clientTimeZone' in source
|
||||
&& typeof source.clientTimeZone === 'string'
|
||||
? source.clientTimeZone
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Derive all distinct client zones in the current request chain. */
|
||||
function requestClientTimeZones(agent: Agent, turn: number, messages: readonly UserMessage[]): string[] {
|
||||
const zones = new Set<string>()
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'turn/start' && event.data.turn === turn) break
|
||||
if (event.type !== 'user/message') continue
|
||||
const zone = clientTimeZone(event.data)
|
||||
if (zone !== undefined) zones.add(zone)
|
||||
}
|
||||
for (const message of messages) {
|
||||
const zone = clientTimeZone(message)
|
||||
if (zone !== undefined) zones.add(zone)
|
||||
}
|
||||
return [...zones].sort()
|
||||
}
|
||||
|
||||
/** Close the request-zone set into the machine authority union. */
|
||||
function clientAuthority(timeZones: string[]): ClientTimeZoneAuthority {
|
||||
const [timeZone, ...remaining] = timeZones
|
||||
if (timeZone === undefined) return { kind: 'missing' }
|
||||
if (remaining.length === 0) return { kind: 'resolved', timeZone }
|
||||
return { kind: 'mixed', timeZones }
|
||||
/** 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,
|
||||
@@ -155,75 +126,17 @@ function renderText(
|
||||
previous: number | undefined,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
displayTimeZone: string,
|
||||
authority: TimeContextAuthority,
|
||||
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`
|
||||
+ `${renderTimeContextAuthority(authority)}\n`
|
||||
+ `${renderTimeZoneContext(sessionTimeZone, client)}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
interface PreparationPosition {
|
||||
turn: number
|
||||
step: number
|
||||
}
|
||||
|
||||
interface ClaimedPreparation extends PreparationPosition {
|
||||
messages: UserMessage[]
|
||||
}
|
||||
|
||||
interface AssemblyAuthorityState extends PreparationPosition {
|
||||
agent: Agent
|
||||
claimed: readonly UserMessage[]
|
||||
deferredIds: Set<string>
|
||||
handledIds: Set<string>
|
||||
accepting: boolean
|
||||
lastFingerprint?: string
|
||||
lastMessageId?: UserMessage['id']
|
||||
readonly signal: AbortSignal
|
||||
readonly onAbort: () => void
|
||||
}
|
||||
|
||||
/** Derive the next unopened step while one turn is in pre-step preparation. */
|
||||
function preparationPosition(agent: Agent): PreparationPosition | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
case 'step/start':
|
||||
case 'turn/end':
|
||||
return undefined
|
||||
case 'step/end':
|
||||
return { turn: event.data.turn, step: event.data.step + 1 }
|
||||
case 'turn/start':
|
||||
return { turn: event.data.turn, step: 1 }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether two preparation coordinates identify the same unopened step. */
|
||||
function samePosition<T extends PreparationPosition>(
|
||||
left: T | undefined,
|
||||
right: PreparationPosition,
|
||||
): left is T {
|
||||
return left?.turn === right.turn && left.step === right.step
|
||||
}
|
||||
|
||||
/** Whether one message is a time-context reading for an exact preparation. */
|
||||
function isAuthorityMessage(
|
||||
message: UserMessage,
|
||||
position: PreparationPosition,
|
||||
): boolean {
|
||||
const source = message.source
|
||||
return source.kind === 'plugin'
|
||||
&& source.plugin === name
|
||||
&& 'authority' in source
|
||||
&& source.authority.turn === position.turn
|
||||
&& source.authority.step === position.step
|
||||
}
|
||||
|
||||
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
||||
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
if (refreshIntervalMs !== undefined && (
|
||||
@@ -238,9 +151,10 @@ 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.
|
||||
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
||||
* @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
|
||||
@@ -268,8 +182,6 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
}
|
||||
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
|
||||
const claimedPreparations = new Map<Agent, ClaimedPreparation>()
|
||||
const assemblyAuthorities = new Map<Agent, AssemblyAuthorityState>()
|
||||
let disposed = false
|
||||
|
||||
/** Resolve one Session-owned formatter without making the process zone authoritative. */
|
||||
@@ -286,200 +198,52 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
return created
|
||||
}
|
||||
|
||||
/** Build one current reading without placing it in the inbox or decision. */
|
||||
/** Build one current reading after downstream pre-step transforms settle. */
|
||||
const readingFor = (
|
||||
agent: Agent,
|
||||
position: PreparationPosition,
|
||||
turn: number,
|
||||
step: number,
|
||||
messages: readonly UserMessage[],
|
||||
): { message: UserMessage; fingerprint: string } => {
|
||||
): UserMessage => {
|
||||
const now = Date.now()
|
||||
const previous = position.step === 1
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, position.turn)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
const sessionTimeZone = agent.session.header.timeZone
|
||||
const authority: TimeContextAuthority = {
|
||||
turn: position.turn,
|
||||
step: position.step,
|
||||
session: sessionTimeZone === undefined
|
||||
? { kind: 'unavailable' }
|
||||
: { kind: 'resolved', timeZone: sessionTimeZone },
|
||||
client: clientAuthority(requestClientTimeZones(agent, position.turn, messages)),
|
||||
}
|
||||
const displayTimeZone = sessionTimeZone ?? fallbackTimeZone
|
||||
const formatter = sessionTimeZone === undefined
|
||||
? fallbackFormatter
|
||||
: formatterFor(sessionTimeZone)
|
||||
return {
|
||||
message: createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: renderText(
|
||||
now,
|
||||
position.turn,
|
||||
position.step,
|
||||
previous,
|
||||
formatter,
|
||||
displayTimeZone,
|
||||
authority,
|
||||
),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: name, authority },
|
||||
}),
|
||||
fingerprint: JSON.stringify(authority),
|
||||
}
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: renderText(
|
||||
now,
|
||||
turn,
|
||||
step,
|
||||
previous,
|
||||
formatter,
|
||||
displayTimeZone,
|
||||
sessionTimeZone,
|
||||
requestMessages(agent, turn, messages),
|
||||
),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: name },
|
||||
})
|
||||
}
|
||||
|
||||
/** Messages added after assembly opened, excluding deferred pre-existing work. */
|
||||
const assemblyMessages = (state: AssemblyAuthorityState): UserMessage[] =>
|
||||
state.agent.inbox.nextStep.filter(message => !state.deferredIds.has(message.id))
|
||||
|
||||
/** Stop accepting late steering while retaining the state for boundary cleanup. */
|
||||
const closeAssembly = (state: AssemblyAuthorityState): void => {
|
||||
state.accepting = false
|
||||
}
|
||||
|
||||
/** Forget one preparation and detach its cancellation observer. */
|
||||
const clearAssembly = (agent: Agent, state = assemblyAuthorities.get(agent)): void => {
|
||||
if (state === undefined) return
|
||||
state.accepting = false
|
||||
state.signal.removeEventListener('abort', state.onAbort)
|
||||
if (assemblyAuthorities.get(agent) === state) assemblyAuthorities.delete(agent)
|
||||
}
|
||||
|
||||
/** Append one same-step authority after the messages that caused it. */
|
||||
const stageAuthority = (state: AssemblyAuthorityState, force: boolean): void => {
|
||||
if (disposed || !state.accepting) return
|
||||
const reading = readingFor(
|
||||
state.agent,
|
||||
state,
|
||||
[...state.claimed, ...assemblyMessages(state)],
|
||||
)
|
||||
if (!force && reading.fingerprint === state.lastFingerprint) return
|
||||
state.agent.inject(reading.message)
|
||||
state.lastFingerprint = reading.fingerprint
|
||||
state.lastMessageId = reading.message.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture messages claimed for the unopened step. The system-prompt
|
||||
* assembly itself does not receive this batch, so the preparation listener
|
||||
* preserves its request-zone provenance explicitly.
|
||||
*/
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
||||
if (disposed) return
|
||||
const position = preparationPosition(agent)
|
||||
if (position === undefined || position.turn !== turn) return
|
||||
const existing = claimedPreparations.get(agent)
|
||||
if (!samePosition(existing, position)) {
|
||||
claimedPreparations.set(agent, { ...position, messages: [message] })
|
||||
return
|
||||
}
|
||||
existing.messages.push(message)
|
||||
})
|
||||
|
||||
/**
|
||||
* Open the narrow assembly window before downstream prompt providers run.
|
||||
* The initial authority enters the ordinary next-step outbox; AgentLoop
|
||||
* drains its closed envelope only after pre-step accepts the step.
|
||||
*/
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (disposed) return next()
|
||||
const agent = context.agent
|
||||
const signal = context.signal
|
||||
const position = agent === undefined ? undefined : preparationPosition(agent)
|
||||
if (agent === undefined || signal === undefined || position === undefined || signal.aborted) {
|
||||
return next()
|
||||
}
|
||||
if (samePosition(assemblyAuthorities.get(agent), position)) return next()
|
||||
clearAssembly(agent)
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
if (lastInjection !== undefined
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return next()
|
||||
}
|
||||
const claimed = claimedPreparations.get(agent)
|
||||
const state = {
|
||||
...position,
|
||||
agent,
|
||||
claimed: samePosition(claimed, position) ? [...claimed.messages] : [],
|
||||
deferredIds: new Set(agent.inbox.nextStep.map(message => message.id)),
|
||||
handledIds: new Set<string>(),
|
||||
accepting: true,
|
||||
signal,
|
||||
onAbort: () => {},
|
||||
} satisfies AssemblyAuthorityState
|
||||
state.onAbort = () => { closeAssembly(state) }
|
||||
assemblyAuthorities.set(agent, state)
|
||||
signal.addEventListener('abort', state.onAbort, { once: true })
|
||||
try {
|
||||
stageAuthority(state, true)
|
||||
return await next()
|
||||
} finally {
|
||||
closeAssembly(state)
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
/** A late steering message supersedes the authority synchronously behind it. */
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (disposed) return
|
||||
const state = assemblyAuthorities.get(agent)
|
||||
if (state === undefined || !state.accepting
|
||||
|| state.deferredIds.has(message.id)
|
||||
|| !agent.inbox.nextStep.some(candidate => candidate.id === message.id)
|
||||
|| message.source.kind !== 'user') return
|
||||
const handledByReplacement = state.handledIds.has(message.id)
|
||||
stageAuthority(state, !handledByReplacement)
|
||||
state.handledIds.add(message.id)
|
||||
})
|
||||
|
||||
/** Recompute after an edit/discard, but do not resurrect a cleared inbox. */
|
||||
ctx.on('agent/inbox/discarded', ({ agent, message }) => {
|
||||
if (disposed) return
|
||||
const state = assemblyAuthorities.get(agent)
|
||||
if (state === undefined || !state.accepting
|
||||
|| state.deferredIds.has(message.id)
|
||||
|| message.source.kind !== 'user') return
|
||||
if (!agent.inbox.nextStep.some(candidate => isAuthorityMessage(candidate, state))) {
|
||||
closeAssembly(state)
|
||||
return
|
||||
}
|
||||
stageAuthority(state, false)
|
||||
state.handledIds = new Set(
|
||||
assemblyMessages(state)
|
||||
.filter(candidate => candidate.source.kind === 'user')
|
||||
.map(candidate => candidate.id),
|
||||
)
|
||||
})
|
||||
|
||||
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()) return decision
|
||||
const staged = assemblyAuthorities.get(agent)
|
||||
if (decision.kind === 'reject' || signal.aborted) {
|
||||
if (samePosition(staged, { turn, step })) closeAssembly(staged)
|
||||
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|
||||
|| (step === 1 && decision.messages.length === 0)) {
|
||||
return decision
|
||||
}
|
||||
if (samePosition(staged, { turn, step })) {
|
||||
closeAssembly(staged)
|
||||
const reading = readingFor(agent, { turn, step }, decision.messages)
|
||||
if (reading.fingerprint !== staged.lastFingerprint) {
|
||||
const replaced = staged.lastMessageId === undefined
|
||||
? false
|
||||
: agent.inbox.replace(staged.lastMessageId, reading.message)
|
||||
if (!replaced) agent.inject(reading.message)
|
||||
staged.lastFingerprint = reading.fingerprint
|
||||
staged.lastMessageId = reading.message.id
|
||||
}
|
||||
return decision
|
||||
}
|
||||
if (decision.messages.length === 0) return decision
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
@@ -487,51 +251,16 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return decision
|
||||
}
|
||||
const reading = readingFor(agent, { turn, step }, decision.messages)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: [
|
||||
...decision.messages,
|
||||
reading.message,
|
||||
readingFor(agent, turn, step, decision.messages),
|
||||
],
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
/** Step/turn/lifecycle boundaries release request-only bookkeeping. */
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (disposed) return
|
||||
if (event.type !== 'step/start' && event.type !== 'turn/end') return
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent === undefined || agent.session !== session) return
|
||||
clearAssembly(agent)
|
||||
if (event.type === 'turn/end') claimedPreparations.delete(agent)
|
||||
})
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (disposed) return
|
||||
if (status !== 'idle') return
|
||||
clearAssembly(agent)
|
||||
claimedPreparations.delete(agent)
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (disposed) return
|
||||
clearAssembly(agent)
|
||||
claimedPreparations.delete(agent)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
for (const [agent, state] of assemblyAuthorities) {
|
||||
closeAssembly(state)
|
||||
for (const message of [...agent.inbox.nextStep]) {
|
||||
if (!isAuthorityMessage(message, state)) continue
|
||||
try {
|
||||
agent.inbox.remove(message.id)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`time-context: failed to discard authority during dispose: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
clearAssembly(agent, state)
|
||||
}
|
||||
claimedPreparations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { decodeTimeContextSource, renderTimeContextAuthority } from './authority.ts'
|
||||
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
const SOURCE_NAME = 'time-context'
|
||||
@@ -21,21 +21,15 @@ export const name = 'time-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Derive the step preparation owned by a time-context reading. A normal
|
||||
* reading follows `step/start`; a pre-step failure may settle context-only
|
||||
* output in the still-open turn before that boundary.
|
||||
*/
|
||||
/** Derive the open step owned by a time-context reading. */
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
let openTurn: number | undefined
|
||||
let openStep: number | undefined
|
||||
let nextStep = 1
|
||||
for (const event of history) {
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
openTurn = event.data.turn
|
||||
openStep = undefined
|
||||
nextStep = 1
|
||||
break
|
||||
}
|
||||
case 'step/start': {
|
||||
@@ -44,7 +38,6 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
|
||||
}
|
||||
case 'step/end': {
|
||||
openStep = undefined
|
||||
nextStep = event.data.step + 1
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
@@ -57,11 +50,19 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
|
||||
}
|
||||
}
|
||||
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
|
||||
return { turn: openTurn, step: openStep ?? nextStep }
|
||||
if (openStep === undefined) fail('time-context reading must follow step/start')
|
||||
return { turn: openTurn, step: openStep }
|
||||
}
|
||||
|
||||
/** 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] : [])
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -81,18 +82,16 @@ function validateReading(
|
||||
if (turn !== expected.turn || step !== expected.step) {
|
||||
fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`)
|
||||
}
|
||||
let source: ReturnType<typeof decodeTimeContextSource>
|
||||
try {
|
||||
source = decodeTimeContextSource(event.data.source)
|
||||
} catch (error: unknown) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
if (source.authority.turn !== turn || source.authority.step !== step) {
|
||||
fail('time-context text and source authority name different positions')
|
||||
if (Object.keys(event.data.source).length !== 2) {
|
||||
fail('time-context source must not duplicate request authority')
|
||||
}
|
||||
const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.`
|
||||
if (renderedAuthority !== renderTimeContextAuthority(source.authority)) {
|
||||
fail('time-context text and source authority describe different zones')
|
||||
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 baseline = match[6]
|
||||
if ((step === 1) !== (baseline === 'model-visible message')) {
|
||||
@@ -115,7 +114,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.events.slice(0, index), event, fail)
|
||||
validateReading(session, session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +127,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.events, event, fail)
|
||||
validateReading(session, session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
54
packages/context/time-context/src/request-zone.ts
Normal file
54
packages/context/time-context/src/request-zone.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/** Request-zone derivation shared by time-context rendering and Schedule tools. */
|
||||
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Client-zone facts derived from the user-rpc messages in one open turn. */
|
||||
export type ClientTimeZoneContext =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: string[] }
|
||||
| { readonly kind: 'missing' }
|
||||
|
||||
/** Read the Host-validated client zone from one ordinary user-rpc message. */
|
||||
function clientTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
return source.kind === 'user'
|
||||
&& 'clientTimeZone' in source
|
||||
&& typeof source.clientTimeZone === 'string'
|
||||
? source.clientTimeZone
|
||||
: 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.
|
||||
*/
|
||||
export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): ClientTimeZoneContext {
|
||||
const timeZones = [...new Set(messages.flatMap((message) => {
|
||||
const timeZone = clientTimeZone(message)
|
||||
return timeZone === undefined ? [] : [timeZone]
|
||||
}))].sort()
|
||||
const [timeZone, ...remaining] = timeZones
|
||||
if (timeZone === undefined) return { kind: 'missing' }
|
||||
if (remaining.length === 0) return { kind: 'resolved', timeZone }
|
||||
return { kind: 'mixed', timeZones }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}.`
|
||||
}
|
||||
@@ -22,9 +22,6 @@ function event(
|
||||
content?: unknown[],
|
||||
plugin = 'time-context',
|
||||
): SessionEvent<'user/message'> {
|
||||
const position = /turn (\d+), step (\d+):/.exec(text)
|
||||
const turn = Number(position?.[1] ?? '1')
|
||||
const step = Number(position?.[2] ?? '1')
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
@@ -35,12 +32,6 @@ function event(
|
||||
? {
|
||||
kind: 'plugin',
|
||||
plugin,
|
||||
authority: {
|
||||
turn,
|
||||
step,
|
||||
session: { kind: 'unavailable' },
|
||||
client: { kind: 'missing' },
|
||||
},
|
||||
}
|
||||
: { kind: 'plugin', plugin },
|
||||
}),
|
||||
@@ -52,10 +43,12 @@ function reading(
|
||||
step = '1',
|
||||
baseline = 'model-visible message',
|
||||
timestamp = '2026-07-14T00:00:00+00:00[UTC]',
|
||||
sessionTimeZone = 'unavailable',
|
||||
clientTimeZone = 'missing',
|
||||
): string {
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
|
||||
+ 'Session time zone: unavailable.\n'
|
||||
+ 'Client time zone for this request: missing.\n'
|
||||
+ `Session time zone: ${sessionTimeZone}.\n`
|
||||
+ `Client time zone for this request: ${clientTimeZone}.\n`
|
||||
+ `Elapsed since the preceding ${baseline}: unavailable.`
|
||||
}
|
||||
|
||||
@@ -79,18 +72,11 @@ function preparing(turn: number, step: number): Session {
|
||||
}
|
||||
|
||||
function appendReading(session: Session, text: string): void {
|
||||
const position = /turn (\d+), step (\d+):/.exec(text)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: {
|
||||
turn: Number(position?.[1] ?? '1'),
|
||||
step: Number(position?.[2] ?? '1'),
|
||||
session: { kind: 'unavailable' },
|
||||
client: { kind: 'missing' },
|
||||
},
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
@@ -112,6 +98,59 @@ describe('time-context invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('derives Session and client zones from their original durable owners', async () => {
|
||||
const ctx = await setup()
|
||||
const id = SessionId('time-invariant-zones')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: SECOND,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'travel request' }],
|
||||
source: { kind: 'user', clientTimeZone: 'America/New_York' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T00:00:00+00:00[UTC]',
|
||||
'Asia/Shanghai',
|
||||
'America/New_York',
|
||||
)))
|
||||
}).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T00:00:00+00:00[UTC]',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Shanghai',
|
||||
)))
|
||||
}).toThrow(/does not match the Session and current request zones/)
|
||||
})
|
||||
|
||||
it('rejects a time-context source that duplicates request authority', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const duplicate: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
source: { ...base.data.source, authority: {} } as never,
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), duplicate)
|
||||
}).toThrow(/must not duplicate request authority/)
|
||||
})
|
||||
|
||||
it('validates each existing reading against its preceding durable prefix', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -160,11 +199,11 @@ describe('time-context invariants', () => {
|
||||
.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('accepts context-only settlement before step/start', async () => {
|
||||
it('rejects a reading before step/start', async () => {
|
||||
const ctx = await setup()
|
||||
const session = Session.create(SessionId('time-invariant-turn-only'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading())) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', session, event(reading())) }).toThrow(/follow step\/start/)
|
||||
})
|
||||
|
||||
it('rejects a reading outside its open preparation', async () => {
|
||||
@@ -172,7 +211,7 @@ describe('time-context invariants', () => {
|
||||
const ended = preparing(1, 1)
|
||||
ended.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => { ctx.emit('session/event', ended, event(reading())) })
|
||||
.toThrow(/expected turn 1\/step 2/)
|
||||
.toThrow(/follow step\/start/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
|
||||
}).toThrow(/inside an open turn/)
|
||||
|
||||
54
packages/context/time-context/tests/request-zone.spec.ts
Normal file
54
packages/context/time-context/tests/request-zone.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
} from '@deepseek-ai/dsh-time-context'
|
||||
|
||||
function request(clientTimeZone?: unknown) {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: clientTimeZone === undefined
|
||||
? { kind: 'user' }
|
||||
: { kind: 'user', clientTimeZone } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('request-zone derivation', () => {
|
||||
it('derives missing, one resolved zone, and sorted unique mixed zones', () => {
|
||||
const plugin = createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'fixture' },
|
||||
})
|
||||
expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' })
|
||||
expect(deriveClientTimeZoneContext([
|
||||
request('Asia/Shanghai'),
|
||||
request('Asia/Shanghai'),
|
||||
])).toEqual({ kind: 'resolved', timeZone: 'Asia/Shanghai' })
|
||||
expect(deriveClientTimeZoneContext([
|
||||
request('Asia/Shanghai'),
|
||||
request('America/New_York'),
|
||||
])).toEqual({
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders resolved, mixed, and unavailable policy lines', () => {
|
||||
expect(renderTimeZoneContext('Asia/Shanghai', {
|
||||
kind: 'resolved',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})).toBe(
|
||||
'Session time zone: Asia/Shanghai.\nClient time zone for this request: Asia/Shanghai.',
|
||||
)
|
||||
expect(renderTimeZoneContext('UTC', {
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'UTC'],
|
||||
})).toBe(
|
||||
'Session time zone: UTC.\nClient time zone for this request: mixed ["America/New_York","UTC"].',
|
||||
)
|
||||
expect(renderTimeZoneContext(undefined, { kind: 'missing' })).toBe(
|
||||
'Session time zone: unavailable.\nClient time zone for this request: missing.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -171,30 +171,29 @@ describe('durable step context', () => {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1, SIGNAL, [
|
||||
await fire(ctx, agent, 1, 1, SIGNAL, [
|
||||
rpcMessage('local request', 'Asia/Shanghai'),
|
||||
])
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
)
|
||||
expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.')
|
||||
expect(contextTexts(session)[0]).toContain('Client time zone for this request: Asia/Shanghai.')
|
||||
const reading = session.events.at(-1)
|
||||
expect(reading).toMatchObject({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
},
|
||||
},
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
},
|
||||
})
|
||||
|
||||
await fire(ctx, agent, 1, 2, SIGNAL, [
|
||||
rpcMessage('same zone again', 'Asia/Shanghai'),
|
||||
])
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports sorted mixed zones from the current request chain without changing the Session zone', async () => {
|
||||
@@ -215,21 +214,10 @@ describe('durable step context', () => {
|
||||
rpcMessage('second tab', 'America/New_York'),
|
||||
])
|
||||
|
||||
const reading = session.events.at(-1)
|
||||
expect(reading).toMatchObject({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
source: {
|
||||
authority: {
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: {
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.')
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].',
|
||||
)
|
||||
})
|
||||
|
||||
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
|
||||
@@ -252,12 +240,6 @@ describe('durable step context', () => {
|
||||
expect(event.data.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'unavailable' },
|
||||
client: { kind: 'missing' },
|
||||
},
|
||||
})
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
@@ -435,6 +417,20 @@ describe('configuration and lifecycle', () => {
|
||||
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
})
|
||||
|
||||
it('fails loud when a persisted Session names an invalid zone', async () => {
|
||||
const { ctx } = await mount()
|
||||
const id = SessionId('invalid-session-zone')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: BASE,
|
||||
timeZone: 'Not/A_Real_Zone',
|
||||
})
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await expect(fire(ctx, sessionAgent(session), 1, 1)).rejects.toThrow(/invalid Session time zone/)
|
||||
})
|
||||
|
||||
it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => {
|
||||
const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN]
|
||||
for (const refreshIntervalMs of invalid) {
|
||||
@@ -456,13 +452,26 @@ describe('configuration and lifecycle', () => {
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets an already-stopped direct registration delegate without contributing', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const stop = timeContext.apply(ctx, {})
|
||||
stop()
|
||||
const session = Session.create(SessionId('stopped-direct-registration'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('real agent-loop request history', () => {
|
||||
it.each([
|
||||
['throws', 1],
|
||||
['throws', 0],
|
||||
['cancels', 0],
|
||||
] as const)('settles preparation context when a downstream pre-step listener %s', async (mode, expectedContexts) => {
|
||||
] as const)('does not persist context when a downstream pre-step listener %s', async (mode, expectedContexts) => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
|
||||
@@ -481,123 +490,42 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drains late assembly steering between initial and superseding same-step authorities', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
it('leaves steering that arrives after claim for the next step and derives fresh context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let proposedTexts: string[] = []
|
||||
let blocked = true
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (context.agent !== undefined) {
|
||||
if (blocked && context.agent !== undefined) {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ messages }, next) => {
|
||||
proposedTexts = messages.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('late-steering'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup(rpcMessage('start in Shanghai', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
agent.steer(rpcMessage('switch to New York', 'America/New_York'))
|
||||
blocked = false
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
const enteredMessages = agent.session.events.filter(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message',
|
||||
expect(requestText(adapter.requests[0]!)).toContain('start in Shanghai')
|
||||
expect(requestText(adapter.requests[0]!)).not.toContain('switch to New York')
|
||||
expect(requestText(adapter.requests[0]!)).toContain('Client time zone for this request: Asia/Shanghai.')
|
||||
expect(requestText(adapter.requests[1]!)).toContain('switch to New York')
|
||||
expect(requestText(adapter.requests[1]!)).toContain(
|
||||
'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].',
|
||||
)
|
||||
const texts = enteredMessages.map(message =>
|
||||
message.data.content.find(block => block.type === 'text')?.text)
|
||||
expect(texts).toEqual([
|
||||
'start in Shanghai',
|
||||
'switch to New York',
|
||||
expect.stringContaining('Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].'),
|
||||
])
|
||||
expect(proposedTexts).toEqual([
|
||||
'start in Shanghai',
|
||||
'switch to New York',
|
||||
])
|
||||
const authorities = enteredMessages
|
||||
.filter(message => message.data.source.kind === 'plugin')
|
||||
.map(message => message.data.source.kind === 'plugin' && 'authority' in message.data.source
|
||||
? message.data.source.authority
|
||||
: undefined)
|
||||
expect(authorities).toEqual([
|
||||
expect.objectContaining({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
client: {
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
},
|
||||
}),
|
||||
])
|
||||
expect(contextTexts(agent.session)).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('collapses edited and discarded late steering to one truthful final authority', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (context.agent !== undefined) {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('edited-late-steering'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('start in Shanghai', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
const edited = rpcMessage('switch to New York', 'America/New_York')
|
||||
agent.steer(edited)
|
||||
const replacement = rpcMessage('stay in Shanghai', 'Asia/Shanghai')
|
||||
expect(agent.inbox.replace(edited.id, replacement)).toBe(true)
|
||||
const discarded = rpcMessage('temporary New York tab', 'America/New_York')
|
||||
agent.steer(discarded)
|
||||
expect(agent.inbox.remove(discarded.id)).toBe(true)
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
const request = requestText(adapter.requests[0]!)
|
||||
expect(request).toContain('start in Shanghai')
|
||||
expect(request).toContain('stay in Shanghai')
|
||||
expect(request).not.toContain('switch to New York')
|
||||
expect(request).not.toContain('temporary New York tab')
|
||||
expect(request).not.toContain('Client time zone for this request: mixed')
|
||||
const authorities = agent.session.events.filter(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context')
|
||||
expect(authorities).toHaveLength(1)
|
||||
expect(authorities[0]).toMatchObject({
|
||||
data: {
|
||||
source: {
|
||||
authority: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not let preparation authority create a step after downstream suppression', async () => {
|
||||
it('does not let time context create an initial step after downstream suppression', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/pre-step', async (_payload, next) => {
|
||||
@@ -619,7 +547,7 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('settles authorities but preserves steering when keep-inbox cancellation wins assembly', async () => {
|
||||
it('preserves post-claim steering without persisting failed-turn context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('resumed')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
@@ -644,21 +572,16 @@ describe('real agent-loop request history', () => {
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(contextTexts(agent.session)).toHaveLength(0)
|
||||
expect(agent.inbox.nextStep).toEqual([steering])
|
||||
expect(agent.inbox.nextStep.some(message =>
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'time-context')).toBe(false)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
const lastAuthority = agent.session.events.findLast(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context')
|
||||
expect(lastAuthority?.seq).toBeLessThan(turnEnd?.seq ?? -1)
|
||||
|
||||
agent.followup(rpcMessage('wake', 'America/New_York'))
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(requestText(adapter.requests[0]!)).toContain('preserve this steering')
|
||||
expect(requestText(adapter.requests[0]!)).toContain('Time sampled while preparing turn 2, step 1:')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -694,55 +617,6 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a rejected context append instead of leaking its authority to the next turn', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('resumed')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let blocked = true
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
if (blocked && context.agent !== undefined) {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('context-append-rejection'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const originalAppend = agent.session.append.bind(agent.session)
|
||||
let rejectContext = true
|
||||
vi.spyOn(agent.session, 'append').mockImplementation(((type, data, options) => {
|
||||
if (rejectContext && type === 'user/message'
|
||||
&& (data as UserMessage).source.kind === 'plugin'
|
||||
&& (data as UserMessage).source.plugin === 'time-context') {
|
||||
rejectContext = false
|
||||
throw new Error('context append unavailable')
|
||||
}
|
||||
return originalAppend(type, data, options)
|
||||
}) as typeof agent.session.append)
|
||||
|
||||
agent.followup(rpcMessage('start', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
blocked = false
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(contextTexts(agent.session)).toHaveLength(0)
|
||||
expect(agent.inbox.nextStep.some(message =>
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'time-context')).toBe(false)
|
||||
|
||||
agent.followup(rpcMessage('wake', 'Asia/Shanghai'))
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const request = requestText(adapter.requests[0]!)
|
||||
expect(request).toContain('Time sampled while preparing turn 2, step 1:')
|
||||
expect(request).not.toContain('Time sampled while preparing turn 1, step 1:')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
|
||||
@@ -55,11 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message.
|
||||
|
||||
System-prompt assembly runs after that claim and before `agent/pre-step`. A provider may use this bounded asynchronous window to stage an authority-delimited envelope in the next-step inbox. The driver adds the envelope's ordinary messages to the pre-step proposal, so guards and transformations see late steering, but keeps preparation authorities outside that decision. Rejection leaves the claimed batch removed; an empty enter decision consumes the envelope without opening a step. A non-empty enter appends the transformed messages followed by only the envelope's final authority after `step/start`. If preparation fails before then, the driver removes the envelope and settles at most its final appendable authority inside the no-step turn, so no old authority leaks while unrelated pending input retains its normal ownership. The [durable time-context decision](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md) owns the current producer.
|
||||
|
||||
Input inserted after an ordinary claim remains pending unless it belongs to that bounded envelope, and idle injection waits until follow-up or steering wakes the driver.
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
|
||||
|
||||
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
|
||||
|
||||
|
||||
@@ -51,36 +51,6 @@ type PreparedStep =
|
||||
| { kind: 'reject' }
|
||||
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
|
||||
|
||||
/** The exact private time-context source shape that may span prompt assembly. */
|
||||
function isPreparationAuthority(message: UserMessage, turn: number, step: number): boolean {
|
||||
const source = message.source as unknown
|
||||
if (typeof source !== 'object' || source === null || Array.isArray(source)) return false
|
||||
const record = source as Record<string, unknown>
|
||||
if (record['kind'] !== 'plugin' || record['plugin'] !== 'time-context') return false
|
||||
const authority = record['authority']
|
||||
return typeof authority === 'object'
|
||||
&& authority !== null
|
||||
&& !Array.isArray(authority)
|
||||
&& (authority as Record<string, unknown>)['turn'] === turn
|
||||
&& (authority as Record<string, unknown>)['step'] === step
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the concrete driver's private Inbox range primitive without adding a
|
||||
* cross-package public method or a source-only package import.
|
||||
*/
|
||||
function claimPreparationRange(
|
||||
inbox: Inbox,
|
||||
start: number,
|
||||
count: number,
|
||||
turn: number,
|
||||
): UserMessage[] {
|
||||
type DriverInbox = {
|
||||
claimRange(target: InboxTarget, start: number, count: number, turn: number, publish?: boolean): UserMessage[]
|
||||
}
|
||||
return (inbox as unknown as DriverInbox).claimRange('next-step', start, count, turn)
|
||||
}
|
||||
|
||||
/** Remove adapter-derived values before plugins propose the next request config. */
|
||||
function requestProposal(header: EpochHeader): LlmCallConfig {
|
||||
if (header.adapterDefaults === undefined) return header.config
|
||||
@@ -261,86 +231,17 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
const sections = renderContextSections(assembly)
|
||||
const context = this.runtimeContext.project(joinContextSections(sections), sections)
|
||||
const proposal = context === undefined ? claimed : [...claimed, context]
|
||||
const preparation = this.preparationEnvelope(position.turn, position.step)
|
||||
.filter(message => !isPreparationAuthority(message, position.turn, position.step))
|
||||
const decision = await this.dispatch.waterfall(
|
||||
'agent/pre-step', { messages: [...proposal, ...preparation], ...position, signal },
|
||||
'agent/pre-step', { messages: claimed, ...position, signal },
|
||||
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
|
||||
kind: 'enter',
|
||||
messages: [...proposal, ...preparation],
|
||||
messages: context === undefined ? claimed : [...claimed, context],
|
||||
}),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
return decision.kind === 'reject' ? decision : { ...decision, assembly }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the closed assembly envelope without consuming it. Non-authority
|
||||
* messages enter the pre-step proposal; the final authority is resolved
|
||||
* only after downstream pre-step transforms have settled.
|
||||
*/
|
||||
private preparationEnvelope(turn: number, step: number): UserMessage[] {
|
||||
const pending = this.inbox.nextStep
|
||||
const first = pending.findIndex(message => isPreparationAuthority(message, turn, step))
|
||||
if (first < 0) return []
|
||||
let last = first
|
||||
for (let index = first + 1; index < pending.length; index += 1) {
|
||||
const message = pending[index]
|
||||
if (message !== undefined && isPreparationAuthority(message, turn, step)) last = index
|
||||
}
|
||||
return pending.slice(first, last + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the closed assembly envelope. Messages before or after its first and
|
||||
* last authority retain ordinary next-step ownership.
|
||||
*/
|
||||
private claimPreparationEnvelope(turn: number, step: number): UserMessage[] {
|
||||
const envelope = this.preparationEnvelope(turn, step)
|
||||
const firstMessage = envelope[0]
|
||||
if (firstMessage === undefined) return []
|
||||
const first = this.inbox.nextStep.findIndex(message => message.id === firstMessage.id)
|
||||
/* v8 ignore next -- preparationEnvelope returned a live next-step member. */
|
||||
if (first < 0) throw new Error('preparation envelope moved before it could be claimed')
|
||||
return claimPreparationRange(this.inbox, first, envelope.length, turn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close context-only assembly output inside a turn that never reached
|
||||
* `step/start`. Each authority leaves the inbox before its surface append,
|
||||
* so an append rejection fails closed instead of leaking it into a later
|
||||
* turn. Steering and unrelated pending input are not touched.
|
||||
*/
|
||||
private settlePreparationAuthorities(turn: number, step: number): void {
|
||||
let finalAuthority: UserMessage | undefined
|
||||
for (const authority of [...this.inbox.nextStep]) {
|
||||
if (!isPreparationAuthority(authority, turn, step)) continue
|
||||
const index = this.inbox.nextStep.findIndex(message => message.id === authority.id)
|
||||
if (index < 0) continue
|
||||
let claimed: UserMessage[]
|
||||
try {
|
||||
claimed = claimPreparationRange(this.inbox, index, 1, turn)
|
||||
} catch (error: unknown) {
|
||||
this.dispatch.emit('agent/error', { turn, step, error })
|
||||
this.loopCtx.logger.warn(
|
||||
`agent "${this.id}": failed to remove pre-step time context: ${errorChain(error)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
finalAuthority = claimed.at(-1) ?? finalAuthority
|
||||
}
|
||||
if (finalAuthority === undefined) return
|
||||
try {
|
||||
this.session.append('user/message', finalAuthority, { surfaceOp: 'append' })
|
||||
} catch (error: unknown) {
|
||||
this.dispatch.emit('agent/error', { turn, step, error })
|
||||
this.loopCtx.logger.warn(
|
||||
`agent "${this.id}": dropped pre-step time context after append failed: ${errorChain(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Open one turn before claiming its first proposed step. */
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind !== 'running') {
|
||||
@@ -358,27 +259,19 @@ export class ReactLoopAgent implements Agent {
|
||||
phase.turn = turn
|
||||
let turnEnds: TurnEndReason | null = null
|
||||
let target: InboxTarget = 'next-turn'
|
||||
let preparingStep: number | undefined
|
||||
try {
|
||||
while (true) {
|
||||
signal.throwIfAborted()
|
||||
const step = phase.step + 1
|
||||
preparingStep = step
|
||||
const decision = await this.preStep(target, { turn, step })
|
||||
if (decision.kind === 'reject') {
|
||||
turnEnds = { kind: 'blocked' }
|
||||
return false
|
||||
}
|
||||
if (turnEnds && decision.messages.length === 0) {
|
||||
this.claimPreparationEnvelope(turn, step)
|
||||
preparingStep = undefined
|
||||
break
|
||||
}
|
||||
if (turnEnds && decision.messages.length === 0) break
|
||||
// A removed waking message or an enter decision rewritten to empty
|
||||
// still owns the initial turn boundary, but it spends no model call.
|
||||
if (phase.step === 0 && decision.messages.length === 0) {
|
||||
this.claimPreparationEnvelope(turn, step)
|
||||
preparingStep = undefined
|
||||
turnEnds = { kind: 'completed' }
|
||||
return false
|
||||
}
|
||||
@@ -386,14 +279,7 @@ export class ReactLoopAgent implements Agent {
|
||||
this.session.append('step/start', { turn, step })
|
||||
phase.step = step
|
||||
try {
|
||||
const preparation = this.claimPreparationEnvelope(turn, step)
|
||||
preparingStep = undefined
|
||||
const finalAuthority = preparation.findLast(message =>
|
||||
isPreparationAuthority(message, turn, step))
|
||||
for (const message of [
|
||||
...decision.messages,
|
||||
...(finalAuthority === undefined ? [] : [finalAuthority]),
|
||||
]) {
|
||||
for (const message of decision.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
// max-tokens is sticky: once any step hits the ceiling, later steps
|
||||
@@ -428,10 +314,6 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
this.throwError(error)
|
||||
} finally {
|
||||
if (preparingStep !== undefined) {
|
||||
this.settlePreparationAuthorities(turn, preparingStep)
|
||||
preparingStep = undefined
|
||||
}
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
|
||||
this.session.append('turn/end', { turn, reason: turnEnds! })
|
||||
|
||||
@@ -71,35 +71,14 @@ export class Inbox {
|
||||
* @internal - The agent loop's step-boundary operation, not a plugin extension point.
|
||||
*/
|
||||
claim(target: InboxTarget, turn: number): UserMessage[] {
|
||||
const claimed = this.claimRange('next-step', 0, this.nextStep.length, turn, false)
|
||||
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
|
||||
if (target === 'next-turn') {
|
||||
claimed.push(...this.claimRange('next-turn', 0, 1, turn, false))
|
||||
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
|
||||
}
|
||||
for (const message of claimed) this.notifications.claimed(message, turn)
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one contiguous pending range into an open turn without classifying
|
||||
* it as cancellation. Concrete drivers may use this protected primitive to
|
||||
* finish a private step-boundary drain while keeping {@link Inbox}'s public
|
||||
* claim semantics unchanged.
|
||||
* @internal
|
||||
*/
|
||||
private claimRange(
|
||||
target: InboxTarget,
|
||||
start: number,
|
||||
count: number,
|
||||
turn: number,
|
||||
publish = true,
|
||||
): UserMessage[] {
|
||||
const claimed = this.mutate(target, start, count, [], false)
|
||||
if (publish) {
|
||||
for (const message of claimed) this.notifications.claimed(message, turn)
|
||||
}
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one message to a pending list and durably record the insertion.
|
||||
* @param target - pending list to extend.
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx.sessionPersistence`, and the persistence listener that implements Session flushes. Static injection makes a missing persistence service a composition error. The plugin listens only to later `agent/created` events, installs on runtime roots, and registers all tools through the exact `agent.ctx`. Agents that already existed when the plugin loaded and runtime children do not receive Schedule.
|
||||
|
||||
Load `@deepseek-ai/dsh-time-context` before publishing a root that should resolve local `at` values without an explicit zone. The official Schedule Web overlay does so. Explicit-offset and explicit-zone values remain usable without an implicit-zone authority.
|
||||
Load `@deepseek-ai/dsh-time-context` before publishing a root that should resolve local `at` values without an explicit zone. The official Schedule Web overlay does so. Explicit-offset and explicit-zone values remain usable without implicit request-zone context.
|
||||
|
||||
Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation.
|
||||
|
||||
@@ -20,11 +20,11 @@ Replay rejects unknown versions, extra fields, reused ids, and delete or dispatc
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It returns `scheduleId`, prompt, and occurrence from the dispatch's nearest preceding same-id create; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches, while inherited dispatches search their persisted prefix; resumed ancestors therefore remain renderable, nested generations may reuse session-local ids, and presentation never changes live ownership.
|
||||
|
||||
## Absolute-time authority
|
||||
## Absolute-time context
|
||||
|
||||
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current step's final time-context authority reports one resolved client zone equal to the immutable Session zone.
|
||||
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open step has a time-context reading and the original user-rpc sources in that turn derive one client zone equal to the immutable Session zone.
|
||||
|
||||
The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. A headerless Session, a missing or mixed client authority, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`.
|
||||
The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. Schedule derives directly from those original owners rather than copying them into the time-context source. A headerless Session, a missing or mixed client-zone result, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`.
|
||||
|
||||
Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only the canonical UTC target, and no Schedule path reads the process time zone.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
请在 `ctx.sessions`、`ctx.agents`、`ctx.tools`、`ctx.sessionPersistence`,以及实现 Session flush 的持久化监听器之后加载此函数插件。静态注入会使缺少持久化服务的组合直接失败。此插件只监听后续的 `agent/created` 事件,在运行时根 agent 上安装,并通过完全相同的 `agent.ctx` 注册所有工具。插件加载时已经存在的 agent 与运行时子 agent 不会获得 Schedule。
|
||||
|
||||
若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式时区 authority 仍可使用。
|
||||
若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式请求时区上下文仍可使用。
|
||||
|
||||
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrence;client renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 live ownership。
|
||||
|
||||
## 绝对时间 authority
|
||||
## 绝对时间上下文
|
||||
|
||||
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前步骤最终的 time-context authority 给出唯一一个已解析的客户端时区,且该时区与不可变的 Session 时区相同时,才可以省略 `time_zone`。
|
||||
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open step 含有 time-context 读数,并且该 turn 的原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`。
|
||||
|
||||
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。如果 Session 没有 header、客户端 authority 缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`。
|
||||
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`。
|
||||
|
||||
落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC 目标,Schedule 的任何路径都不会读取进程时区。
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { decodeTimeContextSource } from '@deepseek-ai/dsh-time-context'
|
||||
import type { TimeContextAuthority } from '@deepseek-ai/dsh-time-context'
|
||||
import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
@@ -218,58 +217,47 @@ interface AtTimeZoneContext {
|
||||
readonly clientTimeZones: string[]
|
||||
}
|
||||
|
||||
/** Find the last time-context authority belonging to the currently open step. */
|
||||
function currentTimeContextAuthority(agent: Agent): TimeContextAuthority | undefined {
|
||||
/** Derive request zones only while the current open step contains a time-context reading. */
|
||||
function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveClientTimeZoneContext> | undefined {
|
||||
const events = agent.session.events
|
||||
let start = -1
|
||||
let stepStart = -1
|
||||
let turn = 0
|
||||
let step = 0
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index]
|
||||
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
|
||||
if (event === undefined) continue
|
||||
if (event.type === 'step/end') return undefined
|
||||
if (event.type === 'step/end' || event.type === 'turn/end') return undefined
|
||||
if (event.type === 'step/start') {
|
||||
start = index
|
||||
stepStart = index
|
||||
turn = event.data.turn
|
||||
step = event.data.step
|
||||
break
|
||||
}
|
||||
}
|
||||
if (start < 0) return undefined
|
||||
for (let index = events.length - 1; index > start; index--) {
|
||||
const event = events[index]
|
||||
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
|
||||
if (event === undefined || event.type !== 'user/message') continue
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'plugin' || source.plugin !== 'time-context') continue
|
||||
let decoded: ReturnType<typeof decodeTimeContextSource>
|
||||
try {
|
||||
decoded = decodeTimeContextSource(source)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (decoded.authority.turn === turn && decoded.authority.step === step) {
|
||||
return decoded.authority
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
if (stepStart < 0) return undefined
|
||||
const hasReading = events.slice(stepStart + 1).some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& Object.keys(event.data.source).length === 2)
|
||||
if (!hasReading) return undefined
|
||||
const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
|
||||
if (turnStart < 0) return undefined
|
||||
const messages = events.slice(turnStart + 1)
|
||||
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return deriveClientTimeZoneContext(messages)
|
||||
}
|
||||
|
||||
/** Resolve the only authority state that may supply an omitted local time zone. */
|
||||
/** Resolve the only request state that may supply an omitted local time zone. */
|
||||
function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
|
||||
const sessionTimeZone = agent.session.header.timeZone ?? 'unavailable'
|
||||
const authority = currentTimeContextAuthority(agent)
|
||||
const clientTimeZones = authority === undefined || authority.client.kind === 'missing'
|
||||
const client = currentClientTimeZoneContext(agent)
|
||||
const clientTimeZones = client === undefined || client.kind === 'missing'
|
||||
? []
|
||||
: authority.client.kind === 'resolved'
|
||||
? [authority.client.timeZone]
|
||||
: [...authority.client.timeZones]
|
||||
: client.kind === 'resolved'
|
||||
? [client.timeZone]
|
||||
: [...client.timeZones]
|
||||
const implicitTimeZone = sessionTimeZone !== 'unavailable'
|
||||
&& authority?.session.kind === 'resolved'
|
||||
&& authority.session.timeZone === sessionTimeZone
|
||||
&& authority.client.kind === 'resolved'
|
||||
&& authority.client.timeZone === sessionTimeZone
|
||||
&& client?.kind === 'resolved'
|
||||
&& client.timeZone === sessionTimeZone
|
||||
? sessionTimeZone
|
||||
: undefined
|
||||
return {
|
||||
@@ -279,14 +267,17 @@ function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate a contained input failure to the closed tool union. */
|
||||
/** Translate one contained input failure to the closed tool union. */
|
||||
function inputError(error: ScheduleInputError, timeZone?: AtTimeZoneContext): ScheduleToolError {
|
||||
if (error.code === 'timezone_confirmation_required') {
|
||||
// The domain emits this code only for the omitted-zone local-at arm,
|
||||
// whose request context is computed immediately before decoding.
|
||||
const requestTimeZone = timeZone as AtTimeZoneContext
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
sessionTimeZone: timeZone?.sessionTimeZone ?? 'unavailable',
|
||||
clientTimeZones: timeZone?.clientTimeZones ?? [],
|
||||
sessionTimeZone: requestTimeZone.sessionTimeZone,
|
||||
clientTimeZones: requestTimeZone.clientTimeZones,
|
||||
}
|
||||
}
|
||||
return { code: error.code, message: error.message }
|
||||
|
||||
@@ -91,21 +91,16 @@ function value(result: ToolExecutionResult): unknown {
|
||||
return result.value
|
||||
}
|
||||
|
||||
function appendTimeAuthority(
|
||||
agent: Agent,
|
||||
authority: {
|
||||
turn: number
|
||||
step: number
|
||||
session: { kind: 'resolved'; timeZone: string } | { kind: 'unavailable' }
|
||||
client:
|
||||
| { kind: 'resolved'; timeZone: string }
|
||||
| { kind: 'mixed'; timeZones: string[] }
|
||||
| { kind: 'missing' }
|
||||
},
|
||||
): void {
|
||||
function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): void {
|
||||
for (const [index, clientTimeZone] of clientTimeZones.entries()) {
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${index + 1}` }],
|
||||
source: { kind: 'user', clientTimeZone } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'time authority' }],
|
||||
source: { kind: 'plugin', plugin: 'time-context', authority },
|
||||
content: [{ type: 'text', text: 'time context' }],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -254,7 +249,7 @@ describe('Schedule tool protocol', () => {
|
||||
expect(changes[0]?.data).not.toHaveProperty('time_zone')
|
||||
})
|
||||
|
||||
it('fails closed when local at lacks confirmed request-zone authority', async () => {
|
||||
it('fails closed when local at lacks confirmed request-zone context', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'ambiguous', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
@@ -268,16 +263,11 @@ describe('Schedule tool protocol', () => {
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
|
||||
it('uses only the current-step matching zone authority for implicit local at', async () => {
|
||||
it('uses the current turn request zones behind a current-step time-context marker', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendTimeAuthority(test.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
@@ -291,12 +281,7 @@ describe('Schedule tool protocol', () => {
|
||||
const mismatch = await harness(true, 'Asia/Shanghai')
|
||||
mismatch.agent.session.append('turn/start', { turn: 1 })
|
||||
mismatch.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendTimeAuthority(mismatch.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'resolved', timeZone: 'America/New_York' },
|
||||
})
|
||||
appendRequestContext(mismatch.agent, ['America/New_York'])
|
||||
expect(value(await execute(mismatch, 'schedule_create', {
|
||||
prompt: 'mismatch', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toEqual({
|
||||
@@ -309,18 +294,7 @@ describe('Schedule tool protocol', () => {
|
||||
const mixed = await harness(true, 'Asia/Shanghai')
|
||||
mixed.agent.session.append('turn/start', { turn: 1 })
|
||||
mixed.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendTimeAuthority(mixed.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
appendTimeAuthority(mixed.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'] },
|
||||
})
|
||||
appendRequestContext(mixed.agent, ['Asia/Shanghai', 'America/New_York'])
|
||||
expect(value(await execute(mixed, 'schedule_create', {
|
||||
prompt: 'mixed', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
@@ -331,12 +305,7 @@ describe('Schedule tool protocol', () => {
|
||||
const unavailable = await harness()
|
||||
unavailable.agent.session.append('turn/start', { turn: 1 })
|
||||
unavailable.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendTimeAuthority(unavailable.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'unavailable' },
|
||||
client: { kind: 'resolved', timeZone: 'America/New_York' },
|
||||
})
|
||||
appendRequestContext(unavailable.agent, ['America/New_York'])
|
||||
expect(value(await execute(unavailable, 'schedule_create', {
|
||||
prompt: 'legacy', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
@@ -345,16 +314,11 @@ describe('Schedule tool protocol', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores prior-step authority and fails closed on a malformed current authority', async () => {
|
||||
it('requires a simple current-step marker and fails closed on a malformed source', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendTimeAuthority(test.agent, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 2 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
@@ -374,6 +338,41 @@ describe('Schedule tool protocol', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['step/end', 'turn/end'] as const)(
|
||||
'fails closed after the current %s boundary',
|
||||
async (boundary) => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
if (boundary === 'turn/end') {
|
||||
test.agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: `closed ${boundary}`,
|
||||
at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('fails closed when an open step has no owning turn boundary', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'missing turn', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns stable at validation errors after persistence preflight', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
|
||||
@@ -1007,10 +1007,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
// A stored headerless session is the one compatibility case: it remains
|
||||
// headerless even if a current caller supplied a zone for the live object.
|
||||
if (meta.timeZone !== undefined && meta.timeZone !== session.header.timeZone) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted with a different timeZone (persisted: ${meta.timeZone}, live: ${String(session.header.timeZone)}) (id collision)`)
|
||||
if (meta.timeZone !== session.header.timeZone) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted with a different timeZone (persisted: ${String(meta.timeZone)}, live: ${String(session.header.timeZone)}) (id collision)`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -937,7 +937,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('stored-prefix adoption keeps a headerless record headerless for a zoned live session', async () => {
|
||||
it('stored-prefix adoption rejects a zoned live session for a headerless record', async () => {
|
||||
const fix = await makeFixture()
|
||||
const log = [
|
||||
...oneTurnLog(),
|
||||
@@ -962,8 +962,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
})
|
||||
const second = await fix.mount(ctx)
|
||||
try {
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -1167,7 +1166,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('a zoned live session claims headerless ownerless state without backfilling it', async () => {
|
||||
it('a zoned live session cannot claim headerless ownerless state', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
@@ -1177,8 +1176,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
|
||||
Reference in New Issue
Block a user