feat(llm): add replay token metering (PR2 round 1)

This commit is contained in:
Hypatia May
2026-07-15 14:47:29 +08:00
parent c9efdf68f9
commit f038780ff6
61 changed files with 3393 additions and 2369 deletions

View File

@@ -32,6 +32,7 @@ Session log (per session):
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
Agent status (per agent):

View File

@@ -118,8 +118,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
}
}
if (se.sourceEventSeqs !== undefined) {
if (se.sourceEventSeqs.length === 0) {
throw new InvariantError('sourceEventSeqs must not be empty when present')
if (se.sourceEventSeqs.length === 0 && event.type !== 'assistant/message') {
throw new InvariantError('sourceEventSeqs must not be empty except on assistant/message')
}
const unique = new Set(se.sourceEventSeqs)
if (unique.size !== se.sourceEventSeqs.length) {

View File

@@ -487,13 +487,17 @@ describe('surface invariants', () => {
// no throw — well-formed replace op
})
it('rejects empty sourceEventSeqs', async () => {
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] })
}).toThrow(InvariantError)
}).not.toThrow()
expect(() => {
session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] })
}).toThrow(/must not be empty except on assistant\/message/)
})
it('rejects duplicate sourceEventSeqs', async () => {