refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions

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 packages/context/time-context/README.md
README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10
README.zh.md: 1133715ebb3348d6e3dbbf8bbef169d6d8c56d8f
README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8
README.zh.md: cd065f7112d42c6b949e279485c4b6b3663c2314

View File

@@ -16,21 +16,21 @@ Opt-in durable context with the current zoned time and elapsed time sampled duri
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. 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.
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-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.
## Timing semantics
The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it adds one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` and before ordinary automatic compaction with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed, rejected, or failed pre-step records nothing.
Positive-interval scheduling scans the raw durable session events for the latest `user/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.
A time reading records an entered pre-step batch, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, but a downstream pre-step listener that rejects or fails prevents it from being recorded.
The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its 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 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.
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.
## Model Experience

View File

@@ -16,21 +16,21 @@
省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。
`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,对每次信号尚未中止的合格步骤前尝试执行追加。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时执行追加。
`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且返回 enter 的合格 pre-step 添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。
## 时序语义
该插件会前置一个 `agent/step` 监听器。需要注入时,它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩(compaction)之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。
该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、reject 或失败的 pre-step 不会记录任何内容。
正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。
第 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。
时间读数记录的是一次请求准备尝试,不是已提交步骤或已传输请求。因为 listener 首先运行,后续的步骤前监听器取消或使该尝试失败时,该追加可能仍会保留。日志仅追加,该插件不执行回滚。
时间读数记录的是一个已进入步骤的 pre-step 批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游 pre-step 监听器 reject 或失败时,该读数不会被记录。
单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。
时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 处使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:失败的准备可能留下额外时间读数,而间隔抑制可让请求复用现有历史,无需添加时间读数。
时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。
## 模型体验

View File

@@ -1,26 +1,26 @@
/**
* Opt-in request-preparation clock context. Eligible pre-step attempts append
* durable, source-attributed time readings to conversation history.
* Opt-in request clock context. Eligible steps add durable,
* source-attributed time readings to the request history.
*
* @module @deepseek-ai/dsh-time-context
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'time-context'
/** The agent registry that owns the pre-step lifecycle seam. */
/** The agent registry that owns pre-step processing. */
export const inject = ['agents']
/** 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
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
refreshIntervalMs?: number
}
@@ -157,23 +157,33 @@ export function apply(ctx: Context, config: Config): void {
}
const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/step', (
ctx.on('agent/pre-step', async (
agent: Agent,
turn: number,
step: number,
signal: AbortSignal,
) => {
if (signal.aborted) return
_messages,
{ turn, step, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted) return decision
const now = Date.now()
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
const lastInjection = latestInjectionTime(agent)
if (lastInjection !== undefined
&& now >= lastInjection
&& now - lastInjection < refreshIntervalMs) return
&& now - lastInjection < refreshIntervalMs) return decision
}
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }), { surfaceOp: 'append' })
return {
kind: 'enter',
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
source: { kind: 'plugin', plugin: name },
}),
],
}
}, { prepend: true })
}

View File

@@ -18,22 +18,23 @@ export const name = 'time-context-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Derive the open step in which a time-context reading may append. */
/** Derive the next request boundary at which a time-context reading may append. */
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
for (const event of history.slice().reverse()) {
switch (event.type) {
case 'step/start':
return event.data
case 'step/end':
return { turn: event.data.turn, step: event.data.step + 1 }
case 'turn/start':
return { turn: event.data.turn, step: 1 }
case 'step/start':
case 'turn/end':
fail('time-context reading must be appended inside an open step')
fail('time-context reading must be appended at a prompt boundary')
break
default:
break
}
}
fail('time-context reading must be appended inside an open step')
fail('time-context reading must be appended at a prompt boundary')
}
/** Validate one plugin-attributed time reading against its session position and timestamp. */

View File

@@ -58,7 +58,6 @@ function preparing(turn: number, step: number): Session {
session.append('step/start', { turn, step: priorStep })
session.append('step/end', { turn, step: priorStep })
}
session.append('step/start', { turn, step })
return session
}
@@ -93,7 +92,6 @@ describe('time-context invariants', () => {
content: [{ type: 'text', text: 'prepare' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
appendReading(session, reading())
await ctx.plugin(InvariantService, { enabled: true })
@@ -109,7 +107,6 @@ describe('time-context invariants', () => {
content: [{ type: 'text', text: 'prepare' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
appendReading(session, reading('1', '2', 'step context'))
await ctx.plugin(InvariantService, { enabled: true })
@@ -129,17 +126,17 @@ describe('time-context invariants', () => {
const session = preparing(1, 2)
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
.toThrow(/inside an open step/)
.toThrow(/at a prompt boundary/)
})
it('rejects a reading outside an open step', async () => {
it('rejects a reading outside a prompt boundary', async () => {
const ctx = await setup()
const ended = preparing(1, 1)
ended.append('step/end', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/inside an open step/)
ended.append('step/start', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/)
expect(() => {
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/inside an open step/)
}).toThrow(/at a prompt boundary/)
})
it.each([

View File

@@ -40,7 +40,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running',
ctx: new Context(),
send: () => {},
@@ -79,7 +79,17 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
}
function textResponse(text: string): StreamChunk[] {
@@ -282,22 +292,17 @@ describe('durable step context', () => {
expect(contextTexts(independent)).toHaveLength(1)
})
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
it('skips an already-aborted prompt submission', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
await fire(ctx, agent, 1, 1)
const abort = new AbortController()
abort.abort()
await fire(ctx, agent, 1, 2, abort.signal)
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})
@@ -355,28 +360,24 @@ describe('configuration and lifecycle', () => {
describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('retains the durable preparation reading when a later step listener %s', async (mode, reasonKind) => {
['throws'],
['cancels'],
] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
ctx.on('agent/step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
return next()
})
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
await agent.whenIdle()
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(contextTexts(agent.session)).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(true)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
await ctx.fiber.dispose()
})
@@ -404,7 +405,7 @@ describe('real agent-loop request history', () => {
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).toBeGreaterThan(starts[index]!.seq)
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
}
expect(contexts.every(event => event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context'
@@ -413,7 +414,7 @@ describe('real agent-loop request history', () => {
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).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:')