The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
70 lines
3.0 KiB
TypeScript
70 lines
3.0 KiB
TypeScript
/**
|
|
* The call configuration of a conversation and its comparison/freeze
|
|
* utilities. `LlmCallConfig` is the non-content third of the request header
|
|
* (see `EpochHeader` in dsh-session): everything about a request besides its
|
|
* message content that can undermine provider KV-cache reuse — `model`
|
|
* selects the cache namespace outright, and the sampling scalars are treated
|
|
* the same way out of caution. It is per-conversation state recorded in the
|
|
* session log (the reconstructability RFC), never a silently-drifting
|
|
* per-call knob: the `agent/request` waterfall proposes a replacement, and
|
|
* the loop logs a real change as a `request/header-delta` event.
|
|
*
|
|
* @module dsh-llm/call-config
|
|
*/
|
|
|
|
/**
|
|
* Model + sampling scalars of one conversation's requests. Every field maps
|
|
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
|
|
* from the logged header rather than accepting these per call.
|
|
*/
|
|
export interface LlmCallConfig {
|
|
model: string
|
|
temperature?: number
|
|
maxTokens?: number
|
|
stop?: string[]
|
|
}
|
|
|
|
/**
|
|
* Field-wise equality over {@link LlmCallConfig} — the comparison a caller
|
|
* runs to decide whether a proposed configuration is a real change (worth a
|
|
* logged header delta) or the held one restated.
|
|
* @param a - one configuration.
|
|
* @param b - the other.
|
|
* @returns whether every field (including the `stop` list, element-wise) matches.
|
|
*/
|
|
export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
|
|
if (a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false
|
|
if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop
|
|
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i])
|
|
}
|
|
|
|
/**
|
|
* Deep-freeze a value in place so any later mutation throws (ESM code runs in
|
|
* strict mode), and return it. The loop freezes every request it builds
|
|
* before dispatch — `llm/stream` listeners and adapters read the request,
|
|
* never rewrite it, so the wire bytes cannot silently desync from what the
|
|
* session log reconstructs. Guards against cycles with a WeakSet: loop-built
|
|
* requests hold `structuredClone`d JSON-validated session data, but the
|
|
* helper accepts arbitrarily constructed values. One exemption: an
|
|
* `AbortSignal` is never entered or frozen — it is the request's live
|
|
* cancellation channel, and freezing one breaks `AbortController.abort()`
|
|
* outright (Node stores the aborted flag as an own property of the signal).
|
|
* @param value - the value to freeze in place.
|
|
* @returns the same value, frozen.
|
|
*/
|
|
export function deepFreeze<T>(value: T): T {
|
|
const seen = new WeakSet<object>()
|
|
const walk = (node: unknown): void => {
|
|
if (node === null || typeof node !== 'object') return
|
|
if (node instanceof AbortSignal) return
|
|
if (seen.has(node)) return
|
|
seen.add(node)
|
|
Object.freeze(node)
|
|
for (const key of Object.keys(node)) {
|
|
walk((node as Record<string, unknown>)[key])
|
|
}
|
|
}
|
|
walk(value)
|
|
return value
|
|
}
|