Files
oh-my-pi/packages/agent/src/append-only-context.ts
T
can1357 80186e341c feat(agent): threaded intentTracing option through append-only context
- Exported `normalizeTools` so `AppendOnlyContext` uses the same tool normalization as the agent loop.
- Added `BuildOptions.intentTracing` to `build()`/`reset()`/`takeSnapshot()` so intent injection is consistent and included in the prefix fingerprint.
- Improved `#computeDigest` to cover tool_calls, tool_call_id, name, and id fields to catch in-place mutations.
- Fixed `#unsubscribeAppendOnly` leak and added no-op guard in `#syncAppendOnlyContext`.
2026-05-25 14:06:44 +02:00

298 lines
8.6 KiB
TypeScript

/**
* Append-only context mode — stabilizes the byte prefix sent to the LLM
* across turns so provider prefix caches (DeepSeek, Anthropic, etc.)
* hit at the maximum possible rate.
*
* Two mechanisms:
*
* 1. **StablePrefix** — system prompt + tool specs are computed once
* and frozen. Subsequent turns reuse the exact same byte sequence
* unless `invalidate()` is called (e.g. after MCP reconnect).
*
* 2. **AppendOnlyLog** — messages only grow; prior turns are never
* re-serialized. Combined with a stable prefix, only the user's new
* message delta is a cache miss each turn.
*/
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
import { normalizeTools } from "./agent-loop";
import type { AgentContext } from "./types";
// ---------------------------------------------------------------------------
// StablePrefix (formerly ImmutablePrefix)
// ---------------------------------------------------------------------------
/** Frozen system prompt + tool spec snapshot. */
export interface StablePrefixSnapshot {
systemPrompt: string[];
tools: Tool[];
fingerprint: string;
}
/** Options threaded through `build()` so the snapshot reflects loop-time settings. */
export interface BuildOptions {
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
intentTracing: boolean;
}
/**
* A frozen prefix (system prompt + tools) that produces stable byte
* sequences across `build()` calls.
*
* The first `build()` snapshots the live state. Subsequent calls reuse
* the cached copy until `invalidate()` is called or the live state's
* fingerprint changes.
*/
export class StablePrefix {
#snapshot: StablePrefixSnapshot | null = null;
#version = 0;
get fingerprint(): string {
return this.#snapshot?.fingerprint ?? "<unbuilt>";
}
get version(): number {
return this.#version;
}
get built(): boolean {
return this.#snapshot !== null;
}
/**
* Build or rebuild from live context.
* Returns `true` if the prefix actually changed (cache miss imminent).
*/
build(context: AgentContext, options: BuildOptions): boolean {
const snapshot = takeSnapshot(context, options);
if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
return false;
}
this.#snapshot = snapshot;
this.#version++;
return true;
}
/** Force rebuild on the next `build()` call. */
invalidate(): void {
this.#snapshot = null;
}
/**
* Returns the cached prefix.
* @throws if `build()` was never called.
*/
toContext(): { systemPrompt: string[]; tools: Tool[] } {
const s = this.#snapshot;
if (!s) throw new Error("StablePrefix.toContext() called before build()");
return { systemPrompt: s.systemPrompt, tools: s.tools };
}
}
// ---------------------------------------------------------------------------
// AppendOnlyLog
// ---------------------------------------------------------------------------
/**
* Append-only message log at the `Message[]` (provider-level) layer.
*
* The only mutation path is `replaceTail()`, reserved for compaction.
* Every other operation is append-only.
*/
export class AppendOnlyLog {
#entries: Message[] = [];
get length(): number {
return this.#entries.length;
}
append(message: any): void {
this.#entries.push(message);
}
extend(messages: any[]): void {
for (const m of messages) this.#entries.push(m);
}
/** Replace the last entry — only legal for compaction. */
replaceTail(replacement: any): void {
const idx = this.#entries.length - 1;
if (idx >= 0) this.#entries[idx] = replacement;
}
/** Returns a shallow copy of all entries. */
toMessages(): Message[] {
return this.#entries.slice();
}
/** Direct readonly access for in-place inspection. */
entries(): readonly Message[] {
return this.#entries;
}
clear(): void {
this.#entries = [];
}
}
// ---------------------------------------------------------------------------
// AppendOnlyContextManager
// ---------------------------------------------------------------------------
/**
* Manages a stable prefix + append-only log for the agent loop.
*
* Call `build(context)` each turn to get a `Context` with stable
* `systemPrompt` and `tools` and append-only messages. Call
* `syncMessages(normalizedMessages)` after `convertToLlm` each
* turn to keep the log in sync.
*
* Example:
* ```
* const mgr = new AppendOnlyContextManager();
* const ctx = mgr.build(context); // first call snapshots prefix
* mgr.syncMessages(normalized); // grow the log
* ctx = mgr.build(context); // subsequent calls use cache
* ```
*/
export class AppendOnlyContextManager {
readonly prefix = new StablePrefix();
readonly log = new AppendOnlyLog();
/** How many normalized messages were synced into the log as of the last sync. */
#lastSyncCount = 0;
/** Rolling digest of synced message content — detects in-place rewrites. */
#syncedDigest = 0;
build(context: AgentContext, options: BuildOptions): Context {
this.prefix.build(context, options);
const { systemPrompt, tools } = this.prefix.toContext();
return { systemPrompt, messages: this.log.toMessages(), tools };
}
/**
* Sync normalized (provider-level) messages into the append-only log.
*
* Detects both compaction (shorter array) and in-place rewrites
* (same length, changed content via a rolling digest).
*/
syncMessages(normalizedMessages: any[]): void {
// Detect in-place rewrites of already-synced messages.
if (
this.#lastSyncCount > 0 &&
this.#lastSyncCount <= normalizedMessages.length &&
this.#computeDigest(normalizedMessages.slice(0, this.#lastSyncCount)) !== this.#syncedDigest
) {
this.log.clear();
this.#lastSyncCount = 0;
}
// Compaction — array shrunk.
if (normalizedMessages.length < this.#lastSyncCount) {
this.log.clear();
this.#lastSyncCount = 0;
}
const newMsgs = normalizedMessages.slice(this.#lastSyncCount);
for (const msg of newMsgs) {
this.log.append(msg);
}
this.#lastSyncCount = normalizedMessages.length;
this.#syncedDigest = this.#computeDigest(normalizedMessages);
}
/** Reset prefix + log for a model/provider switch while mode stays active. */
invalidateForModelChange(): void {
this.prefix.invalidate();
this.log.clear();
this.#lastSyncCount = 0;
this.#syncedDigest = 0;
}
/** Reset the sync cursor AND clear the log. */
resetSyncCursor(): void {
this.log.clear();
this.#lastSyncCount = 0;
this.#syncedDigest = 0;
}
appendMessage(message: any): void {
this.log.append(message);
}
replaceTailMessage(message: any): void {
this.log.replaceTail(message);
}
invalidate(): void {
this.prefix.invalidate();
}
reset(context: AgentContext, options: BuildOptions): void {
this.prefix.invalidate();
this.log.clear();
this.#lastSyncCount = 0;
this.#syncedDigest = 0;
this.prefix.build(context, options);
}
/**
* Deterministic digest over every field the provider may serialize — role,
* content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
* `tool_call_id`, `name`, `id`. Hashed with the same FNV-style rolling
* accumulator so in-place rewrites of *any* of these fields are visible.
*/
#computeDigest(messages: readonly unknown[]): number {
let hash = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const payload = JSON.stringify({
r: m.role ?? null,
c: m.content ?? null,
tc: m.toolCalls ?? m.tool_calls ?? null,
tcid: m.tool_call_id ?? null,
n: m.name ?? null,
id: m.id ?? null,
});
for (let j = 0; j < payload.length; j++) {
hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
}
}
return hash >>> 0;
}
}
// ---------------------------------------------------------------------------
// Snapshot helpers
// ---------------------------------------------------------------------------
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
const systemPrompt = [...context.systemPrompt];
const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
return {
systemPrompt,
tools,
fingerprint: computeFingerprint(systemPrompt, tools, options),
};
}
function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
const payload = JSON.stringify({
s: systemPrompt,
t: tools.map(t => ({
n: t.name,
d: t.description,
p: t.parameters,
s: t.strict,
cf: t.customFormat,
cw: t.customWireName,
})),
i: options.intentTracing,
});
let hash = 0;
for (let i = 0; i < payload.length; i++) {
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}