# Agent Note: 持久的逐步骤时间上下文

Status: implemented

[English](2026-07-16-durable-per-step-time-context.md) | 中文

## 问题

仅存在于请求中的时钟可以告诉模型当前时间，但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中，请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求，自动压缩（compaction）也必须核算模型实际收到的同一份时间上下文。

进程本地刷新缓存使显示的时间依赖无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率，而不引入这种隐藏状态。

## 决策

`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器，并在需要注入时，为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据；受间隔抑制的尝试不会追加任何内容。

监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册，因此先于普通自动压缩监听器运行，使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败；持久日志仅追加，且本插件不执行回滚，因此该读数会保留下来。

省略可选配置 `timeZone` 时，插件在加载时解析一次 Node 进程的 IANA 时区；显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。

插件在加载时手动校验可选配置 `refreshIntervalMs`，其值必须为非负安全整数。省略或设为 `0` 时，每次符合条件的准备尝试都会注入。设为正数时，插件扫描原始会话事件，查找来源属于本插件的最新 `context/message`；不存在此类事件、系统挂钟向后移动，或该事件已达到配置时长时，插件执行注入。即使压缩已隐藏消息，调度仍以原始事件时间戳为准，因此该机制无需计时器或进程本地缓存，也能跨轮次和进程恢复持续生效。

### 文本与时长基线

第一个步骤的注入读数为：

```text
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```

基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering（中途引导）消息。对于普通消息轮次，这包括开启轮次的已接受提示词。如果不存在模型可见消息，时长为 `unavailable`。

后续步骤的注入读数为：

```text
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```

其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数，时长为 `unavailable`。时长采用紧凑的整秒单位，并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后，仍可归属于对应的历史准备尝试。

### 持久性与请求重建

每个读数都作为普通表层节点保留，直至压缩将其隐藏；正数间隔调度绝不会移除已有读数。因此，后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数，而不是一个被原地改写的系统提示词值。

插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本；请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应，因为失败的准备过程可能留下读数，而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器，运行时不需要系统提示词服务。

## 测试

单元测试和真实 agent loop（智能体循环）测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose（资源释放）、来源与表层元数据、多步骤累计可见性，以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动，驱动两个轮次，并从外部校验持久化的上下文事件。

## 取代的决策

本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量；`refreshIntervalMs` 用于控制持久追加频率，而非提示词替换。

## 考虑过的替代方案

- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳，因为替换会抹去先前读数，缓存状态无法回放，而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
- **替换前一条上下文表层节点**——不予采纳，因为替换会保留旧节点的位置或隐藏中间的会话内容；两者都不能表达新读数何时开始可见。
- **通过后台计时器注入**——不予采纳，因为空闲期间没有待处理请求消费该值，而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
- **只通过工具提供时间**——不予采纳，因为普通时间推理会产生本可避免的工具往返，也不能保证每个步骤之前都有读数。
- **使用 `agent/session-prefix`**——不予采纳，因为一个 loop 实例前缀无法表示不同的步骤时间戳，也不会累计具有历史归属的读数。

## 后果

- 省略 `refreshIntervalMs` 或设为 `0` 时，每次符合条件的准备尝试都会留下记录；正数间隔会减少追加频率和历史增长，同时使持久调度在恢复后继续生效。
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止，其中也包括后续取消或失败所留下的准备读数。
- 第一个步骤的时长通常从开启轮次的提示词起算，后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区，时长仍采用 harness 的持久追加边界而非客户端来源时间戳。
