Merge latest master into worktree-fast-gates

This commit is contained in:
Tianyi Cui
2026-07-17 20:39:05 +08:00
17 changed files with 604 additions and 341 deletions

View File

@@ -913,19 +913,19 @@ Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system
## `@deepseek-ai/dsh-time-context`
Requires: `systemPrompt`
Requires: `agents`
```ts config-catalog
/** Request-time clock formatting and refresh policy. 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
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
refreshIntervalMs?: number
}
```
Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts)
Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts)
## `@deepseek-ai/dsh-tool-bash`

View File

@@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |

View File

@@ -219,7 +219,6 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_llm
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_system_prompt
pkg_tasks --> pkg_agent
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_session
@@ -470,7 +469,7 @@ flowchart TD
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |

View File

@@ -85,6 +85,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 |
| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 |
| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 |
| [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 |
### Simplification

View File

@@ -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-14-time-context-plugin.md: 105bf53550f087fdefb1e6fe0ec493f8628d3e18
2026-07-14-time-context-plugin.zh.md: 60e9004b1453e75e1bcd84870ad7f18d200a95d8
2026-07-14-time-context-plugin.md: aa24c6246718cfe0bb3ed63d0791cf890514d9fe
2026-07-14-time-context-plugin.zh.md: e939ff1d0cb250f3fa7a7db34b50d92760e3374d

View File

@@ -6,6 +6,8 @@ English | [中文](2026-07-14-time-context-plugin.zh.md)
## Problem
The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract.
An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message.
Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle.

View File

@@ -6,13 +6,15 @@ Status: implemented
## 问题
本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包package、分区时间格式和校验仍然保留后续 RFC 负责当前的模型可见与持久性契约。
如果部署方既未在提示词中提供时钟也未给模型提供查询工具agent智能体请求就无法获得实时准确的时间。静态文本会变得陈旧而对于日期、截止时间或闲置时长等常规推理调用工具会增加开销。缺少已经过去的时长时模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。
提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。
## 决策
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。
@@ -46,7 +48,7 @@ agent loop智能体循环会在发送前通过 `request/header` 和 `reque
- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`
- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。
- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。
- **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
- **将放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
## 后果

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 12d8191eb72b3fabd3164f13a77d9944631b906e
2026-07-16-durable-per-step-time-context.zh.md: 977ffb7276011ac9ae9b8a72a726bb23baba0a2d

View File

@@ -0,0 +1,70 @@
# RFC: Durable per-step time context
Status: implemented
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 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.
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, 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 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 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
An injected first-step reading is:
```text
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`.
An injected later-step reading is:
```text
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. 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; 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 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, 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 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 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.
- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings.
## Consequences
- 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.

View File

@@ -0,0 +1,70 @@
# RFC: 持久的逐步骤时间上下文
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``request/header-delta` 不包含时间上下文文本;请求重建从每个 `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 的持久追加边界而非客户端来源时间戳。

View File

@@ -1,10 +1,10 @@
# context/ — request-context extensions
Product plugins that add bounded model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in.
Product plugins that add model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in.
| Package | Role | ctx key |
|---|---|---|
| `time-context/` | Current time and elapsed-time system-prompt context | (none) |
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-time-context
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.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
@@ -8,36 +8,51 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim
- id: time-context
name: '@deepseek-ai/dsh-time-context'
config:
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
refreshIntervalMs: 60000 # default; 0 refreshes on every step
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. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work.
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.
## Message baseline
`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.
The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time.
## Timing semantics
The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
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.
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.
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
### Temporal system prompt
### Preparation-time temporal context
**What the model sees**: Every request in an active turn includes the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; `<duration-or-unavailable>` is compact whole-second units or the first-turn fallback.
**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**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate.
**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.
#### Temporal context section
#### First step
```markdown
Current time: <timestamp>
Time since previous message: <duration-or-unavailable>.
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
#### Later steps
```markdown
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```
## Known Limitations and Deferred Work
- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed.
- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000.
- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp.
- **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** — 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.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-time-context",
"description": "Opt-in system-prompt context with the current time and elapsed time since the previous message",
"description": "Opt-in durable per-step context with the current time and elapsed time",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,7 +26,6 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {

View File

@@ -1,8 +1,6 @@
/**
* Opt-in request-time clock context. Active turns receive the current zoned
* time and elapsed time since the preceding model-visible message. The loop
* logs each rendered value as request-header state rather than 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
*/
@@ -10,77 +8,30 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { Message } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'time-context'
/** The system-prompt registry that owns the dynamic request section. */
export const inject = ['systemPrompt']
/** The agent registry that owns the pre-step lifecycle seam. */
export const inject = ['agents']
/** Request-time clock formatting and refresh policy. 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
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
/** 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 and defaults for {@link Config}. */
/** Schemastery validation for {@link Config}. */
export const Config: z<Config> = z.object({
timeZone: z.string(),
refreshIntervalMs: z.number().default(60_000),
refreshIntervalMs: z.number(),
})
interface OpenTurn {
turn: number
startSeq: number
}
/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */
interface RenderState {
turn: number
renderedAt: number
previousMessageTime: number | undefined
text: string
}
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
function openTurn(agent: Agent): OpenTurn | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'turn/end':
return undefined
case 'turn/start':
return { turn: event.data.turn, startSeq: event.seq }
default:
// Merge-extensible session events: only turn boundaries matter here.
break
}
}
return undefined
}
/** Find the latest model-visible timestamp strictly before one turn boundary. */
function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.seq >= turnStartSeq) continue
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
// Merge-extensible session events: non-surface records are not messages.
break
}
}
return undefined
}
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
const parts = Object.fromEntries(
@@ -107,31 +58,85 @@ function formatDuration(elapsedMs: number): string {
return parts.join(' ')
}
/** Find the latest model-visible event, excluding this plugin's pending append. */
function precedingMessageTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
// Merge-extensible session events: non-surface records are not messages.
break
}
}
return undefined
}
/** Find the preceding time-context event within the open turn. */
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
if (event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
function renderText(
now: number,
turn: number,
step: number,
previous: number | undefined,
formatter: Intl.DateTimeFormat,
timeZone: string,
): string {
const elapsed = previous === undefined
? 'unavailable (no earlier message in this session)'
: formatDuration(now - previous)
return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.`
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
const baseline = step === 1 ? 'model-visible message' : 'step context'
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register the request-time clock section for the lifetime of `ctx`.
* @param ctx - plugin context; the section registration is disposed with it.
* @param config - validated time zone and intra-turn refresh interval.
* @throws when the time zone or refresh interval is invalid.
* Register a prepended pre-step listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - time zone and durable refresh scheduling configuration.
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
*/
export function apply(ctx: Context, config: Config): void {
const timeZone = config.timeZone
const refreshIntervalMs = config.refreshIntervalMs as number
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`)
}
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
let formatter: Intl.DateTimeFormat
try {
formatter = new Intl.DateTimeFormat('en-US', {
@@ -152,32 +157,29 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(message, { cause: error })
}
const resolvedTimeZone = formatter.resolvedOptions().timeZone
const states = new WeakMap<Agent, RenderState>()
ctx.systemPrompt.section({
name: 'context:time',
order: 10,
text(context: AssembleContext): string {
const agent = context.agent
if (agent === undefined) return ''
const currentTurn = openTurn(agent)
if (currentTurn === undefined) return ''
const now = Date.now()
const prior = states.get(agent)
if (prior !== undefined
&& prior.turn === currentTurn.turn
&& now >= prior.renderedAt
&& now - prior.renderedAt < refreshIntervalMs) {
return prior.text
}
const previous = prior?.turn === currentTurn.turn
? prior.previousMessageTime
: previousMessageTime(agent, currentTurn.startSeq)
const text = renderText(now, previous, formatter, resolvedTimeZone)
states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text })
return text
},
})
ctx.on('agent/pre-step', (
agent: Agent,
turn: number,
step: number,
_fullSystemPrompt: string,
_sessionPrefix: readonly Message[],
signal: AbortSignal,
) => {
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)
agent.inject(
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
{ source: { kind: 'plugin', plugin: name } },
)
}, { prepend: true })
}

View File

@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
@@ -12,7 +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 = 'You said: "first". Try "echo <something>" to see a tool call.'
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
@@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => {
stdout += chunk
if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) {
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
sentSecond = true
proc.stdin.end('second\n')
}
@@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
}
describe('time-context through a real cordis.yml and stdio process', () => {
it('uses the process zone and persists both first-turn and elapsed-time request context', async () => {
it('uses the process zone and persists one ordered context event per request', async () => {
const { stdout, stderr } = await runTwoTurns()
expect(stderr).not.toContain('UNHANDLED')
expect(stdout).toContain('time-context e2e ready.')
expect(stdout).toContain(FIRST_REPLY)
expect(stdout).toContain('You said: "second".')
expect(stdout).toContain(SECOND_REPLY)
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
expect(logs).toHaveLength(1)
@@ -97,19 +98,29 @@ describe('time-context through a real cordis.yml and stdio process', () => {
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const firstHeader = events.find(event => event.type === 'request/header')
if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event')
expect(firstHeader.data.header.system).toMatch(
/Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
const contexts = events.filter(event => event.type === 'context/message')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
expect(contexts[index]!.surfaceOp).toBe('append')
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
}
const contextText = contexts.map(event => event.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n'))
expect(contextText[0]).toMatch(
/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(firstHeader.data.header.system).toContain(
'Time since previous message: unavailable (no earlier message in this session).',
expect(contextText[0]).toMatch(
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
)
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
const finalSystem = foldRequestHeader(events)?.system
expect(finalSystem).toContain('[Asia/Shanghai]')
expect(finalSystem).toMatch(
/Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
)
const headers = events.filter(event => event.type === 'request/header'
|| event.type === 'request/header-delta')
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
}, TEST_TIMEOUT_MS)
})

View File

@@ -3,9 +3,8 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -15,6 +14,7 @@ import type { Config } from '@deepseek-ai/dsh-time-context'
const BASE = Date.parse('2026-07-14T00:00:00.000Z')
const ORIGINAL_TIME_ZONE = process.env['TZ']
const SIGNAL = new AbortController().signal
beforeEach(() => {
process.env['TZ'] = 'UTC'
@@ -31,18 +31,29 @@ afterEach(() => {
async function mount(config: Config = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(timeContext, config)
return { ctx, fiber }
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return { id: AgentId(id), session } as unknown as Agent
}
async function sectionText(ctx: Context, agent?: Agent): Promise<string | undefined> {
const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent })
return assembly.sections.find(section => section.name === 'context:time')?.text
return {
id: AgentId(id),
options: {},
session,
status: 'running',
ctx: new Context(),
send() {},
steer() {},
inject(content, options) {
session.append('context/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn: number): void {
@@ -53,6 +64,28 @@ function openMessageTurn(session: Session, turn: number): void {
}, { surfaceOp: 'append' })
}
function contextTexts(session: Session): string[] {
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(
ctx: Context,
agent: Agent,
turn: number,
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal)
}
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
@@ -97,173 +130,179 @@ async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promi
return ctx
}
describe('temporal section rendering', () => {
it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => {
const { ctx } = await mount()
function requestText(request: GenerateOptions): string {
return request.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
describe('durable step context', () => {
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
const session = new Session(SessionId('first'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toBe(
'Current time: 2026-07-14T00:00:00+00:00[UTC]\n'
+ 'Time since previous message: unavailable (no earlier message in this session).',
)
})
it('renders a non-UTC numeric offset and all compact duration units', async () => {
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
const session = new Session(SessionId('offset'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'previous' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 90_061_000)
openMessageTurn(session, 2)
expect(await sectionText(ctx, sessionAgent(session))).toBe(
'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
+ 'Time since previous message: 1d 1h 1m 1s.',
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toEqual([
'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)
expect(event?.type).toBe('context/message')
if (event?.type !== 'context/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('unavailable'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding model-visible message: unavailable.',
)
})
it('clamps a backward wall-clock adjustment to a zero duration', async () => {
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)
await fire(ctx, agent, 3, 1)
vi.setSystemTime(BASE + 61_000)
await fire(ctx, agent, 3, 2)
expect(contextTexts(session)[1]).toBe(
'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.',
)
})
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('backward-duration'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'future by adjusted clock' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = new Session(SessionId('later-step-boundary'))
openMessageTurn(session, 4)
await fire(ctx, sessionAgent(session), 4, 2)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('later-step-exhausted'))
await fire(ctx, sessionAgent(session), 1, 2)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
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)
await fire(ctx, agent, 1, 1)
vi.setSystemTime(BASE - 5_000)
openMessageTurn(session, 2)
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.')
await fire(ctx, agent, 1, 2)
expect(contextTexts(session)).toHaveLength(2)
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
})
const previousMessageCases = [
['user/message', (session: Session): void => {
session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}],
['assistant/message', (session: Session): void => {
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
}],
['tool/result', (session: Session): void => {
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('previous'),
content: [{ type: 'text', text: 'r' }],
isError: false,
}, { surfaceOp: 'append' })
}],
['context/message', (session: Session): void => {
session.append('context/message', {
content: [{ type: 'text', text: 'c' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}],
['steering/message', (session: Session): void => {
session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 's' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}],
] as const
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')
it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => {
const { ctx } = await mount()
const session = new Session(SessionId(`previous-${_name}`))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendPrevious(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 5_000)
openMessageTurn(session, 2)
const resumed = new Session(SessionId('resumed'), [...original.events])
const resumedAgent = sessionAgent(resumed)
vi.setSystemTime(BASE + 999)
openMessageTurn(resumed, 2)
const beforeSkip = resumed.events.length
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.')
})
await fire(ctx, resumedAgent, 2, 1)
it('contributes empty text without an active agent turn', async () => {
const { ctx } = await mount()
expect(await sectionText(ctx)).toBe('')
expect(resumed.events).toHaveLength(beforeSkip)
expect(contextTexts(resumed)).toHaveLength(1)
const empty = sessionAgent(new Session(SessionId('empty')))
expect(await sectionText(ctx, empty)).toBe('')
const closedSession = new Session(SessionId('closed'))
openMessageTurn(closedSession, 1)
closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('')
})
})
describe('refresh policy', () => {
it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('interval'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 30_000)
expect(await sectionText(ctx, agent)).toBe(first)
vi.setSystemTime(BASE + 60_000)
const expired = await sectionText(ctx, agent)
expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]')
vi.setSystemTime(BASE + 59_000)
expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]')
})
it('refreshes every assembly when refreshIntervalMs is zero', async () => {
const { ctx } = await mount({ refreshIntervalMs: 0 })
const session = new Session(SessionId('every-step'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 1_000)
expect(await sectionText(ctx, agent)).not.toBe(first)
await fire(ctx, resumedAgent, 2, 2)
expect(contextTexts(resumed)).toHaveLength(2)
expect(contextTexts(resumed)[1]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
it('always refreshes for a new turn and keeps the preceding message baseline', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('turn-refresh'))
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'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 1_000)
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'done' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 2_000)
openMessageTurn(session, 2)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
})
const second = await sectionText(ctx, agent)
expect(second).not.toBe(first)
expect(second).toContain('Time since previous message: 1s.')
})
await fire(ctx, agent, 1, 1)
const abort = new AbortController()
abort.abort()
await fire(ctx, agent, 1, 2, abort.signal)
it('keeps refresh caches independent per agent', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const sessionA = new Session(SessionId('agent-a'))
const sessionB = new Session(SessionId('agent-b'))
const agentA = sessionAgent(sessionA, 'a')
const agentB = sessionAgent(sessionB, 'b')
openMessageTurn(sessionA, 1)
openMessageTurn(sessionB, 1)
const aFirst = await sectionText(ctx, agentA)
vi.setSystemTime(BASE + 30_000)
const bFirst = await sectionText(ctx, agentB)
vi.setSystemTime(BASE + 40_000)
expect(await sectionText(ctx, agentA)).toBe(aFirst)
expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]')
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})
@@ -275,49 +314,79 @@ describe('configuration and lifecycle', () => {
const session = new Session(SessionId('system-zone'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toContain(
'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]',
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]')
})
it('fails loud for an invalid explicit zone or an unavailable process zone', async () => {
const invalid = new Context()
await invalid.plugin(AgentRegistry)
await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
/invalid IANA timeZone/,
)
})
it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => {
for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/)
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/)
})
it('fails loud when the process system zone cannot be resolved', async () => {
vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
throw new RangeError('system zone unavailable')
})
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
const unresolved = new Context()
await unresolved.plugin(AgentRegistry)
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
})
it('removes its section when the plugin fiber disposes', async () => {
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'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
expect(await sectionText(ctx, agent)).toContain('Current time:')
await fire(ctx, agent, 1, 1)
await fiber.dispose()
expect(await sectionText(ctx, agent)).toBeUndefined()
await fire(ctx, agent, 1, 2)
expect(contextTexts(session)).toHaveLength(1)
})
})
describe('real agent-loop request logging', () => {
it('refreshes a long turn in the system prompt and records the header delta without context history', async () => {
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')])
const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 })
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)
ctx.tools.register(defineTool({
name: 'tick',
description: 'advance fake time',
@@ -331,38 +400,55 @@ describe('real agent-loop request logging', () => {
agent.send([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]')
expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]')
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1)
expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system)
vi.setSystemTime(BASE + 361_000)
agent.send([{ type: 'text', text: 'again' }])
await agent.whenIdle()
expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.')
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
}
expect(contexts.every(event => event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context'
&& event.surfaceOp === 'append')).toBe(true)
const firstRequestText = requestText(adapter.requests[0]!)
const secondRequestText = requestText(adapter.requests[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 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 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 sampled while preparing')
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0)
await ctx.fiber.dispose()
})
})
describe('real Loader export path', () => {
it('keeps the namespace metadata and boots through unwrapExports', async () => {
it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => {
expect('default' in timeContext).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
expect(unwrapped).toBe(timeContext)
expect(unwrapped.name).toBe('time-context')
expect(unwrapped.inject).toEqual(['systemPrompt'])
expect(unwrapped.inject).toEqual(['agents'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
await ctx.plugin(plugin)
const session = new Session(SessionId('loader'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:')
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
})
})

View File

@@ -9,7 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/system-prompt" },
{ "path": "../../llm/llm" },
{ "path": "../../core/agent" }
]
}