fix(time-context): preserve empty pre-step decisions
This commit is contained in:
@@ -16,13 +16,13 @@ Opt-in durable context with the current zoned time, immutable Session zone, requ
|
||||
|
||||
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.
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every entered request step whose signal is not already aborted. A positive value adds it 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.
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every non-empty entered request batch whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step, time-context derives client zones from the decision's final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
|
||||
An entering step records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A first-step decision rewritten to empty opens no step and adds no reading. An empty tool continuation can still enter a later step and receives a reading.
|
||||
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
|
||||
|
||||
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.
|
||||
|
||||
@@ -34,7 +34,7 @@ A time reading records an entered request step, not a completed or successfully
|
||||
|
||||
The separately published `./invariant` companion checks the simple plugin source, open turn and step, elapsed baseline, and durable event time. It also re-derives Session and client zones from the Session header and current turn's original user-rpc messages, so duplicated source authority or mismatched rendered policy fails. The rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one.
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while an empty continuation or interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './request-zone.ts'
|
||||
|
||||
export type { ClientTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext } from './request-zone.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
@@ -241,7 +241,7 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
if (wasDisposed()) return next()
|
||||
const decision = await next()
|
||||
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|
||||
|| (step === 1 && decision.messages.length === 0)) {
|
||||
|| decision.messages.length === 0) {
|
||||
return decision
|
||||
}
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import * as timeContext from '@deepseek-ai/dsh-time-context'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
} from '@deepseek-ai/dsh-time-context'
|
||||
import { renderTimeZoneContext } from '../src/request-zone.ts'
|
||||
|
||||
function request(clientTimeZone?: unknown) {
|
||||
return createUserMessage({
|
||||
@@ -15,6 +16,11 @@ function request(clientTimeZone?: unknown) {
|
||||
}
|
||||
|
||||
describe('request-zone derivation', () => {
|
||||
it('publishes derivation without exposing the internal renderer', () => {
|
||||
expect(timeContext.deriveClientTimeZoneContext).toBe(deriveClientTimeZoneContext)
|
||||
expect('renderTimeZoneContext' in timeContext).toBe(false)
|
||||
})
|
||||
|
||||
it('derives missing, one resolved zone, and sorted unique mixed zones', () => {
|
||||
const plugin = createUserMessage({
|
||||
content: [],
|
||||
|
||||
@@ -547,6 +547,36 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not revive an empty continuation after a completed step', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/turn-stopping', (subject) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'pending context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
})
|
||||
ctx.on('agent/pre-step', async (_agent, _messages, context, next) => {
|
||||
const decision = await next()
|
||||
return context.step === 1 || decision.kind === 'reject'
|
||||
? decision
|
||||
: { kind: 'enter', messages: [] }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('finish once', 'Asia/Shanghai'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('preserves post-claim steering without persisting failed-turn context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('resumed')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
@@ -617,7 +647,7 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
|
||||
it('does not add a reading to an empty tool continuation and leaves system headers unchanged', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
@@ -638,11 +668,9 @@ describe('real agent-loop request history', () => {
|
||||
const contexts = agent.session.events.filter(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
|
||||
}
|
||||
expect(contexts[0]!.seq).toBeGreaterThan(starts[0]!.seq)
|
||||
expect(contexts.every(event => event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& event.surfaceOp === 'append')).toBe(true)
|
||||
@@ -653,8 +681,7 @@ describe('real agent-loop request history', () => {
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.')
|
||||
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.')
|
||||
expect(secondRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user