Files
oh-my-pi/packages/coding-agent/src/session/session-context.ts
T
can1357 b137764f0c feat: implemented text-first foveated snapcompact archive layout
- Implemented text-first archive structure that incorporates bounded source text with foveated image frames (HQ edges, LQ middle) to improve context quality.
- Updated snapcompact core logic to use `historyBlocks` for reconstruction, transitioning away from reliance on previous PNG frame inheritance.
- Increased default frame limits to 80 and adjusted token estimation constants (5024) to enhance context budget accuracy.
- Added `pruneToolDescriptions` and improved multi-block summary token estimation to optimize tool spec usage.
2026-06-19 07:38:54 +02:00

355 lines
12 KiB
TypeScript

import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ProviderPayload, ServiceTier } from "@oh-my-pi/pi-ai";
import * as snapcompact from "@oh-my-pi/snapcompact";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages";
import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel?: string;
serviceTier?: ServiceTier;
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
models: Record<string, string>;
/** Names of TTSR rules that have been injected this session */
injectedTtsrRules: string[];
/** MCP tool names selected through discovery for this session branch. */
selectedMCPToolNames: string[];
/** Whether this branch contains an explicit persisted MCP selection entry. */
hasPersistedMCPToolSelection: boolean;
/** Active mode (e.g. "plan") or "none" if no special mode is active */
mode: string;
/** Mode-specific data from the last mode_change entry */
modeData?: Record<string, unknown>;
}
/** Lists session model strings to try when restoring, in fallback order. */
export function getRestorableSessionModels(
models: Readonly<Record<string, string>>,
lastModelChangeRole: string | undefined,
): string[] {
const defaultModel = models.default;
if (
!lastModelChangeRole ||
lastModelChangeRole === "default" ||
lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
) {
return defaultModel ? [defaultModel] : [];
}
const roleModel = models[lastModelChangeRole];
if (!roleModel) return defaultModel ? [defaultModel] : [];
if (!defaultModel || roleModel === defaultModel) return [roleModel];
return [roleModel, defaultModel];
}
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
return entries[i] as CompactionEntry;
}
}
return null;
}
export interface BuildSessionContextOptions {
/**
* Build the full-history display transcript instead of the LLM context:
* every path entry in chronological order, with each compaction emitted
* inline as a `compactionSummary` message at the position it fired rather
* than replacing the history before it. Display-only — never send the
* result to a provider.
*/
transcript?: boolean;
}
/**
* Build the session context from entries using tree traversal.
* If leafId is provided, walks from that entry to root.
* Handles compaction and branch summaries along the path.
*/
export function buildSessionContext(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
options?: BuildSessionContextOptions,
): SessionContext {
// Build uuid index if not available
if (!byId) {
byId = new Map<string, SessionEntry>();
for (const entry of entries) {
byId.set(entry.id, entry);
}
}
// Find leaf
let leaf: SessionEntry | undefined;
if (leafId === null) {
// Explicitly null - return no messages (navigated to before first entry)
return {
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
hasPersistedMCPToolSelection: false,
mode: "none",
};
}
if (leafId) {
leaf = byId.get(leafId);
}
if (!leaf) {
// Fallback to last entry (when leafId is undefined)
leaf = entries[entries.length - 1];
}
if (!leaf) {
return {
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
hasPersistedMCPToolSelection: false,
mode: "none",
};
}
// Walk from leaf to root, collecting path
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
// Extract settings and find compaction
let thinkingLevel: string | undefined = "off";
let serviceTier: ServiceTier | undefined;
const models: Record<string, string> = {};
let compaction: CompactionEntry | null = null;
const injectedTtsrRulesSet = new Set<string>();
let selectedMCPToolNames: string[] = [];
let hasPersistedMCPToolSelection = false;
let mode = "none";
let modeData: Record<string, unknown> | undefined;
// Track whether an explicit `model_change` with role="default" has been
// seen on this path. Once a user (or the agent itself) records an
// explicit default, later assistant-message inference must NOT overwrite
// it: temporary fallbacks (retry fallback, context promotion) and
// server-side model downgrades both produce assistant messages tagged
// with the wrong model id, which previously clobbered the user's pick on
// resume (issue #849).
let hasExplicitDefaultModel = false;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel ?? "off";
} else if (entry.type === "model_change") {
// New format: { model: "provider/id", role?: string }
if (entry.model) {
const role = entry.role ?? "default";
models[role] = entry.model;
if (role === "default") {
hasExplicitDefaultModel = true;
}
}
} else if (entry.type === "service_tier_change") {
serviceTier = entry.serviceTier ?? undefined;
} else if (entry.type === "message" && entry.message.role === "assistant") {
// Legacy fallback: infer default model from assistant messages only
// when no explicit `model_change` (role=default) entry has been
// recorded yet. Newer sessions always record an explicit default
// model_change at the start of the conversation, so this branch is
// only used to keep pre-model_change sessions working.
if (!hasExplicitDefaultModel) {
models.default = `${entry.message.provider}/${entry.message.model}`;
}
} else if (entry.type === "compaction") {
compaction = entry;
} else if (entry.type === "ttsr_injection") {
// Collect injected TTSR rule names
for (const ruleName of entry.injectedRules) {
injectedTtsrRulesSet.add(ruleName);
}
} else if (entry.type === "mcp_tool_selection") {
selectedMCPToolNames = [...entry.selectedToolNames];
hasPersistedMCPToolSelection = true;
} else if (entry.type === "mode_change") {
mode = entry.mode;
modeData = entry.data;
}
}
const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
// Build messages and collect corresponding entries
// When there's a compaction, we need to:
// 1. Emit summary first (entry = compaction)
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
// 3. Emit messages after compaction
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
entry.timestamp,
entry.attribution,
),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
if (options?.transcript) {
// Display transcript: every entry in chronological order. Compactions do
// not erase prior history here — each renders inline (as a divider in the
// TUI) at the point it fired, with any snapcompact frames re-attached so
// the component can report them.
for (const entry of path) {
if (entry.type === "compaction") {
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
messages.push(
createCompactionSummaryMessage(
entry.summary,
entry.tokensBefore,
entry.timestamp,
entry.shortSummary,
undefined,
undefined,
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
),
);
} else {
appendMessage(entry);
}
}
} else if (compaction) {
const providerPayload: ProviderPayload | undefined = (() => {
const candidate = compaction.preserveData?.openaiRemoteCompaction;
if (!candidate || typeof candidate !== "object") return undefined;
const remote = candidate as { provider?: unknown; replacementHistory?: unknown };
if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined;
if (!Array.isArray(remote.replacementHistory)) return undefined;
return {
type: "openaiResponsesHistory",
provider: remote.provider,
items: remote.replacementHistory as Array<Record<string, unknown>>,
};
})();
const remoteReplacementHistory = providerPayload?.items;
// Emit summary first; re-attach any archived snapcompact frames so the
// model can keep reading the archived history after every context rebuild.
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
messages.push(
createCompactionSummaryMessage(
compaction.summary,
compaction.tokensBefore,
compaction.timestamp,
compaction.shortSummary,
providerPayload,
undefined,
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
),
);
// Find compaction index in path
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
if (!remoteReplacementHistory) {
// Emit kept messages (before compaction, starting from firstKeptEntryId)
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
appendMessage(entry);
}
}
}
// Emit messages after compaction
for (let i = compactionIdx + 1; i < path.length; i++) {
const entry = path[i];
appendMessage(entry);
}
} else {
// No compaction - emit all messages, handle branch summaries and custom messages
for (const entry of path) {
appendMessage(entry);
}
}
// Strip dangling tool_use blocks — a tool_use with no matching tool_result on the
// resolved leaf→root path — from ANY assistant turn, not just the trailing one.
// This happens whenever the leaf (or a branch point) lands such that an assistant
// turn's tool results are off the selected path: its result children live on a
// sibling branch, or it is the leaf itself (results are children below it). Left
// in place, `transformMessages` fabricates one synthetic "aborted"/"No result
// provided" result per dangling call, which render as phantom failed calls and
// re-inject the failed batch into the model's
// context — the rewind/restore loop.
//
// Stripping is necessary but not sufficient: a *modified* assistant turn that still
// carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
// blocks in the latest assistant message cannot be modified", and signed thinking
// replayed out of its original turn shape can also fail signature validation (this
// bites the handoff/branch-summary request). So when we rewrite a turn we also
// neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
// plaintext to keep) and clear `thinking` signatures so the provider encoder
// downgrades them to plain text (verified accepted by the live API), preserving the
// visible reasoning while removing the immutability/invalid-signature hazard. Drop a
// turn left with no content. (Live turns never qualify: their results are persisted
// on the same path before any context rebuild.)
const pairedToolResultIds = new Set<string>();
for (const message of messages) {
if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") continue;
const hasDangling = message.content.some(
block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
);
if (!hasDangling) continue;
const normalized = message.content
.filter(
block =>
!(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
)
.map(block =>
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
);
if (normalized.length === 0) {
messages.splice(i, 1);
} else {
messages[i] = { ...message, content: normalized };
}
}
return {
messages,
thinkingLevel,
serviceTier,
models,
injectedTtsrRules,
selectedMCPToolNames,
hasPersistedMCPToolSelection,
mode,
modeData,
};
}