mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
build-review round 1: add durable time refresh scheduling
This commit is contained in:
@@ -870,10 +870,12 @@ Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system
|
||||
Requires: `agents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Request-time clock formatting. Invalid values fail plugin load. */
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
||||
timeZone?: string
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-durable-per-step-time-context.md: eac975fd2a85d18d8323ba7651995516226ab887
|
||||
2026-07-16-durable-per-step-time-context.zh.md: fe38239729a00f71138ad3b37ce2c1d6f7895a60
|
||||
2026-07-16-durable-per-step-time-context.md: 12d8191eb72b3fabd3164f13a77d9944631b906e
|
||||
2026-07-16-durable-per-step-time-context.zh.md: 977ffb7276011ac9ae9b8a72a726bb23baba0a2d
|
||||
|
||||
@@ -6,55 +6,57 @@ English | [中文](2026-07-16-durable-per-step-time-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need each request to see its own reading and the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives.
|
||||
A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives.
|
||||
|
||||
Refresh intervals make the displayed time depend on process-local cache state rather than the durable session. They also let multiple steps share a reading even though each step is a distinct model request.
|
||||
A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and calls `agent.inject()` once for every step whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata.
|
||||
`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
|
||||
|
||||
The listener records context before the matching `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe the pending step's time context. The message then enters the history snapshot used by that step.
|
||||
The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback.
|
||||
|
||||
The plugin has one optional config key, `timeZone`. An omitted value resolves the Node process's IANA zone once at plugin load; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. There is no refresh interval or timer because every step records a reading.
|
||||
The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone.
|
||||
|
||||
The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
|
||||
|
||||
### Text and elapsed baselines
|
||||
|
||||
The first step in a turn receives:
|
||||
An injected first-step reading is:
|
||||
|
||||
```text
|
||||
Time recorded before turn <turn>, step 1: <timestamp>
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
|
||||
|
||||
Later steps receive:
|
||||
An injected later-step reading is:
|
||||
|
||||
```text
|
||||
Time recorded before turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration>.
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
Their baseline is the durable event timestamp of the preceding time-context message in the same turn. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading historically attributable after later turns append more context.
|
||||
Their baseline is the durable event timestamp of the preceding time-context message in the same turn. If interval suppression leaves no earlier same-turn reading, the duration is `unavailable`. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading attributable to its historical preparation attempt after later turns append more context.
|
||||
|
||||
### Durability and request reconstruction
|
||||
|
||||
Each reading remains a normal surface node until compaction shadows it. A later request therefore sees the cumulative unshadowed readings that affected earlier steps, rather than a system-prompt value rewritten in place.
|
||||
Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place.
|
||||
|
||||
The plugin contributes nothing to system-prompt assembly. `request/header` and `request/header-delta` contain no time-context text; request reconstruction obtains the reading from the durable surface prefix at the matching `step/start`. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime.
|
||||
The plugin contributes nothing to system-prompt assembly. `request/header` and `request/header-delta` contain no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because a failed preparation can leave a reading while interval suppression can prepare a request without appending one. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit and real-loop tests pin formatting, both elapsed baselines, backward-clock clamping, time-zone validation, aborted-signal behavior, listener disposal, source and surface metadata, ordering before `step/start` and ordinary pre-step listeners, exactly one event per transmitted request, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally.
|
||||
Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally.
|
||||
|
||||
## Supersedes
|
||||
|
||||
This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable per-step history replaces the `context:time` prompt section, refresh cache, `refreshIntervalMs`, and request-header deltas.
|
||||
This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the dynamic system-prompt section and refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance.
|
||||
- **Keep the dynamic system-prompt section and process-local refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance.
|
||||
- **Replace the preceding context surface node** — rejected because replacement preserves the old node's position or shadows intervening conversation; neither represents when the new reading became visible.
|
||||
- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing.
|
||||
- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step.
|
||||
@@ -62,7 +64,7 @@ This decision supersedes the dynamic system-prompt storage and refresh policy in
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every opted-in model request receives a fresh, reconstructable time reading before the step opens.
|
||||
- Timing context grows by one two-line message per step until compaction shadows older surface nodes; historical truth costs more tokens than a replace-in-place system section.
|
||||
- Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume.
|
||||
- Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure.
|
||||
- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context.
|
||||
- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps.
|
||||
|
||||
@@ -6,55 +6,57 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,每个请求既需要看到自己的读数,也需要看到影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。
|
||||
仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。
|
||||
|
||||
刷新间隔使显示的时间取决于进程本地缓存状态,而不是持久会话。它还允许多个步骤共用同一个读数,即使每个步骤对应不同的模型请求。
|
||||
进程本地刷新缓存使显示的时间依赖无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并为信号尚未取消的每个步骤调用一次 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据。
|
||||
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
|
||||
|
||||
监听器在匹配的 `step/start` 之前记录上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到待执行步骤的时间上下文。随后,该消息进入该步骤使用的历史快照。
|
||||
监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。
|
||||
|
||||
插件只有一个可选配置键 `timeZone`。省略时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。由于每个步骤都会记录读数,因此插件没有刷新间隔或计时器。
|
||||
省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。
|
||||
|
||||
插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
|
||||
|
||||
### 文本与时长基线
|
||||
|
||||
轮次中的第一个步骤收到:
|
||||
第一个步骤的注入读数为:
|
||||
|
||||
```text
|
||||
Time recorded before turn <turn>, step 1: <timestamp>
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
|
||||
|
||||
后续步骤收到:
|
||||
后续步骤的注入读数为:
|
||||
|
||||
```text
|
||||
Time recorded before turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration>.
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后仍可按历史归属。
|
||||
其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数,时长为 `unavailable`。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后,仍可归属于对应的历史准备尝试。
|
||||
|
||||
### 持久性与请求重建
|
||||
|
||||
每个读数都作为普通表层节点保留,直至压缩将其隐藏。因此,后续请求会看到影响先前步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。
|
||||
每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。
|
||||
|
||||
插件不向系统提示词组装贡献任何内容。`request/header` 和 `request/header-delta` 不包含时间上下文文本;请求重建从匹配 `step/start` 时的持久表层前缀取得读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。
|
||||
插件不向系统提示词组装贡献任何内容。`request/header` 和 `request/header-delta` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为失败的准备过程可能留下读数,而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、挂钟后退钳制、时区校验、已取消信号行为、监听器 dispose(资源释放)、来源与表层元数据、相对于 `step/start` 和普通预步骤监听器的顺序、每个已发送请求恰好一个事件、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
|
||||
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
|
||||
|
||||
## 取代的决策
|
||||
|
||||
本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久的逐步骤历史取代 `context:time` 提示词区段、刷新缓存、`refreshIntervalMs` 和请求头增量。
|
||||
本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留动态系统提示词区段和刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
|
||||
- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
|
||||
- **替换前一条上下文表层节点**——不予采纳,因为替换会保留旧节点的位置或隐藏中间的会话内容;两者都不能表达新读数何时开始可见。
|
||||
- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
|
||||
- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。
|
||||
@@ -62,7 +64,7 @@ Elapsed since the preceding step context: <duration>.
|
||||
|
||||
## 后果
|
||||
|
||||
- 选择加入的每个模型请求都会在步骤开始前获得新鲜且可重建的时间读数。
|
||||
- 在压缩隐藏旧表层节点之前,时间上下文会按每个步骤一条两行消息的速度增长;与原地替换的系统提示词区段相比,保持历史真实性会消耗更多 token。
|
||||
- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。
|
||||
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。
|
||||
- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
|
||||
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-time-context
|
||||
|
||||
Opt-in durable context with the current zoned time and elapsed time at every model step. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -9,38 +9,45 @@ Opt-in durable context with the current zoned time and elapsed time at every mod
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
```
|
||||
|
||||
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 zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load.
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends 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 prepends an `agent/pre-step` listener. Every non-aborted step appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`.
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
|
||||
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline reports `unavailable`.
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `context/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.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state, so the durable message plus the matching `step/start` reconstruct each request's reading.
|
||||
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. 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 a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Per-step temporal context
|
||||
### Preparation-time temporal context
|
||||
|
||||
**What the model sees**: Before each step, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units.
|
||||
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
|
||||
|
||||
**Token effect**: One two-line message accumulates per step until compaction shadows older history.
|
||||
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
|
||||
#### First step
|
||||
|
||||
```markdown
|
||||
Time recorded before turn <turn>, step 1: <timestamp>
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Later steps
|
||||
|
||||
```markdown
|
||||
Time recorded before turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration>.
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
@@ -48,4 +55,4 @@ Elapsed since the preceding step context: <duration>.
|
||||
- **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.
|
||||
- **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ.
|
||||
- **History cost between compactions** — one reading remains model-visible for every unshadowed step so prior timing claims stay historically truthful.
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Opt-in per-step clock context. Every pending model request receives a
|
||||
* durable, source-attributed time reading in conversation history.
|
||||
* Opt-in request-preparation clock context. Eligible pre-step attempts append
|
||||
* durable, source-attributed time readings to conversation history.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-time-context
|
||||
*/
|
||||
@@ -16,15 +16,18 @@ export const name = 'time-context'
|
||||
/** The agent registry that owns the pre-step lifecycle seam. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Request-time clock formatting. Invalid values fail plugin load. */
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
||||
timeZone?: string
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validation for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
timeZone: z.string(),
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
@@ -86,6 +89,18 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find this plugin's latest durable injection, including a shadowed surface event. */
|
||||
function latestInjectionTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function renderText(
|
||||
now: number,
|
||||
turn: number,
|
||||
@@ -96,18 +111,32 @@ function renderText(
|
||||
): string {
|
||||
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
||||
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
||||
return `Time recorded before turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
||||
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
if (refreshIntervalMs !== undefined && (
|
||||
!Number.isSafeInteger(refreshIntervalMs)
|
||||
|| refreshIntervalMs < 0
|
||||
)) {
|
||||
throw new TypeError(
|
||||
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a prepended pre-step listener for the lifetime of `ctx`.
|
||||
* @param ctx - plugin context; the listener is disposed with it.
|
||||
* @param config - validated time zone configuration.
|
||||
* @throws when the configured or process time zone cannot be resolved.
|
||||
* @param config - time zone and durable refresh scheduling configuration.
|
||||
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
let formatter: Intl.DateTimeFormat
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -139,6 +168,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
if (lastInjection !== undefined
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return
|
||||
}
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
|
||||
@@ -12,8 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time recorded before turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time recorded before turn 2, step 1:'
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -112,15 +112,15 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
.map(block => block.text)
|
||||
.join('\n'))
|
||||
expect(contextText[0]).toMatch(
|
||||
/Time recorded before turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
)
|
||||
expect(contextText[0]).toMatch(
|
||||
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||
)
|
||||
expect(contextText[1]).toMatch(/Time recorded before turn 2, step 1:/)
|
||||
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header'
|
||||
|| event.type === 'request/header-delta')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time recorded before')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -65,9 +65,15 @@ function openMessageTurn(session: Session, turn: number): void {
|
||||
}
|
||||
|
||||
function contextTexts(session: Session): string[] {
|
||||
return session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
const texts: string[] = []
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context') {
|
||||
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
async function fire(
|
||||
@@ -146,7 +152,7 @@ describe('durable step context', () => {
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([
|
||||
'Time recorded before turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
@@ -168,8 +174,11 @@ describe('durable step context', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the preceding durable step-context timestamp after step one', async () => {
|
||||
const { ctx } = await mount()
|
||||
it.each([
|
||||
['omitted interval', {}],
|
||||
['zero interval', { refreshIntervalMs: 0 }],
|
||||
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
|
||||
const { ctx } = await mount(config)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 3)
|
||||
@@ -179,7 +188,7 @@ describe('durable step context', () => {
|
||||
await fire(ctx, agent, 3, 2)
|
||||
|
||||
expect(contextTexts(session)[1]).toBe(
|
||||
'Time recorded before turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
|
||||
'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
|
||||
+ 'Elapsed since the preceding step context: 1m 1s.',
|
||||
)
|
||||
})
|
||||
@@ -207,8 +216,8 @@ describe('durable step context', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps backward wall-clock movement against the preceding context to zero', async () => {
|
||||
const { ctx } = await mount()
|
||||
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('backward'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
@@ -217,9 +226,70 @@ describe('durable step context', () => {
|
||||
|
||||
await fire(ctx, agent, 1, 2)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
|
||||
})
|
||||
|
||||
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const original = new Session(SessionId('seed-source'))
|
||||
openMessageTurn(original, 1)
|
||||
await fire(ctx, sessionAgent(original), 1, 1)
|
||||
const user = original.events.find(event => event.type === 'user/message')
|
||||
const reading = original.events.find(event => event.type === 'context/message')
|
||||
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
|
||||
original.append('context/message', {
|
||||
content: [{ type: 'text', text: 'compacted history' }],
|
||||
source: { kind: 'plugin', plugin: 'compact-basic' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
|
||||
sourceEventSeqs: [user.seq, reading.seq],
|
||||
})
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
|
||||
|
||||
const resumed = new Session(SessionId('resumed'), [...original.events])
|
||||
const resumedAgent = sessionAgent(resumed)
|
||||
vi.setSystemTime(BASE + 999)
|
||||
openMessageTurn(resumed, 2)
|
||||
const beforeSkip = resumed.events.length
|
||||
|
||||
await fire(ctx, resumedAgent, 2, 1)
|
||||
|
||||
expect(resumed.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(resumed)).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(BASE + 1_000)
|
||||
await fire(ctx, resumedAgent, 2, 2)
|
||||
|
||||
expect(contextTexts(resumed)).toHaveLength(2)
|
||||
expect(contextTexts(resumed)[1]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a positive interval across turns without sharing state between sessions', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const first = new Session(SessionId('interval-first'))
|
||||
const firstAgent = sessionAgent(first, 'first-agent')
|
||||
openMessageTurn(first, 1)
|
||||
await fire(ctx, firstAgent, 1, 1)
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
vi.setSystemTime(BASE + 500)
|
||||
openMessageTurn(first, 2)
|
||||
const beforeSkip = first.events.length
|
||||
await fire(ctx, firstAgent, 2, 1)
|
||||
|
||||
const independent = new Session(SessionId('interval-independent'))
|
||||
openMessageTurn(independent, 1)
|
||||
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
|
||||
|
||||
expect(first.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(first)).toHaveLength(1)
|
||||
expect(contextTexts(independent)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('ordering'))
|
||||
@@ -268,6 +338,15 @@ describe('configuration and lifecycle', () => {
|
||||
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system 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) {
|
||||
await expect(mount({ refreshIntervalMs })).rejects.toThrow(
|
||||
'time-context: refreshIntervalMs must be a non-negative safe integer',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes its listener when the plugin fiber disposes', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
const session = new Session(SessionId('dispose'))
|
||||
@@ -283,6 +362,32 @@ describe('configuration and lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('real agent-loop request history', () => {
|
||||
it.each([
|
||||
['throws', 'error'],
|
||||
['cancels', 'aborted'],
|
||||
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
let laterSawReading = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
laterSawReading = contextTexts(subject.session).length === 1
|
||||
if (mode === 'throws') throw new Error('later pre-step failure')
|
||||
subject.cancel('later pre-step cancellation')
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(laterSawReading).toBe(true)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
|
||||
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)
|
||||
@@ -314,17 +419,17 @@ describe('real agent-loop request history', () => {
|
||||
|
||||
const firstRequestText = requestText(adapter.requests[0]!)
|
||||
const secondRequestText = requestText(adapter.requests[1]!)
|
||||
expect(firstRequestText).toContain('Time recorded before turn 1, step 1:')
|
||||
expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.')
|
||||
expect(firstRequestText).not.toContain('Time recorded before turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time recorded before turn 1, step 1:')
|
||||
expect(secondRequestText).toContain('Time recorded before turn 1, step 2:')
|
||||
expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
|
||||
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time recorded before')
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header'
|
||||
|| event.type === 'request/header-delta')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time recorded before')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -348,6 +453,6 @@ describe('real Loader export path', () => {
|
||||
const session = new Session(SessionId('loader'))
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
expect(contextTexts(session)[0]).toContain('Time recorded before turn 1, step 1:')
|
||||
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user