161 lines
6.4 KiB
JavaScript
161 lines
6.4 KiB
JavaScript
/**
|
|
* What one conversation got, and what to do with it.
|
|
*
|
|
* Two jobs, both pure:
|
|
*
|
|
* 1. **Derive** the small record a tool call leaves in the session log. The
|
|
* harness persists `output.presentationMeta` verbatim as the `meta` of the
|
|
* `tool/result` event, so this is the one place a result can state facts
|
|
* about itself without inventing an event type or a side channel.
|
|
* 2. **Fold** those records into a running total per conversation. The fold is
|
|
* the projection unit's transition function, which the framework requires to
|
|
* be pure and to return the *same reference* for events it does not care
|
|
* about — an unchanged reference costs nothing downstream, while a fresh
|
|
* object on every tool call would republish on every step.
|
|
*
|
|
* ## Why `sourceBytes` rather than a headline saving
|
|
*
|
|
* The honest comparison is between what the caller received and what reading
|
|
* those same files would have cost. The index already recorded every file's
|
|
* size when it embedded it, so the second figure costs nothing at query time.
|
|
*
|
|
* It is still an **upper bound**, and the numbers are labelled that way
|
|
* everywhere they surface: a careful agent might have read one of those four
|
|
* files rather than all four. A figure that flatters the tool is worse than no
|
|
* figure, because it stops being believed the first time somebody checks.
|
|
*/
|
|
|
|
/** State for a conversation that has never called the tool. */
|
|
export function emptyUsage() {
|
|
return {
|
|
version: 1,
|
|
searches: 0,
|
|
indexes: 0,
|
|
failed: 0,
|
|
excerpts: 0,
|
|
returnedBytes: 0,
|
|
sourceBytes: 0,
|
|
files: 0,
|
|
weak: 0,
|
|
// Bytes of avoided context multiplied by the steps that have re-read them.
|
|
// Accumulated here rather than derived, because deriving it needs a step
|
|
// count this state does not own — and the fold sees every step anyway.
|
|
rereadBytes: 0,
|
|
lastFocus: '',
|
|
}
|
|
}
|
|
|
|
/** The context a conversation is carrying that it would not have, in bytes. */
|
|
function avoidedBytesOf(state) {
|
|
return Math.max(0, state.sourceBytes - state.returnedBytes)
|
|
}
|
|
|
|
/**
|
|
* The record one call writes into the session log.
|
|
*
|
|
* Deliberately tiny: it is appended for every recall call and replayed on every
|
|
* projection rebuild, so it carries totals and never content.
|
|
*
|
|
* @param {{action?: string, query?: string}} args the validated call arguments
|
|
* @param {Record<string, any>} value the canonical result
|
|
* @returns {{recall: Record<string, any>}} the persisted metadata
|
|
*/
|
|
export function deriveUsage(args, value) {
|
|
const action = value?.action ?? args?.action ?? 'search'
|
|
const record = { action }
|
|
if (value?.ok === false) record.failed = 1
|
|
if (typeof value?.focus?.path === 'string') record.focus = value.focus.path
|
|
if (value?.weak === true) record.weak = 1
|
|
|
|
if (action === 'search' && Array.isArray(value?.results)) {
|
|
record.excerpts = value.results.length
|
|
record.returnedBytes = value.results.reduce(
|
|
(sum, result) => sum + Buffer.byteLength(String(result?.excerpt ?? ''), 'utf8'),
|
|
0,
|
|
)
|
|
}
|
|
if (action === 'index' && typeof value?.index?.embedded === 'number') {
|
|
record.embedded = value.index.embedded
|
|
}
|
|
if (typeof value?.measure?.sourceBytes === 'number') {
|
|
record.sourceBytes = value.measure.sourceBytes
|
|
record.files = value.measure.files
|
|
}
|
|
return { recall: record }
|
|
}
|
|
|
|
/**
|
|
* Fold one session event into the running total.
|
|
*
|
|
* @param {ReturnType<typeof emptyUsage>} state state covering all prior events
|
|
* @param {{type?: string, meta?: any}} event the next committed session event
|
|
* @returns the next state, or **the same reference** when not this unit's event
|
|
*/
|
|
export function foldUsage(state, event) {
|
|
// Every step re-reads whatever the conversation is already carrying, so each
|
|
// step adds the avoided-so-far to the compounding total. This is what makes
|
|
// the figure exact rather than an estimate the caller has to supply.
|
|
if (event?.type === 'step/start') {
|
|
const carried = avoidedBytesOf(state)
|
|
if (carried === 0) return state
|
|
return { ...state, rereadBytes: state.rereadBytes + carried }
|
|
}
|
|
|
|
const record = event?.type === 'tool/result' ? event.meta?.recall : undefined
|
|
if (record === undefined || record === null || typeof record !== 'object') return state
|
|
|
|
const next = {
|
|
...state,
|
|
searches: state.searches + (record.action === 'search' ? 1 : 0),
|
|
indexes: state.indexes + (record.action === 'index' ? 1 : 0),
|
|
failed: state.failed + (record.failed === 1 ? 1 : 0),
|
|
excerpts: state.excerpts + (record.excerpts ?? 0),
|
|
returnedBytes: state.returnedBytes + (record.returnedBytes ?? 0),
|
|
sourceBytes: state.sourceBytes + (record.sourceBytes ?? 0),
|
|
files: state.files + (record.files ?? 0),
|
|
weak: state.weak + (record.weak === 1 ? 1 : 0),
|
|
lastFocus: typeof record.focus === 'string' ? record.focus : state.lastFocus,
|
|
}
|
|
return next
|
|
}
|
|
|
|
/**
|
|
* The derived figures a surface shows, computed from state.
|
|
*
|
|
* Kept out of the state itself so the persisted fold stays a plain running
|
|
* total: anything derivable is derived on read, which is what lets the formula
|
|
* change without invalidating every checkpoint in the cache.
|
|
*
|
|
* @param {ReturnType<typeof emptyUsage>} state folded totals
|
|
* @param {{cacheReadPricePerMillion?: number}} [assumptions]
|
|
* @returns {Record<string, number|string|boolean>}
|
|
*/
|
|
export function usageSummary(state, assumptions = {}) {
|
|
const { cacheReadPricePerMillion = 0.007 } = assumptions
|
|
// What a read would have cost, less what the excerpt did cost.
|
|
const avoidedBytes = avoidedBytesOf(state)
|
|
// bytes/4 is an approximation of tokens; labelled as one wherever it is shown.
|
|
const avoidedTokens = Math.round(avoidedBytes / 4)
|
|
const compression = state.sourceBytes > 0 ? state.sourceBytes / Math.max(1, state.returnedBytes) : 0
|
|
// The compounding figure: context kept out, re-read once per step since.
|
|
const rereadTokens = Math.round((state.rereadBytes ?? 0) / 4)
|
|
const avoidedUsd = ((avoidedTokens + rereadTokens) / 1_000_000) * cacheReadPricePerMillion
|
|
|
|
return {
|
|
searches: state.searches,
|
|
indexes: state.indexes,
|
|
failed: state.failed,
|
|
excerpts: state.excerpts,
|
|
files: state.files,
|
|
weak: state.weak,
|
|
returnedBytes: state.returnedBytes,
|
|
sourceBytes: state.sourceBytes,
|
|
avoidedBytes,
|
|
avoidedTokens,
|
|
compression: Number(compression.toFixed(2)),
|
|
rereadTokens,
|
|
avoidedUsd: Number(avoidedUsd.toFixed(4)),
|
|
lastFocus: state.lastFocus,
|
|
}
|
|
}
|