Compare commits

...
4 Commits
Author SHA1 Message Date
reasonix 50b1bf4a50 fix(cli): don't crash session hydration on a tool_call missing its function
Resuming a session (reasonix code) whose persisted assistant message holds a
tool_call without a function — e.g. a streaming delta saved before its function
landed — threw "Cannot read properties of undefined (reading 'arguments')" in
hydrateCardsFromMessages, taking down the whole UI at startup. Skip the partial
tool_call; the orphaned tool-output message already no-ops when its card is
absent.
2026-06-03 03:05:05 -07:00
esengine 8907573f09 docs(v1): mark the TypeScript line as legacy, point to the Go rewrite (main-v2) 2026-05-31 01:29:19 -07:00
512e3dd66e chore(cache): localize /status cache-detail line and make tool sort locale-independent (#2320)
Follow-up polish to the cache-efficiency guardrails (#2314):

- /status "cache detail" line was hard-coded English; route it through i18n
  (statusCacheDetail / statusCacheChurn) so it matches every other status row.
  EN + zh-CN translated; ja/de/ru inherit EN like the rest of their
  observability block.
- sortToolSpecs used localeCompare, which is locale-sensitive and could let
  the host locale reshuffle the serialized tool prefix and reintroduce the
  very cache churn the sort is meant to prevent. Switch to a stable
  codepoint compare. No change for ASCII tool names (all existing tests pass).

Co-authored-by: yhh <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 17:44:02 -07:00
Sivan fccea10236 Cache Efficiency Guardrails and Diagnostics / 缓存效率守卫与诊断 (#2314)
* feat(cache): add cache efficiency guardrails

* fix(cache): hash sent tool snapshot for diagnostics
2026-05-29 17:29:40 -07:00
30 changed files with 702 additions and 80 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **これはレガシーの TypeScript 系列(Reasonix 0.x)で、メンテナンスのみです。**
> 開発は **Go 版の書き直し** に移行しました —— 新しいデフォルトの [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) ブランチと[移行ガイド](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)を参照してください。ここではバグ修正のみ反映されます。
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
+5
View File
@@ -2,6 +2,11 @@
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **This is the legacy TypeScript line (Reasonix 0.x), in maintenance mode.**
> Active development moved to the **Go rewrite** — see the [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) branch (the new default) and the [migration guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md). Only bug fixes land here.
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
+5
View File
@@ -2,6 +2,11 @@
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **这是 legacy TypeScript 线(Reasonix 0.x),仅维护。**
> 开发已转移到 **Go 重写版** —— 见 [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) 分支(新的默认分支)与[迁移指南](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)。此处仅修复 bug。
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
+3 -2
View File
@@ -59,8 +59,9 @@ hit rates:
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
a summary message. The summary request is shaped to reuse the main agent's
already-cached system/tool/history prefix; the following main-agent request
still pays a cold miss for the newly synthesized summary segment.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
+51
View File
@@ -0,0 +1,51 @@
/**
* Deterministic cache-shape probe.
*
* This does not call DeepSeek. It validates local invariants that keep live
* prefix-cache hit rates high: stable tool-spec ordering, component prefix
* hashes, and explicit log rewrite tracking.
*
* Run: npx tsx scripts/probe-cache-shape.mts
*/
import assert from "node:assert/strict";
import { AppendOnlyLog, ImmutablePrefix } from "../src/memory/runtime.js";
import { ToolRegistry } from "../src/tools.js";
import type { ToolSpec } from "../src/types.js";
function tool(name: string): ToolSpec {
return {
type: "function",
function: {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
},
};
}
const registry = new ToolRegistry();
registry.register({ name: "zeta", fn: async () => "z" });
registry.register({ name: "alpha", fn: async () => "a" });
assert.deepEqual(
registry.specs().map((s) => s.function.name),
["alpha", "zeta"],
"ToolRegistry.specs() must be sorted by tool name",
);
const prefixA = new ImmutablePrefix({ system: "s", toolSpecs: [tool("read"), tool("write")] });
const prefixB = new ImmutablePrefix({ system: "s", toolSpecs: [tool("write"), tool("read")] });
assert.equal(prefixB.fingerprint, prefixA.fingerprint, "reordered tools should hash identically");
assert.equal(
prefixB.componentHashes.tools,
prefixA.componentHashes.tools,
"tool component hash should be stable under reordered input",
);
const log = new AppendOnlyLog();
log.append({ role: "user", content: "hello" });
assert.equal(log.rewriteVersion, 0, "append must not count as a rewrite");
log.compactInPlace([{ role: "assistant", content: "summary" }]);
assert.equal(log.rewriteVersion, 1, "compactInPlace must count as a rewrite");
console.log("PASS: deterministic cache-shape invariants hold");
+91 -31
View File
@@ -7,13 +7,21 @@
* Surfaces: cache trajectory, cost shape, anything degrading over time.
*
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-long-session.mts
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
*/
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory/runtime.js";
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry/stats.js";
import {
DEEPSEEK_CONTEXT_TOKENS,
pricingFor,
type CacheChurnReason,
type TurnStats,
} from "../src/telemetry/stats.js";
import { ToolRegistry } from "../src/tools.js";
// Force a small ctx window so the 50% fold threshold trips in a few
@@ -21,18 +29,42 @@ import { ToolRegistry } from "../src/tools.js";
// id, real API call, just the local gauge is shrunk.
DEEPSEEK_CONTEXT_TOKENS["deepseek-chat"] = 50_000;
function loadDotenv(path: string) {
function loadDotenv(path: string): void {
if (!existsSync(path)) return;
const txt = readFileSync(path, "utf8");
for (const line of txt.split(/\r?\n/)) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
loadDotenv("./.env.testbak");
const PRICE_HIT_PER_M = 0.07;
const PRICE_MISS_PER_M = 0.27;
const PRICE_COMPLETION_PER_M = 1.1;
function loadReasonixApiKey(): void {
if (process.env.DEEPSEEK_API_KEY) return;
const path = join(homedir(), ".reasonix", "config.json");
if (!existsSync(path)) return;
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
apiKey?: string;
deepseekApiKey?: string;
endpoint?: { apiKey?: string };
};
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
if (key) process.env.DEEPSEEK_API_KEY = key;
}
loadDotenv("./.env.testbak");
loadReasonixApiKey();
function cacheRatio(usage: {
promptCacheHitTokens: number;
promptCacheMissTokens: number;
}): number {
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
}
function formatReasons(reasons: readonly CacheChurnReason[]): string {
return reasons.length > 0 ? reasons.join(",") : "-";
}
const docLine = (i: number, sec: string) =>
`[${sec}#${i}] section ${sec} entry ${i}: requirement traces to constraint ${(i % 7) + 1}, status ${i % 3 === 0 ? "open" : "closed"}, owner team-${(i % 5) + 1}, last touched 2026-04-${(i % 28) + 1}.`;
@@ -69,11 +101,11 @@ async function main() {
maxToolIters: 4,
});
let mutations = 0;
let rawRewrites = 0;
let folds = 0;
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
loop.log.compactInPlace = (...args) => {
mutations++;
rawRewrites++;
return origCompactInPlace(...args);
};
@@ -100,48 +132,57 @@ async function main() {
"api-v2",
];
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum");
console.log("-----+----------+---------+---------+------+----------+--------");
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum | churn");
console.log("-----+----------+---------+---------+------+----------+--------+----------");
let cumCost = 0;
let forceSummaryHit = false;
const measured: Array<{
turn: number;
ratio: number;
forcedSummary: boolean;
reasons: CacheChurnReason[];
}> = [];
const pricing = pricingFor(model);
for (let i = 0; i < sections.length; i++) {
const t0 = Date.now();
let usage: {
promptTokens: number;
completionTokens: number;
promptCacheHitTokens: number;
promptCacheMissTokens: number;
} | null = null;
let stats: TurnStats | null = null;
let warning = "";
let turnForcedSummary = false;
for await (const ev of loop.step(`Read the "${sections[i]}" section.`)) {
if (ev.role === "assistant_final" && ev.stats?.usage) {
usage = ev.stats.usage as typeof usage;
stats = ev.stats;
}
if (ev.role === "warning" && ev.content) {
warning = ev.content;
if (/folded \d+ messages/.test(ev.content)) folds++;
}
if (ev.forcedSummary) forceSummaryHit = true;
if (ev.forcedSummary) {
forceSummaryHit = true;
turnForcedSummary = true;
}
}
const ms = Date.now() - t0;
const usage = stats?.usage;
if (!usage) {
console.log(
`${String(i).padStart(3)} | (no usage) -- ${warning ? `warning: ${warning}` : ""}`,
);
continue;
}
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
const cost =
(usage.promptCacheHitTokens * PRICE_HIT_PER_M +
usage.promptCacheMissTokens * PRICE_MISS_PER_M +
usage.completionTokens * PRICE_COMPLETION_PER_M) /
1_000_000;
const ratio = cacheRatio(usage);
const cost = pricing
? (usage.promptCacheHitTokens * pricing.inputCacheHit +
usage.promptCacheMissTokens * pricing.inputCacheMiss +
usage.completionTokens * pricing.output) /
1_000_000
: 0;
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
measured.push({ turn: i, ratio, forcedSummary: turnForcedSummary, reasons: [...reasons] });
cumCost += cost;
console.log(
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} | ${formatReasons(reasons)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
);
if (forceSummaryHit) {
console.log(
@@ -151,14 +192,33 @@ async function main() {
}
}
console.log(`\ntotal log.compactInPlace() calls: ${mutations} (expected: ${folds} folds)`);
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites} (${folds} fold warning events)`);
console.log(`total cost across session: $${cumCost.toFixed(4)}`);
if (mutations !== folds) {
console.log(`FAIL: ${mutations} mutations but only ${folds} fold events — unexpected mutation`);
const sustained = measured.filter((entry) => entry.turn >= 4 && !entry.forcedSummary);
const sustainedAvg =
sustained.length > 0
? sustained.reduce((sum, entry) => sum + entry.ratio, 0) / sustained.length
: 0;
const unstablePrefixTurns = measured
.slice(1)
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
console.log(`sustained cache hit average (turn >=4, pre-summary): ${sustainedAvg.toFixed(1)}%`);
if (unstablePrefixTurns.length > 0) {
const detail = unstablePrefixTurns
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
.join(", ");
console.log(`FAIL: non-log prefix churn detected (${detail})`);
process.exit(1);
}
console.log(`\nVERDICT: ${folds} fold(s) fired, no other mutations.`);
if (sustainedAvg < 85) {
console.log(`FAIL: sustained cache hit average ${sustainedAvg.toFixed(1)}% below 85% threshold`);
process.exit(1);
}
console.log(
`\nVERDICT: cache stayed warm through oversized tool results; raw rewrites are diagnostic, and force-summary cold miss is expected when triggered.`,
);
}
main().catch((e) => {
+57 -14
View File
@@ -7,23 +7,54 @@
* that the API-level append-vs-mutate primitive behaves as expected.
*
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-loop-cache.mts
* Reads DEEPSEEK_API_KEY from .env.testbak.
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
*/
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CacheFirstLoop } from "../src/loop.js";
import { DeepSeekClient } from "../src/client.js";
import { ImmutablePrefix } from "../src/memory/runtime.js";
import type { CacheChurnReason, TurnStats } from "../src/telemetry/stats.js";
import { ToolRegistry } from "../src/tools.js";
function loadDotenv(path: string) {
function loadDotenv(path: string): void {
if (!existsSync(path)) return;
const txt = readFileSync(path, "utf8");
for (const line of txt.split(/\r?\n/)) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
function loadReasonixApiKey(): void {
if (process.env.DEEPSEEK_API_KEY) return;
const path = join(homedir(), ".reasonix", "config.json");
if (!existsSync(path)) return;
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
apiKey?: string;
deepseekApiKey?: string;
endpoint?: { apiKey?: string };
};
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
if (key) process.env.DEEPSEEK_API_KEY = key;
}
loadDotenv("./.env.testbak");
loadReasonixApiKey();
function cacheRatio(usage: {
promptCacheHitTokens: number;
promptCacheMissTokens: number;
}): number {
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
}
function formatReasons(reasons: readonly CacheChurnReason[]): string {
return reasons.length > 0 ? reasons.join(",") : "-";
}
const filler = (label: string, n: number): string =>
Array.from(
@@ -67,49 +98,61 @@ async function main() {
loop.log.append({ role: "assistant", content: "noted." });
const ratios: number[] = [];
let mutations = 0;
const observedChurn: Array<{ turn: number; reasons: CacheChurnReason[] }> = [];
let rawRewrites = 0;
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
loop.log.compactInPlace = (...args: Parameters<typeof origCompactInPlace>) => {
mutations++;
rawRewrites++;
return origCompactInPlace(...args);
};
for (let i = 0; i < 6; i++) {
let usage: { promptTokens: number; promptCacheHitTokens: number; promptCacheMissTokens: number } | null = null;
let stats: TurnStats | null = null;
const rewritesBefore = rawRewrites;
for await (const ev of loop.step(`Turn ${i}: just say "ok ${i}".`)) {
if (ev.role === "assistant_final" && ev.stats?.usage) {
usage = ev.stats.usage as typeof usage;
stats = ev.stats;
}
}
const usage = stats?.usage;
if (!usage) {
console.log(`turn-${i}: no usage captured`);
ratios.push(0);
continue;
}
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
const ratio = cacheRatio(usage);
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
observedChurn.push({ turn: i, reasons: [...reasons] });
console.log(
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)}`,
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)} rawRewrites=${rawRewrites - rewritesBefore} churn=${formatReasons(reasons)}`,
);
ratios.push(ratio);
}
console.log(`\ntotal log.compactInPlace() calls: ${mutations}`);
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites}`);
console.log(`cache hit % per turn: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
const warmRatios = ratios.slice(1);
const avgWarm = warmRatios.reduce((a, b) => a + b, 0) / warmRatios.length;
console.log(`warm-turn average (excluding cold start): ${avgWarm.toFixed(1)}%`);
if (mutations > 0) {
console.log(`\nFAIL: log was mutated ${mutations} time(s) — append-only invariant broken`);
const unstablePrefixTurns = observedChurn
.slice(1)
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
if (unstablePrefixTurns.length > 0) {
const detail = unstablePrefixTurns
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
.join(", ");
console.log(`\nFAIL: non-log prefix churn detected (${detail})`);
process.exit(1);
}
if (avgWarm < 80) {
console.log(`\nFAIL: warm-turn average ${avgWarm.toFixed(1)}% below 80% threshold`);
process.exit(1);
}
console.log(`\nPASS: append-only sustained, cache hit avg ${avgWarm.toFixed(1)}%`);
console.log(
`\nPASS: cache stayed warm (avg ${avgWarm.toFixed(1)}%); raw rewrites are reported for diagnosis, not treated as cache failure`,
);
}
main().catch((e) => {
+6
View File
@@ -959,6 +959,12 @@ function AppInner({
cacheHitRatio: 0,
lastPromptTokens: 0,
lastTurnCostUsd: 0,
totalCacheHitTokens: 0,
totalCacheMissTokens: 0,
lastCacheMissTokens: 0,
lastToolSchemaTokens: 0,
lastPrefixChanged: false,
lastPrefixChangeReasons: [],
});
const transcriptRef = useRef<WriteStream | null>(null);
+6
View File
@@ -62,6 +62,12 @@ export function ReplayApp({ meta, pages }: ReplayAppProps) {
// Replay is read-only — no live last-turn prompt tokens to show.
lastPromptTokens: 0,
lastTurnCostUsd: 0,
totalCacheHitTokens: 0,
totalCacheMissTokens: 0,
lastCacheMissTokens: 0,
lastToolSchemaTokens: 0,
lastPrefixChanged: false,
lastPrefixChangeReasons: [],
};
const prefixHash =
+21
View File
@@ -18,6 +18,7 @@ export interface CtxBreakdownData {
toolsCount: number;
logMessages: number;
topTools: Array<{ name: string; tokens: number; turn: number }>;
topToolSchemas: Array<{ name: string; tokens: number }>;
}
/**
@@ -54,6 +55,14 @@ export function computeCtxBreakdown(loop: CacheFirstLoop): CtxBreakdownData {
const logTokens = userTokens + assistantTokens + toolResultTokens + toolCallTokens;
const ctxMax = resolveContextTokens(loop.model);
const topTools = [...toolBreakdown].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
const schemaCosts =
typeof loop.tools?.schemaTokenCosts === "function"
? loop.tools.schemaTokenCosts()
: loop.prefix.toolSpecs.map((spec) => ({
name: spec.function.name,
tokens: countTokensBounded(JSON.stringify(spec)),
}));
const topToolSchemas = [...schemaCosts].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
return {
systemTokens,
toolsTokens,
@@ -63,6 +72,7 @@ export function computeCtxBreakdown(loop: CacheFirstLoop): CtxBreakdownData {
toolsCount: loop.prefix.toolSpecs.length,
logMessages: entries.length,
topTools,
topToolSchemas,
};
}
@@ -154,6 +164,17 @@ export function CtxBreakdownBlock({ data }: { data: CtxBreakdownData }): React.R
))}
</Box>
) : null}
{data.topToolSchemas.length > 0 ? (
<Box marginTop={1} flexDirection="column">
<Text dim>{t("ctxBreakdown.topToolSchemas", { count: data.topToolSchemas.length })}</Text>
{data.topToolSchemas.map((tool) => (
<Box key={tool.name}>
<Text color={COLOR.info}>{` ${tool.name.padEnd(28)}`}</Text>
<Text dim>{` ${formatTokens(tool.tokens).padStart(8)}`}</Text>
</Box>
))}
</Box>
) : null}
<Box marginTop={1}>
<Text color={FG.faint}>{t("ctxBreakdown.compactHint")}</Text>
</Box>
@@ -62,6 +62,21 @@ const status: SlashHandler = (_args, loop, ctx) => {
cost: cost.toFixed(4),
turns: summary.turns,
});
const churn =
summary.lastPrefixChangeReasons.length > 0
? t("handlers.observability.statusCacheChurn", {
reasons: summary.lastPrefixChangeReasons.join(","),
})
: "";
const cacheDetailLine =
summary.turns > 0
? t("handlers.observability.statusCacheDetail", {
miss: compactNum(summary.totalCacheMissTokens),
last: compactNum(summary.lastCacheMissTokens),
schemas: compactNum(summary.lastToolSchemaTokens),
churn,
})
: "";
const budgetLine =
typeof loop.budgetUsd === "number"
@@ -118,6 +133,7 @@ const status: SlashHandler = (_args, loop, ctx) => {
mcpLine,
sessionLine,
];
if (cacheDetailLine) lines.splice(3, 0, cacheDetailLine);
if (workspaceLine) lines.push(workspaceLine);
if (budgetLine) lines.push(budgetLine);
if (pendingLine) lines.push(pendingLine);
+3
View File
@@ -49,6 +49,9 @@ export function hydrateCardsFromMessages(messages: ReadonlyArray<ChatMessage>):
}
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
// A persisted session can carry a partial tool_call (a streaming delta
// saved before its function landed); skip it instead of crashing hydration.
if (!tc.function) continue;
let parsedArgs: unknown = tc.function.arguments;
try {
parsedArgs = JSON.parse(tc.function.arguments);
+71 -2
View File
@@ -7,7 +7,12 @@ import { buildAssistantMessage } from "./loop/messages.js";
import { DEFAULT_MAX_RESULT_CHARS } from "./mcp/registry.js";
import type { AppendOnlyLog } from "./memory/runtime.js";
import { rewriteSession } from "./memory/session.js";
import { type SessionStats, resolveContextTokens } from "./telemetry/stats.js";
import {
type SessionStats,
inputCostUsd,
pricingFor,
resolveContextTokens,
} from "./telemetry/stats.js";
import { countTokensBounded, estimateRequestTokens } from "./tokenizer.js";
import type { ChatMessage, ToolSpec } from "./types.js";
@@ -37,6 +42,12 @@ export const FORCE_SUMMARY_THRESHOLD = 0.8;
export const TURN_START_FOLD_THRESHOLD = 0.9;
/** Hard deadline for semantic fold summaries so a hung request cannot stall the turn loop. */
export const HISTORY_FOLD_SUMMARY_TIMEOUT_MS = 15_000;
/** Normal-band folds should pay for themselves over a short horizon; aggressive folds still prioritize headroom. */
export const HISTORY_FOLD_ECONOMIC_HORIZON_TURNS = 3;
export const HISTORY_FOLD_MIN_ECONOMIC_SAVINGS_FRACTION = 0.15;
export const HISTORY_FOLD_MIN_ECONOMIC_SAVINGS_USD = 0.002;
/** Summary + next-turn cold segment reserve used by fold economics. */
export const HISTORY_FOLD_SUMMARY_RESERVE_TOKENS = 4096;
/** Prepended to fold summary content so the model knows it's a synthesized recap.
* Re-export of the shared constant so existing imports keep resolving. */
export const HISTORY_FOLD_MARKER = COMPACTION_SUMMARY_MARKER;
@@ -71,6 +82,7 @@ export interface PostUsageDecision {
tailBudget?: number;
/** True when this fold is in the 70-85% band — used in user-facing messaging. */
aggressive?: boolean;
economics?: FoldEconomics;
}
export interface FoldResult {
@@ -80,6 +92,57 @@ export interface FoldResult {
summaryChars: number;
}
export interface FoldEconomics {
horizonTurns: number;
carryInputUsd: number;
foldInputUsd: number;
savingsUsd: number;
savingsFraction: number;
worthwhile: boolean;
}
export function estimateFoldEconomics(
usage: Usage,
model: string,
tailBudgetTokens: number,
): FoldEconomics {
const pricing = pricingFor(model);
if (!pricing) {
return {
horizonTurns: HISTORY_FOLD_ECONOMIC_HORIZON_TURNS,
carryInputUsd: 0,
foldInputUsd: 0,
savingsUsd: 0,
savingsFraction: 0,
worthwhile: true,
};
}
const horizonTurns = HISTORY_FOLD_ECONOMIC_HORIZON_TURNS;
const carryInputUsd = inputCostUsd(model, usage) * horizonTurns;
const summaryCallUsd = inputCostUsd(model, usage);
const postFoldPromptTokens = Math.min(
usage.promptTokens,
tailBudgetTokens + HISTORY_FOLD_SUMMARY_RESERVE_TOKENS,
);
const postFoldColdUsd = (postFoldPromptTokens * pricing.inputCacheMiss) / 1_000_000;
const postFoldWarmUsd =
((horizonTurns - 1) * postFoldPromptTokens * pricing.inputCacheHit) / 1_000_000;
const foldInputUsd = summaryCallUsd + postFoldColdUsd + postFoldWarmUsd;
const savingsUsd = carryInputUsd - foldInputUsd;
const savingsFraction = carryInputUsd > 0 ? savingsUsd / carryInputUsd : 0;
return {
horizonTurns,
carryInputUsd,
foldInputUsd,
savingsUsd,
savingsFraction,
worthwhile:
savingsUsd >= HISTORY_FOLD_MIN_ECONOMIC_SAVINGS_USD &&
savingsFraction >= HISTORY_FOLD_MIN_ECONOMIC_SAVINGS_FRACTION,
};
}
function buildFoldSummaryInstruction(pinnedSkillNames: string[]): string {
const base =
"Summarize the conversation above as one self-contained prose recap. Preserve the user's " +
@@ -158,11 +221,17 @@ export class ContextManager {
};
}
if (ratio > HISTORY_FOLD_THRESHOLD) {
const tailBudget = Math.floor(ctxMax * HISTORY_FOLD_TAIL_FRACTION);
const economics = estimateFoldEconomics(usage, model, tailBudget);
if (!economics.worthwhile) {
return { kind: "none", ...base, economics };
}
return {
kind: "fold",
...base,
tailBudget: Math.floor(ctxMax * HISTORY_FOLD_TAIL_FRACTION),
tailBudget,
aggressive: false,
economics,
};
}
return { kind: "none", ...base };
+3
View File
@@ -1224,6 +1224,8 @@ export const EN: TranslationSchema = {
statusCtxNone: " ctx no turns yet",
statusCost: " cost ${cost} · cache {bar} {pct}% · turns {turns}",
statusCostCold: " cost ${cost} · turns {turns} (cache warming up)",
statusCacheDetail: " cache miss {miss} total · last {last} · schemas {schemas}{churn}",
statusCacheChurn: " · churn {reasons}",
statusBudget: " budget ${spent} / ${cap} ({pct}%){tag}",
statusSession: ' session "{name}" · {count} messages in log (resumed {resumed})',
statusSessionEphemeral: " session (ephemeral — no persistence)",
@@ -1699,6 +1701,7 @@ export const EN: TranslationSchema = {
title: "\u25a3 context",
compactHint: " /compact folds (auto at 50%) \u00b7 /new wipes log",
topTools: " top tool results by cost ({count}):",
topToolSchemas: " top tool schemas by prompt cost ({count}):",
msg: "msg",
turnLabel: "turn",
},
+1
View File
@@ -1737,6 +1737,7 @@ export const JA: TranslationSchema = {
title: "\u25a3 コンテキスト",
compactHint: " /compact で折りたたみ(50%で自動)\u00b7 /new でログ消去",
topTools: " コスト上位のツール結果({count}):",
topToolSchemas: " プロンプトコスト上位のツールスキーマ({count}):",
msg: "メッセージ",
turnLabel: "ターン",
},
+1
View File
@@ -1651,6 +1651,7 @@ export const de: TranslationSchema = {
title: "▣ Kontext",
compactHint: " /compact faltet (automatisch bei 50 %) · /new löscht Log",
topTools: " Top-Tool-Ergebnisse nach Kosten ({count}):",
topToolSchemas: " Top-Tool-Schemas nach Prompt-Kosten ({count}):",
msg: "Nachr",
turnLabel: "Turn",
},
+1
View File
@@ -685,6 +685,7 @@ export interface TranslationSchema {
title: string;
compactHint: string;
topTools: string;
topToolSchemas: string;
msg: string;
turnLabel: string;
};
+3
View File
@@ -1152,6 +1152,8 @@ export const zhCN: TranslationSchema = {
statusCtxNone: " 上下文 尚无轮次",
statusCost: " 成本 ${cost} · 缓存 {bar} {pct}% · 轮次 {turns}",
statusCostCold: " 成本 ${cost} · 轮次 {turns}(缓存预热中)",
statusCacheDetail: " 缓存 未命中 {miss} 累计 · 最近 {last} · schema {schemas}{churn}",
statusCacheChurn: " · 前缀变化 {reasons}",
statusBudget: " 预算 ${spent} / ${cap}({pct}%){tag}",
statusSession: ' 会话 "{name}" · 日志中 {count} 条消息(恢复了 {resumed} 条)',
statusSessionEphemeral: " 会话 (临时 — 无持久化)",
@@ -1603,6 +1605,7 @@ export const zhCN: TranslationSchema = {
title: "▣ 上下文",
compactHint: " /compact 折叠(超过 50% 自动触发)· /new 清空日志",
topTools: " 常用工具(按成本排序,{count} 个):",
topToolSchemas: " 工具 schema(按 prompt 成本排序,{count} 个):",
msg: "条",
turnLabel: "轮",
},
+87 -11
View File
@@ -57,14 +57,16 @@ import {
} from "./memory/session.js";
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
import {
type PrefixDiagnosticHashes,
appendCacheDiagnostic,
buildCacheDiagnostic,
latestCacheDiagnostic,
} from "./telemetry/cache-diagnostics.js";
import { SessionStats, type TurnStats } from "./telemetry/stats.js";
import { type CacheDiagnostics, SessionStats, type TurnStats } from "./telemetry/stats.js";
import { countTokensBounded } from "./tokenizer.js";
import { ToolRegistry } from "./tools.js";
import { ReadTracker } from "./tools/read-tracker.js";
import type { ChatMessage, ToolCall } from "./types.js";
import type { ChatMessage, ToolCall, ToolSpec } from "./types.js";
export const MID_TURN_STEER_WRAPPER =
"[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]";
@@ -131,6 +133,15 @@ export interface LoopAbortOptions {
discardCurrentTurn?: boolean;
}
interface CacheShapeSnapshot {
systemHash: string;
toolsHash: string;
fewShotsHash: string;
prefixHash: string;
logRewriteVersion: number;
toolSchemaTokens: number;
}
function shrinkMessageForRetention(message: ChatMessage): ChatMessage {
if (message.role !== "assistant" || !Array.isArray(message.tool_calls)) return message;
return (
@@ -210,6 +221,7 @@ export class CacheFirstLoop {
private _turnSelfCorrected = false;
private _foldedThisTurn = false;
private context!: ContextManager;
private _lastCacheShape: CacheShapeSnapshot | null = null;
/** Subscribe API so UI hooks can derive `running` from finally-guaranteed insertions. */
get inflight(): InflightSet {
@@ -380,6 +392,7 @@ export class CacheFirstLoop {
this.stats.reset();
this._turn = 0;
this._budgetWarned = false;
this._lastCacheShape = null;
// Drain leftover steer text — otherwise the first step() after /new
// injects it as a user message and the next turn leaks prior intent.
this._steerQueue.length = 0;
@@ -410,6 +423,7 @@ export class CacheFirstLoop {
this.log.compactInPlace([]);
this.scratch.reset();
this._inflight.clear();
this._lastCacheShape = null;
this._steerQueue.length = 0;
this._steerConsumed = false;
this.sessionName = opts.sessionName;
@@ -552,6 +566,48 @@ export class CacheFirstLoop {
return [...this.prefix.toMessages(), ...healedMessages];
}
private cacheShapeForRequest(
prefixEvidence: PrefixDiagnosticHashes,
toolSpecs: readonly ToolSpec[],
): CacheShapeSnapshot {
return {
systemHash: prefixEvidence.systemHash,
toolsHash: prefixEvidence.toolSpecsHash,
fewShotsHash: prefixEvidence.fewShotsHash,
prefixHash: prefixEvidence.prefixHash,
logRewriteVersion: this.log.rewriteVersion,
toolSchemaTokens: countTokensBounded(JSON.stringify(toolSpecs)),
};
}
private cacheDiagnosticsForUsage(
shape: CacheShapeSnapshot,
usage: TurnStats["usage"] | null,
): CacheDiagnostics {
const prev = this._lastCacheShape;
const prefixChangeReasons: CacheDiagnostics["prefixChangeReasons"] = [];
if (prev) {
if (prev.systemHash !== shape.systemHash) prefixChangeReasons.push("system");
if (prev.toolsHash !== shape.toolsHash) prefixChangeReasons.push("tools");
if (prev.fewShotsHash !== shape.fewShotsHash) prefixChangeReasons.push("few_shots");
if (prev.logRewriteVersion !== shape.logRewriteVersion) {
prefixChangeReasons.push("log_rewrite");
}
}
return {
prefixHash: shape.prefixHash,
prefixChanged: prefixChangeReasons.length > 0,
prefixChangeReasons,
systemHash: shape.systemHash,
toolsHash: shape.toolsHash,
fewShotsHash: shape.fewShotsHash,
logRewriteVersion: shape.logRewriteVersion,
toolSchemaTokens: shape.toolSchemaTokens,
promptCacheMissTokens: usage?.promptCacheMissTokens ?? 0,
promptCacheHitTokens: usage?.promptCacheHitTokens ?? 0,
};
}
private healActiveLogBeforeSend(): ChatMessage[] {
// Skip the expensive 4-pass healing pipeline when the log hasn't
// changed since the last call — the common case between iterations
@@ -566,22 +622,34 @@ export class CacheFirstLoop {
DEFAULT_MAX_RESULT_TOKENS,
);
const pruned = stripDroppableReasoningContent(argsShrunk.messages);
if (healed.healedCount === 0 && argsShrunk.healedCount === 0 && pruned.prunedCount === 0) {
// stripDroppableReasoningContent removes reasoning_content from stale plain
// assistant turns. Re-stamp only tool-call turns that need round-trip-safe
// reasoning fields for thinking-mode models.
const stamped = stampMissingReasoningForThinkingMode(pruned.messages, this.model, {
toolCallsOnly: true,
});
const final = stamped.messages;
if (
healed.healedCount === 0 &&
argsShrunk.healedCount === 0 &&
pruned.prunedCount === 0 &&
stamped.stampedCount === 0
) {
this._healedCache = current;
this._healedVersion = this.log.version;
return current;
}
this.log.compactInPlace(pruned.messages);
this._healedCache = pruned.messages;
this.log.compactInPlace(final);
this._healedCache = final;
this._healedVersion = this.log.version;
if (this.sessionName) {
try {
rewriteSession(this.sessionName, pruned.messages);
rewriteSession(this.sessionName, final);
} catch {
/* disk issue shouldn't block the in-memory heal */
}
}
return pruned.messages;
return final;
}
abort(opts: LoopAbortOptions = {}): void {
@@ -744,7 +812,7 @@ export class CacheFirstLoop {
// when the user navigates away before the model responds). A failed
// first round-trip still leaves the message in the log; the user can
// /retry without re-typing.
const turnStartLogIndex = this.log.length;
const turnStartLogIndex = this.log.totalLength;
this.appendAndPersist({ role: "user", content: userInput });
const toolSpecs = this.prefix.tools();
const rateLimitState = { shown: false };
@@ -878,6 +946,7 @@ export class CacheFirstLoop {
// Snapshot prefix evidence from the same turn-start tool list sent
// to the API so MCP hot-adds during the turn don't rewrite history.
const prefixEvidence = this.prefix.diagnosticHashes(toolSpecs);
const cacheShape = this.cacheShapeForRequest(prefixEvidence, toolSpecs);
try {
callModel = this.model;
@@ -969,9 +1038,16 @@ export class CacheFirstLoop {
continue;
}
// Attribute under the actual model used (escalated → pro, else
// callModel) so cost/usage logs reflect reality.
const turnStats = this.stats.record(this._turn, callModel, usage ?? new Usage());
// Attribute under the actual model used (escalated → pro, else callModel)
// so cost/usage logs reflect reality.
const cacheDiagnostics = this.cacheDiagnosticsForUsage(cacheShape, usage);
this._lastCacheShape = cacheShape;
const turnStats = this.stats.record(
this._turn,
callModel,
usage ?? new Usage(),
cacheDiagnostics,
);
let cacheDiagnostic = buildCacheDiagnostic({
turn: this._turn,
model: callModel,
+4
View File
@@ -76,6 +76,7 @@ export function healLoadedMessages(
export function stampMissingReasoningForThinkingMode(
messages: ChatMessage[],
model: string,
options: { toolCallsOnly?: boolean } = {},
): { messages: ChatMessage[]; stampedCount: number } {
if (!isThinkingModeModel(model)) {
return { messages, stampedCount: 0 };
@@ -83,6 +84,9 @@ export function stampMissingReasoningForThinkingMode(
let stampedCount = 0;
const out = messages.map((msg) => {
if (msg.role !== "assistant") return msg;
if (options.toolCallsOnly && (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0)) {
return msg;
}
if (Object.hasOwn(msg, "reasoning_content")) return msg;
stampedCount += 1;
return { ...msg, reasoning_content: "" };
+47 -6
View File
@@ -12,6 +12,33 @@ export interface ImmutablePrefixOptions {
fewShots?: readonly ChatMessage[];
}
export interface PrefixComponentHashes {
system: string;
tools: string;
fewShots: string;
full: string;
}
function shortHash(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
}
function toolName(spec: ToolSpec): string {
return spec.function?.name ?? "";
}
export function sortToolSpecs(specs: readonly ToolSpec[]): ToolSpec[] {
// Locale-independent codepoint compare — localeCompare would let the host
// locale reshuffle the serialized tool prefix and reintroduce cache churn.
return [...specs]
.map((spec) => structuredClone(spec) as ToolSpec)
.sort((a, b) => {
const an = toolName(a);
const bn = toolName(b);
return an < bn ? -1 : an > bn ? 1 : 0;
});
}
export class ImmutablePrefix {
/** Stable across turns; rebuilt only on /new when REASONIX.md changed on disk. */
system: string;
@@ -27,7 +54,7 @@ export class ImmutablePrefix {
constructor(opts: ImmutablePrefixOptions) {
this.system = opts.system;
this._toolSpecs = [...(opts.toolSpecs ?? [])];
this._toolSpecs = sortToolSpecs(opts.toolSpecs ?? []);
this.fewShots = Object.freeze([...(opts.fewShots ?? [])]);
}
@@ -63,7 +90,7 @@ export class ImmutablePrefix {
const name = spec.function?.name;
if (!name) return false;
if (this._toolSpecs.some((t) => t.function?.name === name)) return false;
this._toolSpecs.push(spec);
this._toolSpecs = sortToolSpecs([...this._toolSpecs, spec]);
this.invalidatePrefixCaches();
this._frozenToolsCache = null;
return true;
@@ -109,6 +136,15 @@ export class ImmutablePrefix {
});
}
get componentHashes(): PrefixComponentHashes {
return {
system: shortHash(this.system),
tools: shortHash(this._toolSpecs),
fewShots: shortHash(this.fewShots),
full: this.fingerprint,
};
}
/** Dev/test only — throws on cache drift, which always means a non-`addTool` mutation slipped in. */
verifyFingerprint(): string {
const fresh = this.computeFingerprint();
@@ -122,12 +158,11 @@ export class ImmutablePrefix {
}
private computeFingerprint(): string {
const blob = JSON.stringify({
return shortHash({
system: this.system,
tools: this._toolSpecs,
shots: this.fewShots,
});
return createHash("sha256").update(blob).digest("hex").slice(0, 16);
}
}
@@ -146,6 +181,7 @@ export class AppendOnlyLog {
// Monotonic counter bumped on every mutation. Consumers compare against
// their own snapshot to detect staleness without destructive check-and-clear.
private _version = 0;
private _rewriteVersion = 0;
constructor(opts?: { windowSize?: number; sessionPath?: string }) {
this._windowSize = opts?.windowSize ?? DEFAULT_WINDOW;
@@ -187,6 +223,7 @@ export class AppendOnlyLog {
this._totalLength = replacement.length;
this._fullHistoryCache = null;
this._version++;
this._rewriteVersion++;
}
// Checks memory window first; falls back to disk for older messages.
@@ -213,11 +250,11 @@ export class AppendOnlyLog {
if (!this._sessionPath || this._entries.length >= this._totalLength) {
return this.toMessages();
}
if (this._fullHistoryCache && this._fullHistoryCache.version === this._totalLength) {
if (this._fullHistoryCache && this._fullHistoryCache.version === this._version) {
return this._fullHistoryCache.messages.map((e) => ({ ...e }));
}
const whole = readTailMessages(this._sessionPath, this._totalLength);
this._fullHistoryCache = { version: this._totalLength, messages: whole };
this._fullHistoryCache = { version: this._version, messages: whole };
return whole.map((e) => ({ ...e }));
}
@@ -248,6 +285,10 @@ export class AppendOnlyLog {
get version(): number {
return this._version;
}
get rewriteVersion(): number {
return this._rewriteVersion;
}
}
export class VolatileScratch {
+35 -1
View File
@@ -106,6 +106,22 @@ export interface TurnStats {
usage: Usage;
cost: number;
cacheHitRatio: number;
cacheDiagnostics?: CacheDiagnostics;
}
export type CacheChurnReason = "system" | "tools" | "few_shots" | "log_rewrite";
export interface CacheDiagnostics {
prefixHash: string;
prefixChanged: boolean;
prefixChangeReasons: CacheChurnReason[];
systemHash: string;
toolsHash: string;
fewShotsHash: string;
logRewriteVersion: number;
toolSchemaTokens: number;
promptCacheMissTokens: number;
promptCacheHitTokens: number;
}
export interface SessionSummary {
@@ -122,6 +138,12 @@ export interface SessionSummary {
/** Floor estimate for next call — actual cost = this + user delta + new tool outputs. */
lastPromptTokens: number;
lastTurnCostUsd: number;
totalCacheHitTokens: number;
totalCacheMissTokens: number;
lastCacheMissTokens: number;
lastToolSchemaTokens: number;
lastPrefixChanged: boolean;
lastPrefixChangeReasons: CacheChurnReason[];
}
export class SessionStats {
@@ -201,7 +223,12 @@ export class SessionStats {
this._cacheDiagnostics = [];
}
record(turn: number, model: string, usage: Usage): TurnStats {
record(
turn: number,
model: string,
usage: Usage,
cacheDiagnostics?: CacheDiagnostics,
): TurnStats {
const cost = costUsd(model, usage);
const stats: TurnStats = {
turn,
@@ -209,6 +236,7 @@ export class SessionStats {
usage,
cost,
cacheHitRatio: usage.cacheHitRatio,
cacheDiagnostics,
};
this.turns.push(stats);
this.trimOldTurns();
@@ -293,6 +321,12 @@ export class SessionStats {
cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
lastPromptTokens: last?.usage.promptTokens ?? this._carryoverLastPromptTokens,
lastTurnCostUsd: round(last?.cost ?? 0, 6),
totalCacheHitTokens: this.cumulativeCacheHitTokens,
totalCacheMissTokens: this.cumulativeCacheMissTokens,
lastCacheMissTokens: last?.usage.promptCacheMissTokens ?? 0,
lastToolSchemaTokens: last?.cacheDiagnostics?.toolSchemaTokens ?? 0,
lastPrefixChanged: last?.cacheDiagnostics?.prefixChanged ?? false,
lastPrefixChangeReasons: last?.cacheDiagnostics?.prefixChangeReasons ?? [],
};
}
}
+18 -7
View File
@@ -1,6 +1,8 @@
import type { PauseGate } from "./core/pause-gate.js";
import { truncateForModel, truncateForModelByTokens } from "./mcp/registry.js";
import { sortToolSpecs } from "./memory/runtime.js";
import { analyzeSchema, flattenSchema, nestArguments } from "./repair/flatten.js";
import { countTokensBounded } from "./tokenizer.js";
import {
type NormalizedToolRateLimitConfig,
type ToolRateLimitOption,
@@ -172,13 +174,22 @@ export class ToolRegistry {
}
specs(): ToolSpec[] {
return [...this._tools.values()].map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description ?? "",
parameters: t.flatSchema ?? t.parameters ?? { type: "object", properties: {} },
},
return sortToolSpecs(
[...this._tools.values()].map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description ?? "",
parameters: t.flatSchema ?? t.parameters ?? { type: "object", properties: {} },
},
})),
);
}
schemaTokenCosts(): Array<{ name: string; tokens: number }> {
return this.specs().map((spec) => ({
name: spec.function.name,
tokens: countTokensBounded(JSON.stringify(spec)),
}));
}
+6
View File
@@ -125,6 +125,12 @@ function summarizeTurns(turns: TurnStats[]): SessionSummary {
cacheHitRatio: round(cacheHitRatio, 4),
lastPromptTokens: lastTurn?.usage.promptTokens ?? 0,
lastTurnCostUsd: round(lastTurn?.cost ?? 0, 6),
totalCacheHitTokens: hit,
totalCacheMissTokens: miss,
lastCacheMissTokens: lastTurn?.usage.promptCacheMissTokens ?? 0,
lastToolSchemaTokens: lastTurn?.cacheDiagnostics?.toolSchemaTokens ?? 0,
lastPrefixChanged: lastTurn?.cacheDiagnostics?.prefixChanged ?? false,
lastPrefixChangeReasons: lastTurn?.cacheDiagnostics?.prefixChangeReasons ?? [],
};
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { Usage } from "../src/client.js";
import { AppendOnlyLog, ImmutablePrefix } from "../src/memory/runtime.js";
import { SessionStats } from "../src/telemetry/stats.js";
import { ToolRegistry } from "../src/tools.js";
import type { ToolSpec } from "../src/types.js";
function tool(name: string): ToolSpec {
return {
type: "function",
function: {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
},
};
}
describe("cache-shape diagnostics", () => {
it("sorts ToolRegistry specs by tool name for stable prefix bytes", () => {
const reg = new ToolRegistry();
reg.register({ name: "zeta", fn: async () => "z" });
reg.register({ name: "alpha", fn: async () => "a" });
expect(reg.specs().map((s) => s.function.name)).toEqual(["alpha", "zeta"]);
});
it("normalizes ImmutablePrefix tool order before hashing", () => {
const a = new ImmutablePrefix({ system: "s", toolSpecs: [tool("read"), tool("write")] });
const b = new ImmutablePrefix({ system: "s", toolSpecs: [tool("write"), tool("read")] });
expect(b.fingerprint).toBe(a.fingerprint);
expect(b.componentHashes.tools).toBe(a.componentHashes.tools);
});
it("keeps hot-added tools sorted inside the prefix", () => {
const prefix = new ImmutablePrefix({ system: "s", toolSpecs: [tool("write")] });
expect(prefix.addTool(tool("read"))).toBe(true);
expect(prefix.toolSpecs.map((s) => s.function.name)).toEqual(["read", "write"]);
});
it("tracks append-only-breaking rewrites separately from normal appends", () => {
const log = new AppendOnlyLog();
expect(log.rewriteVersion).toBe(0);
log.append({ role: "user", content: "hello" });
expect(log.rewriteVersion).toBe(0);
log.compactInPlace([{ role: "assistant", content: "summary" }]);
expect(log.rewriteVersion).toBe(1);
});
it("surfaces miss tokens and prefix churn in session summaries", () => {
const stats = new SessionStats();
stats.record(1, "deepseek-v4-flash", new Usage(1000, 50, 1050, 900, 100), {
prefixHash: "p1",
prefixChanged: true,
prefixChangeReasons: ["tools"],
systemHash: "s1",
toolsHash: "t1",
fewShotsHash: "f1",
logRewriteVersion: 2,
toolSchemaTokens: 345,
promptCacheHitTokens: 900,
promptCacheMissTokens: 100,
});
const summary = stats.summary();
expect(summary.totalCacheHitTokens).toBe(900);
expect(summary.totalCacheMissTokens).toBe(100);
expect(summary.lastCacheMissTokens).toBe(100);
expect(summary.lastToolSchemaTokens).toBe(345);
expect(summary.lastPrefixChanged).toBe(true);
expect(summary.lastPrefixChangeReasons).toEqual(["tools"]);
});
});
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory/runtime.js";
import { ImmutablePrefix, sortToolSpecs } from "../src/memory/runtime.js";
import type { ChatMessage, ToolSpec } from "../src/types.js";
interface CapturedRequest {
@@ -123,10 +123,11 @@ describe("ContextManager fold sends cache-aligned summary request", () => {
await loop.compactHistory({ keepRecentTokens: 40 });
const req = captured[0]!;
const expectedTools = sortToolSpecs(TOOLS);
expect(req.tools).toBeDefined();
expect(req.tools).toEqual(TOOLS);
expect(JSON.stringify(req.tools)).toBe(JSON.stringify(TOOLS));
expect(req.tools).toEqual(expectedTools);
expect(JSON.stringify(req.tools)).toBe(JSON.stringify(expectedTools));
});
it("summary request preserves the head conversation bytes (head messages unmodified)", async () => {
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { Usage } from "../src/client.js";
import { ContextManager, estimateFoldEconomics } from "../src/context-manager.js";
function manager(): ContextManager {
return new ContextManager({
client: {} as never,
log: {} as never,
stats: {} as never,
sessionName: null,
getAbortSignal: () => new AbortController().signal,
getCurrentTurn: () => 1,
getSystemPrompt: () => "system",
});
}
describe("ContextManager fold economics", () => {
it("does not fold in the normal band when cache carry cost is cheaper than fold tax", () => {
const usage = new Usage(760_000, 100, 760_100, 752_000, 8_000);
const decision = manager().decideAfterUsage(usage, "deepseek-v4-flash", false);
expect(decision.kind).toBe("none");
expect(decision.economics?.worthwhile).toBe(false);
});
it("folds in the normal band when high miss tokens make carrying context expensive", () => {
const usage = new Usage(760_000, 100, 760_100, 0, 760_000);
const decision = manager().decideAfterUsage(usage, "deepseek-v4-flash", false);
expect(decision.kind).toBe("fold");
expect(decision.economics?.worthwhile).toBe(true);
});
it("still folds aggressively for headroom even if cache economics are cheap", () => {
const usage = new Usage(790_000, 100, 790_100, 782_000, 8_000);
const decision = manager().decideAfterUsage(usage, "deepseek-v4-flash", false);
expect(decision.kind).toBe("fold");
expect(decision.aggressive).toBe(true);
});
it("estimates fold cost over a short multi-turn horizon", () => {
const usage = new Usage(760_000, 100, 760_100, 0, 760_000);
const economics = estimateFoldEconomics(usage, "deepseek-v4-flash", 200_000);
expect(economics.horizonTurns).toBeGreaterThan(1);
expect(economics.carryInputUsd).toBeGreaterThan(economics.foldInputUsd);
expect(economics.savingsUsd).toBeGreaterThan(0);
});
});
+13
View File
@@ -127,6 +127,19 @@ describe("hydrateCardsFromMessages", () => {
expect(cards[0]).toMatchObject({ kind: "tool", args: "not-json", done: false });
});
it("skips a partial tool_call missing its function instead of crashing hydration", () => {
const msgs: ChatMessage[] = [
{
role: "assistant",
content: "recovered",
// biome-ignore lint/suspicious/noExplicitAny: a corrupt persisted session can carry a streaming-delta tool_call with no function, though the type marks it required
tool_calls: [{ id: "call-x", type: "function" } as any],
},
];
const cards = hydrateCardsFromMessages(msgs);
expect(cards.map((c) => c.kind)).toEqual(["streaming"]);
});
it("starts resumed long sessions with old heavy card fields already elided", () => {
const huge = "large retained field\n".repeat(800);
const msgs: ChatMessage[] = [];
+10 -1
View File
@@ -238,6 +238,7 @@ describe("CacheFirstLoop (non-streaming)", () => {
],
},
{ content: "done" },
{ content: "next done" },
]);
const tools = new ToolRegistry();
@@ -261,13 +262,21 @@ describe("CacheFirstLoop (non-streaming)", () => {
await loop.run("go");
expect(prefix.toolSpecs.map((spec) => spec.function.name)).toEqual([
"probe",
"mcp_dynamic_tool",
"probe",
]);
expect(loop.stats.cacheDiagnostics).toHaveLength(2);
expect(loop.stats.cacheDiagnostics[0]?.toolNames).toEqual(["probe"]);
expect(loop.stats.cacheDiagnostics[1]?.toolNames).toEqual(["probe"]);
expect(loop.stats.cacheDiagnostics[1]?.missReason).not.toBe("mcp-tool-hot-add");
expect(loop.stats.turns[1]?.cacheDiagnostics?.prefixChangeReasons).toEqual([]);
await loop.run("next");
expect(loop.stats.turns[2]?.cacheDiagnostics?.prefixChangeReasons).toContain("tools");
expect(loop.stats.summary().lastPrefixChangeReasons).toContain("tools");
expect(loop.stats.cacheDiagnostics[2]?.toolNames).toEqual(["mcp_dynamic_tool", "probe"]);
expect(loop.stats.cacheDiagnostics[2]?.missReason).toBe("mcp-tool-hot-add");
});
it("yields tool_start before each tool dispatch so the TUI can show 'running…'", async () => {
+2 -2
View File
@@ -77,7 +77,7 @@ describe("RFC #110 — cache-prefix invariant under MCP reconnect", () => {
expect(after.fingerprint).not.toBe(before.fingerprint);
});
it("REORDERING the same tools changes the fingerprint (array order is part of the prefix)", () => {
it("REORDERING the same tools keeps the fingerprint stable (prefix sorts tool specs)", () => {
const a = new ImmutablePrefix({
system: "s",
toolSpecs: [tool("read"), tool("write"), tool("search")],
@@ -86,6 +86,6 @@ describe("RFC #110 — cache-prefix invariant under MCP reconnect", () => {
system: "s",
toolSpecs: [tool("write"), tool("read"), tool("search")],
});
expect(b.fingerprint).not.toBe(a.fingerprint);
expect(b.fingerprint).toBe(a.fingerprint);
});
});