From 6f2317e5787a53b361a063b9f41cf1144fe67f34 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 6 Aug 2026 20:32:06 +0800 Subject: [PATCH] fix(schedule): reuse same-turn time context --- .../2026-08-05-durable-web-schedule.md | 4 ++-- .../2026-08-05-durable-web-schedule.zh.md | 4 ++-- packages/context/time-context/README.md | 8 +++---- packages/schedule/tool-schedule/README.md | 2 +- packages/schedule/tool-schedule/README.zh.md | 2 +- packages/schedule/tool-schedule/src/tools.ts | 8 +++---- .../tool-schedule/tests/tools.spec.ts | 23 +++++++++++++++---- 7 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index da0da9e1cc..128a6da3b6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -41,7 +41,7 @@ Every Web prompt samples its own `clientTimeZone`, which the Host validates befo Time-context delegates through `agent/pre-step`, derives the final non-empty entered batch's zones from the immutable Session header and message-bound browser sources, and appends one model-visible reading to that batch. Its source remains the simple plugin marker; it does not copy those facts into another durable authority. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives fresh context when that step enters. Rejection, an empty decision, cancellation, or failure before `step/start` records no reading, and this feature adds no inbox or AgentLoop lifecycle state. -Schedule requires a current-step time-context marker, then derives request zones directly from the open turn's original `user-rpc` sources. An implicit local `at` is accepted only when that derivation has one client zone equal to the Session zone. A headerless Session, missing or mixed client provenance, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones. An explicit `time_zone` bypasses that ambiguity check but still passes the same IANA validation. +Schedule requires a time-context marker in the current open turn, then derives request zones directly from that turn's original `user-rpc` sources. An implicit local `at` is accepted only when that derivation has one client zone equal to the Session zone. A headerless Session, missing or mixed client provenance, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones. An explicit `time_zone` bypasses that ambiguity check but still passes the same IANA validation. ### Absolute-time normalization @@ -107,7 +107,7 @@ The design does not recognize or migrate any unmerged Schedule implementation or Package tests pin strict decoding, transitions, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, gap rejection, overlap-first selection, mismatch confirmation, time bounds, bounded waits, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor, optional header round-trips, a real SQLite v13-to-v14 migration, and a production JSONL restart. The assembled Loader/Web restart lane proves pending recovery, fork isolation, one durable dispatch, cold-history rendering without Agent activation, and no redelivery after another restart. Host/client tests cover zone identity across live, stored, and concurrent-create paths; per-operation prompt provenance; commit gating; reversed watermarks; semantic header identity; per-event prefix matching; same-seq upgrades; every window merge exit; and reconnect generations. -Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, simple source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources and fail closed without a current-step marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the existing short `after` case and one absolute-time case, observe the identity-matched persisted prefix, and render the durable reminder card from attached history. The deliberately absent model adapter closes the turn with an error after dispatch, proving that model failure does not remove the receipt. +Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, simple source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources, reuse a same-turn marker across an empty continuation, and fail closed without an open-turn marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the existing short `after` case and one absolute-time case, observe the identity-matched persisted prefix, and render the durable reminder card from attached history. The deliberately absent model adapter closes the turn with an error after dispatch, proving that model failure does not remove the receipt. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 879f9b12fc..815f7e142b 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -41,7 +41,7 @@ Status: implemented Time-context 会委托 `agent/pre-step`,从不可变 Session header 和与消息绑定的浏览器来源为最终进入的非空批次派生时区,再向该批次追加一条模型可见读数。其来源仍是简单插件标记,不会把这些事实复制成另一份持久权威。AgentLoop 领取当前批次后才插入的 steering(中途引导)保留常规 next-step 归属,并在该步骤进入时获得新上下文。`step/start` 之前出现 reject、空决策、取消或失败时,不会记录读数;本功能也不增加 inbox 或 AgentLoop 生命周期状态。 -Schedule 要求当前步骤存在 time-context 标记,然后直接从 open turn 的原始 `user-rpc` 来源派生请求时区。只有派生结果包含一个与 Session 时区相等的 client 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 client/Session 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。 +Schedule 要求当前 open turn 中存在 time-context 标记,然后直接从该 turn 的原始 `user-rpc` 来源派生请求时区。只有派生结果包含一个与 Session 时区相等的 client 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 client/Session 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。 ### 绝对时间规范化 @@ -107,7 +107,7 @@ due → admission → followup → dispatch → flush(true) → session/flushed package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、offset 与 local-calendar profile、IANA 校验、gap 拒绝、overlap-first 选择、mismatch confirmation、时间边界、有界等待、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败、可选 header round-trip、一次真实 SQLite v13 到 v14 migration,以及 production JSONL restart。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 live、stored 与 concurrent-create 路径中的 zone identity、逐操作提示词 provenance、commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。 -Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、简单来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,并在缺少当前步骤标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。 +Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、简单来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,在空的续跑中复用同 turn 标记,并在缺少 open-turn 标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。 ## 后果 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 257c161f28..e430cf6350 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -11,7 +11,7 @@ Opt-in durable context with the current zoned time, immutable Session zone, requ name: '@deepseek-ai/dsh-time-context' config: timeZone: Asia/Shanghai # optional fallback for headerless Sessions; omit for the process zone - refreshIntervalMs: 60000 # optional; omit or set to 0 for every entered request step + refreshIntervalMs: 60000 # optional; omit or set to 0 for every non-empty entered request batch ``` When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats its readings. A headerless Session instead uses the configured fallback; when `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the fallback. An explicit `timeZone` is validated at plugin load but does not override a Session-owned zone. @@ -42,7 +42,7 @@ The time reading stays in derived conversation history until a later compaction #### What the model sees -On each entered step that injects, one source-tagged context message contains the four lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. Positive intervals can let an entered step reuse prior history without a new reading. +On each non-empty entered batch that injects, one source-tagged context message contains the four lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. An empty continuation or positive interval can let an entered step reuse prior history without a new reading. ##### First step @@ -64,7 +64,7 @@ Elapsed since the preceding step context: . #### Token effect -Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every entered request step. +Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every non-empty entered request batch. #### KV Cache effect @@ -76,4 +76,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp. - **Headerless fallback zone** — a Session without `SessionHeader.timeZone` renders through the configured or process fallback but reports its Session zone as `unavailable`; consumers that require unambiguous local-time interpretation must request an explicit zone. - **Immutable Session zone** — a Session zone does not change when another browser resumes it. The request-bound browser sources expose disagreement instead of silently changing the displayed default. -- **History cost between compactions** — omission or `0` retains one reading for every entered request step, including steps whose later request preparation fails; a positive interval reduces but does not eliminate this cost. +- **History cost between compactions** — omission or `0` retains one reading for every non-empty entered request batch, including batches whose later request preparation fails; empty continuations reuse prior history, while a positive interval reduces but does not eliminate this cost. diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 9a2c23013f..3e0a0cea98 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -22,7 +22,7 @@ Replay rejects unknown versions, extra fields, reused ids, and delete or dispatc ## Absolute-time context -The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open step has a time-context reading and the original user-rpc sources in that turn derive one client zone equal to the immutable Session zone. +The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open turn has a time-context reading and its original user-rpc sources derive one client zone equal to the immutable Session zone. The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. Schedule derives directly from those original owners rather than copying them into the time-context source. A headerless Session, a missing or mixed client-zone result, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`. diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index abc8037a8b..b08bad14d5 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -22,7 +22,7 @@ ## 绝对时间上下文 -`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open step 含有 time-context 读数,并且该 turn 的原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`。 +`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open turn 含有 time-context 读数,并且其原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`。 Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`。 diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index a703f7ff73..433f082403 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -217,7 +217,7 @@ interface AtTimeZoneContext { readonly clientTimeZones: string[] } -/** Derive request zones only while the current open step contains a time-context reading. */ +/** Derive request zones only while the current open turn contains a time-context reading. */ function currentClientTimeZoneContext(agent: Agent): ReturnType | undefined { const events = agent.session.events let stepStart = -1 @@ -234,13 +234,13 @@ function currentClientTimeZoneContext(agent: Agent): ReturnType event.type === 'user/message' + const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn) + if (turnStart < 0) return undefined + const hasReading = events.slice(turnStart + 1).some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context' && Object.keys(event.data.source).length === 2) if (!hasReading) return undefined - const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn) - if (turnStart < 0) return undefined const messages = events.slice(turnStart + 1) .flatMap(event => event.type === 'user/message' ? [event.data] : []) return deriveClientTimeZoneContext(messages) diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index f4c2b4b94c..8400feaa1e 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -261,6 +261,21 @@ describe('Schedule tool protocol', () => { }) expect(test.flushes.count).toBe(1) expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + + const unmarked = await harness(true, 'Asia/Shanghai') + unmarked.agent.session.append('turn/start', { turn: 1 }) + unmarked.agent.session.append('step/start', { turn: 1, step: 1 }) + unmarked.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'request without time reading' }], + source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + }), { surfaceOp: 'append' }) + expect(value(await execute(unmarked, 'schedule_create', { + prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' }, + }))).toMatchObject({ + code: 'timezone_confirmation_required', + sessionTimeZone: 'Asia/Shanghai', + clientTimeZones: [], + }) }) it('uses the current turn request zones behind a current-step time-context marker', async () => { @@ -314,7 +329,7 @@ describe('Schedule tool protocol', () => { }) }) - it('requires a simple current-step marker and fails closed on a malformed source', async () => { + it('reuses a simple same-turn marker across an empty continuation and ignores a malformed source', async () => { const test = await harness(true, 'Asia/Shanghai') test.agent.session.append('turn/start', { turn: 1 }) test.agent.session.append('step/start', { turn: 1, step: 1 }) @@ -331,10 +346,10 @@ describe('Schedule tool protocol', () => { }), { surfaceOp: 'append' }) expect(value(await execute(test, 'schedule_create', { - prompt: 'fail closed', at: { date: '2026-08-06', time: '09:00:00' }, + prompt: 'same-turn local', at: { date: '2026-08-06', time: '09:00:00' }, }))).toMatchObject({ - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], + kind: 'at', + scheduledAt: '2026-08-06T01:00:00.000Z', }) })