mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b1bf4a50 | ||
|
|
8907573f09 | ||
|
|
512e3dd66e | ||
|
|
fccea10236 |
No files matched your search
@@ -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>
|
||||
·
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1737,6 +1737,7 @@ export const JA: TranslationSchema = {
|
||||
title: "\u25a3 コンテキスト",
|
||||
compactHint: " /compact で折りたたみ(50%で自動)\u00b7 /new でログ消去",
|
||||
topTools: " コスト上位のツール結果({count}):",
|
||||
topToolSchemas: " プロンプトコスト上位のツールスキーマ({count}):",
|
||||
msg: "メッセージ",
|
||||
turnLabel: "ターン",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -685,6 +685,7 @@ export interface TranslationSchema {
|
||||
title: string;
|
||||
compactHint: string;
|
||||
topTools: string;
|
||||
topToolSchemas: string;
|
||||
msg: string;
|
||||
turnLabel: string;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user