Merge latest origin/master into compact-post-step-overflow-recovery
# Conflicts: # docs/agent-lifecycle.md # docs/architecture.md # docs/cookbook/extension-cookbook.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/rfc/INDEX.md # docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md # docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md # docs/rfc/implemented/feature/2026-07-07-session-prefix.md # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/config.ts # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/src/summarizer.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/compact/compact/README.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/src/stream.ts # packages/llm/llm-pi-ai/tests/convert.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/index.ts # packages/llm/llm/tests/service.spec.ts # scripts/gen-doc-graphs.ts
This commit is contained in:
@@ -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,29 @@
|
||||
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'
|
||||
|
||||
/** 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 +57,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 +156,27 @@ 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,
|
||||
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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user