fix(schedule): reuse same-turn time context

This commit is contained in:
pku-xht
2026-08-06 20:32:06 +08:00
committed by Tianyi Cui
parent 32c6866adf
commit 6f2317e578
7 changed files with 33 additions and 18 deletions

View File

@@ -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. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. Positive intervals can let an entered step reuse prior history without a new reading.
On each non-empty entered batch that injects, one source-tagged context message contains the four lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. 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: <duration-or-unavailable>.
#### Token effect
Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every 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.

View File

@@ -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`.

View File

@@ -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`

View File

@@ -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<typeof deriveClientTimeZoneContext> | undefined {
const events = agent.session.events
let stepStart = -1
@@ -234,13 +234,13 @@ function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveCli
}
}
if (stepStart < 0) return undefined
const hasReading = events.slice(stepStart + 1).some(event => 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)

View File

@@ -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',
})
})