refactor(chat): remove datetime context and implement per-message timestamp prefixing

This commit is contained in:
RainbowBird
2026-04-25 22:43:51 +08:00
parent 3566e8b4a8
commit 9db55c3496
9 changed files with 225 additions and 139 deletions
@@ -11,7 +11,6 @@ const llmStreamMock = vi.fn()
const trackFirstMessageMock = vi.fn()
const ingestContextMessageMock = vi.fn()
const getContextsSnapshotMock = vi.fn()
const createDatetimeContextMock = vi.fn()
const createMinecraftContextMock = vi.fn()
const persistSessionMessagesMock = vi.fn()
const forkSessionMock = vi.fn()
@@ -101,7 +100,6 @@ vi.mock('../composables/response-categoriser', () => ({
}))
vi.mock('./chat/context-providers', () => ({
createDatetimeContext: () => createDatetimeContextMock(),
createMinecraftContext: () => createMinecraftContextMock(),
}))
@@ -160,7 +158,6 @@ describe('chat orchestrator contract', () => {
trackFirstMessageMock.mockReset()
ingestContextMessageMock.mockReset()
getContextsSnapshotMock.mockReset()
createDatetimeContextMock.mockReset()
createMinecraftContextMock.mockReset()
createMinecraftContextMock.mockReturnValue(undefined)
persistSessionMessagesMock.mockReset()
@@ -180,26 +177,18 @@ describe('chat orchestrator contract', () => {
})
it('keeps hook order and composes context prompt after system message', async () => {
const datetimeContext = {
id: 'datetime',
contextId: 'datetime',
source: 'ReplaceSelf',
content: 'now',
createdAt: 123,
}
const contextsSnapshot = {
weather: [
'system:weather': [
{
id: 'weather',
contextId: 'weather',
contextId: 'system:weather',
source: 'ReplaceSelf',
content: 'sunny',
text: 'sunny',
createdAt: 456,
},
],
}
createDatetimeContextMock.mockReturnValue(datetimeContext)
getContextsSnapshotMock.mockReturnValue(contextsSnapshot)
let composedMessages: Message[] = []
@@ -249,7 +238,12 @@ describe('chat orchestrator contract', () => {
expect(store.sending).toBe(false)
expect(trackFirstMessageMock).toHaveBeenCalledTimes(1)
expect(ingestContextMessageMock).toHaveBeenCalledWith(datetimeContext)
// Datetime is no longer pushed through ingestContextMessage; it is now
// applied at message-assembly time as a system-prompt anchor + per-message
// [HH:MM] prefix. ingestContextMessage should still be called for other
// context providers (e.g. minecraft) when they are configured, but not
// for datetime in this test (minecraft is mocked to return undefined).
expect(ingestContextMessageMock).not.toHaveBeenCalled()
expect(persistSessionMessagesMock).not.toHaveBeenCalled()
expect(parserConsumeMock).toHaveBeenCalledWith('hello')
expect(parserEndMock).toHaveBeenCalledTimes(1)
@@ -268,13 +262,28 @@ describe('chat orchestrator contract', () => {
expect(composedMessages).toHaveLength(2)
expect(composedMessages[0]).toMatchObject({ role: 'system' })
expect(composedMessages[1]).toMatchObject({ role: 'user' })
const userMessageContent = (composedMessages[1] as any).content
expect(userMessageContent[0].text).toBe('hello from user')
// System message stays untouched: keeping it 100% static is what makes
// the prefix permanently KV-cache friendly across turns and across day
// boundaries (the date now lives inside per-message timestamp prefixes
// instead of a system anchor).
const systemContent = (composedMessages[0] as any).content
const systemText = typeof systemContent === 'string' ? systemContent : systemContent.map((p: any) => p.text).join('')
expect(systemText).toBe('system prompt')
// The user turn is prefixed with [YYYY-MM-DD HH:MM]. Both historic and
// current turns share the same shape so prefix-cache stays valid when a
// "current" turn becomes "historic" on the next send. Side-channel context
// (weather) is appended as a separate text part so providers don't see
// consecutive same-role messages.
const userMessageContent = (composedMessages[1] as any).content
expect(userMessageContent[0].text).toMatch(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] hello from user$/)
const syntheticContextText = userMessageContent[1].text
expect(syntheticContextText).toContain('<context>')
expect(syntheticContextText).toContain('<module name="weather">')
expect(syntheticContextText).not.toContain('<context>')
expect(syntheticContextText).not.toContain('<module ')
expect(syntheticContextText).toContain('[Context]')
expect(syntheticContextText).toContain('- system:weather: sunny')
})
it('rejects cancelled queued sends before they start', async () => {
+49 -5
View File
@@ -16,8 +16,9 @@ import { useLlmmarkerParser } from '../composables/llm-marker-parser'
import { categorizeResponse, createStreamingCategorizer } from '../composables/response-categoriser'
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
import { formatContextPromptText } from './chat/context-prompt'
import { createDatetimeContext, createMinecraftContext } from './chat/context-providers'
import { createMinecraftContext } from './chat/context-providers'
import { useChatContextStore } from './chat/context-store'
import { formatTimePrefix } from './chat/datetime-prefix'
import { createChatHooks } from './chat/hooks'
import { useChatSessionStore } from './chat/session-store'
import { useChatStreamStore } from './chat/stream-store'
@@ -27,6 +28,32 @@ import { useAiriCardStore } from './modules/airi-card'
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
import { useConsciousnessStore } from './modules/consciousness'
// Prepends a literal text fragment to a message's content. Handles both the
// shorthand string form and the array-of-parts form. When the first part is
// already text, it merges into that part to keep the part count stable for
// downstream consumers; otherwise it inserts a new text part at the front.
// Constraint is `content?: unknown` to admit both required-content roles
// (system/user) and optional-content roles (assistant); the generic preserves
// the caller's discriminated-union narrowing.
function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T {
const content = msg.content
if (content === undefined)
return { ...msg, content: text } as T
if (typeof content === 'string')
return { ...msg, content: `${text}${content}` } as T
if (Array.isArray(content)) {
const first = content[0] as { type?: string, text?: string } | undefined
if (first && first.type === 'text' && typeof first.text === 'string') {
const next = [{ ...first, text: `${text}${first.text}` }, ...content.slice(1)]
return { ...msg, content: next } as T
}
return { ...msg, content: [{ type: 'text', text }, ...content] } as T
}
return msg
}
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
try {
return structuredClone(message)
@@ -136,8 +163,10 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
chatSession.ensureSession(sessionId)
// Inject current datetime context before composing the message
chatContext.ingestContextMessage(createDatetimeContext())
// Datetime is no longer injected through the side-channel context store.
// It is applied at message-assembly time (see below) as a system-prompt
// date anchor + per-message [HH:MM] prefixes, which is more KV-cache
// friendly and less prone to weak models echoing timestamps verbatim.
const minecraftContext = createMinecraftContext()
if (minecraftContext)
chatContext.ingestContextMessage(minecraftContext)
@@ -302,13 +331,28 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
],
})
// Per-message datetime injection (replaces the old `<context>` XML block):
// every user/assistant message gets a `[YYYY-MM-DD HH:MM]` prefix
// derived from its persisted `createdAt`. The full date appears on every
// turn so the model can read "today" from the most recent message; the
// system prompt itself stays 100% static for permanent KV-cache reuse.
// Legacy entries without a persisted `createdAt` fall back to "now"
// rather than a fabricated older timestamp.
// See `./chat/datetime-prefix.ts` for the rationale.
const nowTs = Date.now()
const newMessages = sessionMessagesForSend.map((msg) => {
const { context: _context, id: _id, createdAt: _createdAt, ...withoutContext } = msg
const { context: _context, id: _id, createdAt, ...withoutContext } = msg
const rawMessage = toRaw(withoutContext)
const ts = createdAt ?? nowTs
if (rawMessage.role === 'user') {
return prependTextToContent(rawMessage, formatTimePrefix(ts))
}
if (rawMessage.role === 'assistant') {
const { slices: _slices, tool_results: _toolResults, categorization: _categorization, ...rest } = rawMessage as ChatAssistantMessage
return toRaw(rest)
return prependTextToContent(toRaw(rest), formatTimePrefix(ts))
}
return rawMessage
@@ -7,18 +7,18 @@ import { buildContextPromptMessage, formatContextPromptText } from './context-pr
function makeContext(overrides: Record<string, unknown> = {}): ContextSnapshot {
return {
'system:datetime': [
'system:minecraft-integration': [
{
id: 'volatile-random-id',
contextId: 'system:datetime',
contextId: 'system:minecraft-integration',
strategy: ContextUpdateStrategy.ReplaceSelf,
text: 'Current datetime: 2026-04-07T12:34:00.000Z',
text: 'Bot is online in forest biome',
createdAt: 1743940440000,
metadata: {
source: {
id: 'system:datetime',
id: 'system:minecraft-integration',
kind: 'plugin' as const,
plugin: { id: 'airi:system:datetime' },
plugin: { id: 'airi:minecraft' },
},
},
...overrides,
@@ -38,44 +38,42 @@ describe('formatContextPromptText', () => {
expect(text).not.toContain('volatile-random-id')
expect(text).not.toContain('1743940440000')
expect(text).not.toContain('airi:system:datetime')
expect(text).not.toContain('airi:minecraft')
})
// https://github.com/moeru-ai/airi/issues/1539
it('issue #1539: only includes text content in XML format', () => {
it('emits a flat [Context] bullet list (no XML wrapper)', () => {
const text = formatContextPromptText(makeContext())
expect(text).toContain('<context>')
expect(text).toContain('</context>')
expect(text).toContain('<module name="system:datetime">')
expect(text).toContain('Current datetime: 2026-04-07T12:34:00.000Z')
expect(text).not.toContain('<context>')
expect(text).not.toContain('<module')
expect(text.startsWith('[Context]')).toBe(true)
expect(text).toContain('- system:minecraft-integration: Bot is online in forest biome')
})
// https://github.com/moeru-ai/airi/issues/1539
it('issue #1539: produces identical output regardless of volatile fields', () => {
it('produces identical output regardless of volatile fields', () => {
const a = formatContextPromptText(makeContext({ id: 'aaa', createdAt: 1 }))
const b = formatContextPromptText(makeContext({ id: 'bbb', createdAt: 2 }))
expect(a).toBe(b)
})
it('formats multiple modules', () => {
it('formats multiple modules as bullets under one [Context] header', () => {
const snapshot: ContextSnapshot = {
'system:datetime': [
'system:minecraft-integration': [
{
id: 'a',
contextId: 'system:datetime',
contextId: 'system:minecraft-integration',
strategy: ContextUpdateStrategy.ReplaceSelf,
text: 'Current datetime: 2026-04-07T12:34:00.000Z',
text: 'Bot is online',
createdAt: 0,
},
],
'system:minecraft': [
'system:weather': [
{
id: 'b',
contextId: 'system:minecraft',
contextId: 'system:weather',
strategy: ContextUpdateStrategy.ReplaceSelf,
text: 'Bot is online',
text: 'Sunny, 22C',
createdAt: 0,
},
],
@@ -83,9 +81,10 @@ describe('formatContextPromptText', () => {
const text = formatContextPromptText(snapshot)
expect(text).toContain('<module name="system:datetime">')
expect(text).toContain('<module name="system:minecraft">')
expect(text).toContain('Bot is online')
const lines = text.split('\n')
expect(lines[0]).toBe('[Context]')
expect(lines).toContain('- system:minecraft-integration: Bot is online')
expect(lines).toContain('- system:weather: Sunny, 22C')
})
})
@@ -2,32 +2,45 @@ import type { UserMessage } from '@xsai/shared-chat'
import type { ContextMessage } from '../../types/chat'
import { toXml } from 'xast-util-to-xml'
import { x } from 'xastscript'
export type ContextSnapshot = Record<string, ContextMessage[]>
/**
* Build an xast tree from context snapshot.
* Only the `text` field is included — volatile metadata (random IDs,
* millisecond timestamps) is excluded to keep the output deterministic
* and friendly to LLM KV-cache prefix matching.
* See: https://github.com/moeru-ai/airi/issues/1539
* Render runtime context modules into a compact, readable text block.
*
* Use when:
* - Composing chat prompts that need to attach side-channel runtime context
* (e.g. game state, system status) to the latest user message.
*
* Expects:
* - A snapshot keyed by `contextId`. Only the per-message `text` field is
* included; volatile metadata (random IDs, ms timestamps) is excluded so
* the output stays deterministic and KV-cache-friendly.
*
* Returns:
* - Empty string when the snapshot is empty.
* - Otherwise a `[Context]` block with one bullet per module, e.g.
* `[Context]\n- system:minecraft-integration: Bot is online ...`
*
* Why this shape (not XML):
* - Weak local models (8B/14B) tend to mirror conspicuous structured
* wrappers (`<context>...</context>`) back into their replies, treating
* them as data to be quoted. A flat bullet list looks like ordinary
* narrative, which suppresses that mirroring tendency.
* - See: https://github.com/moeru-ai/airi/issues/1539
*/
function buildContextTree(contextsSnapshot: ContextSnapshot) {
const modules = Object.entries(contextsSnapshot).map(([key, messages]) =>
x('module', { name: key }, messages.map(m => x(null, m.text))),
)
return x('context', modules)
}
export function formatContextPromptText(contextsSnapshot: ContextSnapshot) {
const entries = Object.entries(contextsSnapshot)
if (entries.length === 0)
return ''
return toXml(buildContextTree(contextsSnapshot))
const lines = entries.flatMap(([contextId, messages]) =>
messages.map(m => `- ${contextId}: ${m.text}`),
)
if (lines.length === 0)
return ''
return ['[Context]', ...lines].join('\n')
}
export function buildContextPromptMessage(contextsSnapshot: ContextSnapshot): UserMessage | null {
@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createDatetimeContext } from './datetime'
describe('createDatetimeContext', () => {
it('returns a context message with datetime text', () => {
const ctx = createDatetimeContext()
expect(ctx.contextId).toBe('system:datetime')
expect(ctx.text).toContain('Current datetime:')
expect(ctx.strategy).toBe('replace-self')
})
it('includes ISO string in text', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-04-07T12:34:56.789Z'))
const ctx = createDatetimeContext()
expect(ctx.text).toContain('2026-04-07T12:34:56.789Z')
}
finally {
vi.useRealTimers()
}
})
it('produces different text at different times', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-04-07T12:34:00.000Z'))
const a = createDatetimeContext()
vi.setSystemTime(new Date('2026-04-07T12:35:00.000Z'))
const b = createDatetimeContext()
expect(a.text).not.toBe(b.text)
}
finally {
vi.useRealTimers()
}
})
})
@@ -1,31 +0,0 @@
import type { ContextMessage } from '../../../types/chat'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { nanoid } from 'nanoid'
const DATETIME_CONTEXT_ID = 'system:datetime'
/**
* Creates a context message containing the current datetime information.
* This context is injected before each chat message to provide temporal awareness.
*/
export function createDatetimeContext(): ContextMessage {
const now = new Date()
return {
id: nanoid(),
contextId: DATETIME_CONTEXT_ID,
strategy: ContextUpdateStrategy.ReplaceSelf,
text: `Current datetime: ${now.toISOString()} (${now.toLocaleString()})`,
createdAt: Date.now(),
metadata: {
source: {
id: DATETIME_CONTEXT_ID,
kind: 'plugin',
plugin: {
id: 'airi:system:datetime',
},
},
},
}
}
@@ -1,2 +1 @@
export { createDatetimeContext } from './datetime'
export { createMinecraftContext } from './minecraft'
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { formatTimePrefix } from './datetime-prefix'
describe('formatTimePrefix', () => {
it('wraps `[YYYY-MM-DD HH:MM]` with trailing space', () => {
const ts = new Date(2026, 3, 25, 18, 47, 0).getTime()
expect(formatTimePrefix(ts)).toBe('[2026-04-25 18:47] ')
})
it('zero-pads month, day, hour, minute', () => {
const ts = new Date(2026, 0, 5, 3, 7, 0).getTime() // 5 January 2026, 03:07 local
expect(formatTimePrefix(ts)).toBe('[2026-01-05 03:07] ')
})
it('produces stable output for the same input (cache-friendly)', () => {
const ts = new Date(2026, 3, 25, 18, 47, 0).getTime()
expect(formatTimePrefix(ts)).toBe(formatTimePrefix(ts))
})
it('produces different output across day boundaries (lets the model see day changes)', () => {
const day1 = new Date(2026, 3, 25, 12, 0, 0).getTime()
const day2 = new Date(2026, 3, 26, 12, 0, 0).getTime()
expect(formatTimePrefix(day1)).not.toBe(formatTimePrefix(day2))
expect(formatTimePrefix(day1)).toContain('2026-04-25')
expect(formatTimePrefix(day2)).toContain('2026-04-26')
})
it('shares the same prefix across timestamps in the same minute (KV-cache stable)', () => {
const a = new Date(2026, 3, 25, 18, 47, 12).getTime()
const b = new Date(2026, 3, 25, 18, 47, 58).getTime()
expect(formatTimePrefix(a)).toBe(formatTimePrefix(b))
})
})
@@ -0,0 +1,61 @@
/**
* Per-message timestamp prefix.
*
* Replaces the old `<context><module name="system:datetime">...</module></context>`
* block (which weak local models tended to mirror back into replies and which
* invalidated KV-cache prefixes on every send).
*
* Strategy:
* - Each user/assistant message is prefixed with `[YYYY-MM-DD HH:MM]` derived
* from its persisted `createdAt`. Stored timestamps never change, so the
* prefixed history stays byte-stable across turns and accumulates KV-cache
* prefix matches.
* - The full date is included on every message so the model can infer "today"
* from the most recent message — there is no separate system-prompt date
* anchor, which keeps the system prompt 100% static and permanently
* cacheable across turns and across day boundaries.
*
* Format choice:
* - `[YYYY-MM-DD HH:MM]` is ISO-like, structurally compact (~17 chars), and
* sits in a region of the training distribution where bracketed datetime
* prefixes occur naturally (chat logs, IRC, syslog), which suppresses the
* "echo it back as data" tendency of weak local models.
* - `Date.toString()` (e.g. `Sat Apr 25 2026 18:47:00 GMT+0800 (China Standard
* Time)`) is avoided: too long, trailing locale parens carry no useful
* signal, and the format clusters in log/debug-output training data which
* correlates with verbatim copy-back.
*/
const DATE_TIME = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
/**
* Formats a timestamp as `[YYYY-MM-DD HH:MM] ` in the user's local timezone.
*
* Use when:
* - Annotating user/assistant messages so the model has a concrete time
* anchor on every turn — historic and current alike use the same shape so
* that prefix-cache stays valid when a "current" turn becomes "historic" on
* the next send.
*
* Returns:
* - String including a trailing space, e.g. `"[2026-04-25 18:47] "`.
*
* Before:
* - createdAt = 1745570820000 (a Unix ms in Asia/Shanghai)
*
* After:
* - "[2026-04-25 18:47] "
*/
export function formatTimePrefix(createdAt: number): string {
// Intl en-CA locale uses ISO-style `YYYY-MM-DD, HH:MM`. Strip the comma to
// produce the bracketed `YYYY-MM-DD HH:MM` form.
const formatted = DATE_TIME.format(new Date(createdAt)).replace(', ', ' ')
return `[${formatted}] `
}