From 7ffcb1d456bbe96e1a88ec0782f7a409aaa643dd Mon Sep 17 00:00:00 2001 From: romer8 Date: Mon, 18 May 2026 19:56:46 -0600 Subject: [PATCH 1/5] feat(engine): IndexedDB-backed result cache + URI minting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md Unit 1. Foundation for the MCP result-by-reference protocol. Caches oversized tool results in IndexedDB and mints `mcp+cache:///<8-byte-b64>` URIs the LLM can pass forward in subsequent tool calls. Unit 2 will instrument the engine's tool-result write site to use this. Unit 3 will add the substitution layer that resolves URIs before dispatch. Storage choice rationale: - IndexedDB (not in-memory Map): persists across page reload, browser- managed eviction, gigabyte-scale quota. Survives the "user refreshed mid-conversation" failure mode that an in-memory Map can't. - One DB per chatbox-core installation. One object store keyed by URI. - Secondary indexes on convId (for clearConversation) and addedAt (for age-based eviction). Heuristic auto-cache: - Caches only payloads whose JSON-serialized size exceeds the threshold (default 4 KB to match v0.6.4's MAX_TOOL_RESULT_CHARS). Smaller payloads aren't worth the cache write. - No producer-side opt-in needed. Every tool result over the threshold becomes a candidate. URI format: `mcp+cache:///<11-char-base64url>`. Conv-id is path-traversal-sanitized (`[^A-Za-z0-9_-]` → `_`); falls back to "default" if sanitization yields an empty string. 8-byte (64-bit) random token from `crypto.getRandomValues` — unguessable inside a conversation that holds at most thousands of entries. Public surface: - `cacheToolResult({ payload, convId, sourceToolName, threshold })` returns the minted URI or null (below threshold / no IndexedDB). - `readCachedPayload(uri)` returns the original payload or null. - `clearConversation(convId)` drops all entries for a conv-id (host calls this on dashboard switch / chat reset). - `evictOlderThan({ maxAgeMs })` age-based eviction. - `hasIndexedDB()` runtime check — module is a graceful no-op in test environments without an IndexedDB shim. Tests: fake-indexeddb (new dev dep) shims the real API in Node so the same code path that runs in browsers executes here. 16 new tests cover URI minting + collision safety + sanitization, threshold heuristic, write/read round-trip, cache miss, clearConversation scoping (drops named conv-id only), and evictOlderThan no-op + keep- fresh semantics. Full suite: 463 → 479 passed. --- engine/cache.js | 315 +++++++++++++++++++++++++++++++++++++++++++ engine/cache.test.js | 262 +++++++++++++++++++++++++++++++++++ package-lock.json | 11 ++ package.json | 1 + 4 files changed, 589 insertions(+) create mode 100644 engine/cache.js create mode 100644 engine/cache.test.js diff --git a/engine/cache.js b/engine/cache.js new file mode 100644 index 0000000..c5e87a9 --- /dev/null +++ b/engine/cache.js @@ -0,0 +1,315 @@ +/** + * engine/cache.js — IndexedDB-backed result cache for the MCP + * result-by-reference protocol. + * + * Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md + * Unit 1. + * + * Solves: cross-server MCP tool composition bottleneck where the LLM + * regenerates large data arrays (e.g., 240-row time-series) token-by-token + * between tool calls. By caching oversized tool results in IndexedDB and + * minting an `mcp+cache://` URI the LLM can pass forward, the substitution + * layer in `processToolCalls` (Unit 3) can swap the URI for inline data + * before dispatch — eliminating the regeneration cost. + * + * Storage choice rationale (per plan refinement 2026-05-18): + * - IndexedDB (not in-memory Map): persists across page reload, browser- + * managed eviction, gigabyte-scale quota. Survives the "user refreshed + * mid-conversation and lost everything" failure mode. + * - One DB per chatbox-core installation. One object store keyed by URI. + * - URI format: mcp+cache:///<8-byte-base64url>. Conv-id scopes + * URIs to a conversation; clearConversation() drops all entries for a + * given conv-id. + * + * Heuristic auto-cache (per plan refinement): + * - Caches only payloads whose JSON-serialized size exceeds the threshold + * (default 4 KB to match v0.6.4's MAX_TOOL_RESULT_CHARS). Smaller + * payloads aren't worth the cache write — the LLM can inline them + * cheaply. + * + * Browser-only: + * - This module uses `globalThis.indexedDB`. In Node test environments + * without an IndexedDB shim, the module's exported functions are + * no-ops (return null / resolve to undefined) so the engine's + * code paths can compile + run without crashing during tests. + */ + +const DB_NAME = "chatbox-core-result-cache"; +const DB_VERSION = 1; +const STORE_NAME = "results"; + +/** Default threshold below which we don't bother caching (bytes of JSON). */ +export const DEFAULT_CACHE_THRESHOLD_BYTES = 4096; + +/** Scheme prefix for every URI this module mints. */ +export const CACHE_URI_SCHEME = "mcp+cache://"; + +/** + * True when the current runtime has a usable IndexedDB. Test environments + * (vitest default node) don't, so callers can short-circuit gracefully. + */ +export function hasIndexedDB() { + return typeof globalThis !== "undefined" && !!globalThis.indexedDB; +} + +let _dbPromise = null; + +function openDb() { + if (!hasIndexedDB()) { + return Promise.resolve(null); + } + if (_dbPromise) return _dbPromise; + + _dbPromise = new Promise((resolve, reject) => { + const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "uri" }); + // Secondary index on convId so clearConversation() can scan + // efficiently without iterating every entry. + store.createIndex("convId", "convId", { unique: false }); + store.createIndex("addedAt", "addedAt", { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return _dbPromise; +} + +/** + * Mint a fresh cache URI for the given conversation. + * + * Format: mcp+cache:///<11-char-base64url>. + * 8 bytes of randomness ≈ 64 bits — practically unguessable inside a + * conversation that hosts at most thousands of entries. + */ +export function mintCacheUri(convId) { + const safeConvId = sanitizeConvId(convId); + const tokenBytes = new Uint8Array(8); + cryptoSource().getRandomValues(tokenBytes); + const token = bytesToBase64Url(tokenBytes); + return `${CACHE_URI_SCHEME}${safeConvId}/${token}`; +} + +/** + * Estimate the byte-size of a JSON-serializable payload. Used by the + * auto-cache heuristic — payloads under threshold don't earn a cache entry. + */ +export function estimateSize(payload) { + try { + return new Blob([JSON.stringify(payload)]).size; + } catch { + // Non-serializable payloads (circular refs, etc.) bypass the cache. + return 0; + } +} + +/** + * Cache a tool result payload if its serialized size exceeds the threshold. + * + * Returns the minted cache URI on success, or `null` when: + * - the payload was below the threshold (don't bother caching); + * - IndexedDB is unavailable in this runtime (Node test env without shim); + * - the write failed (logged, swallowed — the engine continues without + * the cache benefit). + * + * Caller is responsible for surfacing the returned URI to the LLM (Unit 2 + * does this at the tool-result write site). + */ +export async function cacheToolResult({ + payload, + convId, + sourceToolName, + threshold = DEFAULT_CACHE_THRESHOLD_BYTES, +}) { + if (!hasIndexedDB()) return null; + const size = estimateSize(payload); + if (size < threshold) return null; + + const uri = mintCacheUri(convId); + const entry = { + uri, + payload, + convId: sanitizeConvId(convId), + sourceToolName: sourceToolName || "", + addedAt: Date.now(), + sizeBytes: size, + }; + + try { + const db = await openDb(); + if (!db) return null; + await runTx(db, "readwrite", (store) => store.put(entry)); + return uri; + } catch (err) { + console.warn("[chatbox-core cache] write failed:", err); + return null; + } +} + +/** + * Read a cached payload by URI. Returns the payload value, or `null` if + * the URI is missing / IndexedDB is unavailable / the read fails. + * + * Used by Unit 3's substitution layer. + */ +export async function readCachedPayload(uri) { + if (!hasIndexedDB()) return null; + if (typeof uri !== "string" || !uri.startsWith(CACHE_URI_SCHEME)) return null; + + try { + const db = await openDb(); + if (!db) return null; + const entry = await runTx(db, "readonly", (store) => store.get(uri)); + return entry?.payload ?? null; + } catch (err) { + console.warn("[chatbox-core cache] read failed:", err); + return null; + } +} + +/** + * Drop all cache entries belonging to a conversation. Called by the host + * on dashboard switch / chat reset to free browser quota. + */ +export async function clearConversation(convId) { + if (!hasIndexedDB()) return; + const safeConvId = sanitizeConvId(convId); + try { + const db = await openDb(); + if (!db) return; + await runTx(db, "readwrite", (store) => { + return new Promise((resolve, reject) => { + const index = store.index("convId"); + const request = index.openCursor(IDBKeyRange.only(safeConvId)); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + cursor.delete(); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + }); + } catch (err) { + console.warn("[chatbox-core cache] clearConversation failed:", err); + } +} + +/** + * Best-effort eviction: drop entries older than `maxAgeMs` until the + * total cache size is under `maxBytes`. Host-callable for quota management; + * the engine doesn't call this automatically (browser-managed eviction + * handles the hard quota). + */ +export async function evictOlderThan({ maxAgeMs }) { + if (!hasIndexedDB()) return; + const cutoff = Date.now() - maxAgeMs; + try { + const db = await openDb(); + if (!db) return; + await runTx(db, "readwrite", (store) => { + return new Promise((resolve, reject) => { + const index = store.index("addedAt"); + const request = index.openCursor(IDBKeyRange.upperBound(cutoff)); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + cursor.delete(); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + }); + } catch (err) { + console.warn("[chatbox-core cache] evictOlderThan failed:", err); + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function runTx(db, mode, work) { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + const result = work(store); + // Result may be a request (e.g., store.get) or a Promise (for cursor + // walks). Handle both shapes. + if (result && typeof result.then === "function") { + result.then( + (value) => { + tx.oncomplete = () => resolve(value); + tx.onerror = () => reject(tx.error); + }, + reject, + ); + } else if (result && typeof result.onsuccess !== "undefined") { + result.onsuccess = () => { + tx.oncomplete = () => resolve(result.result); + tx.onerror = () => reject(tx.error); + }; + result.onerror = () => reject(result.error); + } else { + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error); + } + }); +} + +function bytesToBase64Url(bytes) { + // Browser-safe base64url (no padding, URL-safe alphabet). + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + const b64 = (globalThis.btoa || nodeBtoa)(binary); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function nodeBtoa(str) { + // Test-env fallback when running under Node without `btoa` (older Node + // versions). globalThis.btoa exists in Node 16+ and all browsers. + return Buffer.from(str, "binary").toString("base64"); +} + +function cryptoSource() { + if (globalThis.crypto?.getRandomValues) return globalThis.crypto; + // Node test-env fallback — same surface as web crypto. + return { + getRandomValues(arr) { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + }, + }; +} + +/** + * Replace path-traversal / authority-injection characters with `_`. The + * conv-id ends up inside a URI we mint and pass through messages[]; this + * keeps it shaped like an identifier no matter what the host passes in. + */ +function sanitizeConvId(convId) { + const raw = String(convId ?? "default"); + return raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "default"; +} + +/** + * Test-only: reset the cached DB connection so tests can re-open with a + * fresh fake-indexeddb instance between cases. + * + * NOT exported from the engine's public surface — only consumed by the + * cache.test.js fixture. + */ +export function __resetDbForTests() { + _dbPromise = null; +} diff --git a/engine/cache.test.js b/engine/cache.test.js new file mode 100644 index 0000000..5df99a1 --- /dev/null +++ b/engine/cache.test.js @@ -0,0 +1,262 @@ +/** + * engine/cache.test.js — coverage for the IndexedDB-backed result cache. + * + * Uses fake-indexeddb so the same code path that runs in browsers executes + * here in Node. The shim provides a real (in-memory) IndexedDB + * implementation — not a mock — so transaction semantics, indexes, and + * cursor behavior are exercised authentically. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Install the fake IndexedDB on globalThis BEFORE importing the module +// under test, since cache.js binds to globalThis.indexedDB at import time +// for the `hasIndexedDB()` check. +import "fake-indexeddb/auto"; + +import { + CACHE_URI_SCHEME, + DEFAULT_CACHE_THRESHOLD_BYTES, + __resetDbForTests, + cacheToolResult, + clearConversation, + estimateSize, + evictOlderThan, + hasIndexedDB, + mintCacheUri, + readCachedPayload, +} from "./cache.js"; + +// --------------------------------------------------------------------------- +// Per-test fixture — reset the DB so cases don't leak entries between runs. +// fake-indexeddb's `auto` module installs a fresh in-memory instance per +// import; we reset our cached `_dbPromise` so the next call re-opens. +// --------------------------------------------------------------------------- + +beforeEach(async () => { + // Reset the module's cached DB connection so we re-open fresh, and + // wipe the object store so prior-test entries don't leak. We can't + // deleteDatabase here because the prior test's connection holds it + // open and the delete request blocks forever in fake-indexeddb. + __resetDbForTests(); + + // Open a one-shot connection just to clear the store. If the DB + // doesn't exist yet (first test), the upgrade handler creates it + // and the clear() runs against an empty store — also fine. + await new Promise((resolve, reject) => { + const open = globalThis.indexedDB.open("chatbox-core-result-cache", 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("results")) { + const store = db.createObjectStore("results", { keyPath: "uri" }); + store.createIndex("convId", "convId", { unique: false }); + store.createIndex("addedAt", "addedAt", { unique: false }); + } + }; + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("results", "readwrite"); + tx.objectStore("results").clear(); + tx.oncomplete = () => { + db.close(); + // Reset again so the module re-opens on next call rather than + // reusing this fixture's closed handle. + __resetDbForTests(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// hasIndexedDB / mintCacheUri / estimateSize — pure helpers +// --------------------------------------------------------------------------- + +describe("hasIndexedDB()", () => { + it("returns true when globalThis.indexedDB is present", () => { + expect(hasIndexedDB()).toBe(true); + }); +}); + +describe("mintCacheUri(convId)", () => { + it("mints a URI with the correct scheme + conv-id + token shape", () => { + const uri = mintCacheUri("conv-abc"); + expect(uri.startsWith(CACHE_URI_SCHEME)).toBe(true); + expect(uri).toMatch(/^mcp\+cache:\/\/conv-abc\/[A-Za-z0-9_-]{11}$/); + }); + + it("two calls mint different URIs (collision-safe)", () => { + const a = mintCacheUri("conv-x"); + const b = mintCacheUri("conv-x"); + expect(a).not.toEqual(b); + }); + + it("sanitizes conv-id (path traversal / authority injection)", () => { + const uri = mintCacheUri("../evil/../host"); + // Path-traversal chars `/` and `.` get sanitized to `_`. + expect(uri).not.toMatch(/\.\./); + expect(uri.split("/").length).toBe(4); // mcp+cache:, '', sanitized-conv-id, token + }); + + it("falls back to 'default' when conv-id sanitizes to empty", () => { + const uri = mintCacheUri("////"); + expect(uri).toMatch(/^mcp\+cache:\/\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+$/); + }); +}); + +describe("estimateSize(payload)", () => { + it("returns the JSON-string size in bytes", () => { + const size = estimateSize({ a: 1, b: "hello" }); + expect(size).toBeGreaterThan(10); + expect(size).toBeLessThan(30); + }); + + it("returns 0 for non-serializable payloads", () => { + const circ = { x: null }; + circ.x = circ; + expect(estimateSize(circ)).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// cacheToolResult — write path with the threshold heuristic +// --------------------------------------------------------------------------- + +describe("cacheToolResult — heuristic write path", () => { + it("returns null and skips the write for payloads below threshold", async () => { + const uri = await cacheToolResult({ + payload: { tiny: "ok" }, + convId: "c1", + sourceToolName: "test_tool", + }); + expect(uri).toBeNull(); + }); + + it("writes oversized payloads + returns a fresh URI", async () => { + // Build a payload that easily exceeds 4 KB. + const bigData = Array.from({ length: 200 }, (_, i) => ({ + id: i, + label: `row-${i}`, + x: 1.234567890123, + y: 9.876543210987, + })); + const uri = await cacheToolResult({ + payload: { ok: true, data: bigData }, + convId: "c1", + sourceToolName: "query_output_files_from_output_selector", + }); + expect(uri).not.toBeNull(); + expect(uri.startsWith("mcp+cache://c1/")).toBe(true); + }); + + it("custom threshold overrides the default", async () => { + // Below default threshold, above 50-byte threshold. + const payload = { hello: "world" }; + const aboveCustom = await cacheToolResult({ + payload, + convId: "c1", + sourceToolName: "x", + threshold: 5, + }); + expect(aboveCustom).not.toBeNull(); + + const belowCustom = await cacheToolResult({ + payload, + convId: "c1", + sourceToolName: "x", + threshold: DEFAULT_CACHE_THRESHOLD_BYTES, + }); + expect(belowCustom).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readCachedPayload — round-trip + missing / malformed URIs +// --------------------------------------------------------------------------- + +describe("readCachedPayload — round-trip", () => { + it("returns the original payload after write+read", async () => { + const big = { rows: Array.from({ length: 500 }, (_, i) => ({ x: i, y: i * 2, z: `r${i}` })) }; + const uri = await cacheToolResult({ + payload: big, + convId: "c1", + sourceToolName: "t", + }); + expect(uri).not.toBeNull(); + + const round = await readCachedPayload(uri); + expect(round).toEqual(big); + }); + + it("returns null for an unknown URI (cache miss)", async () => { + const missing = await readCachedPayload( + "mcp+cache://c1/aaaaaaaaaaa", + ); + expect(missing).toBeNull(); + }); + + it("returns null for a non-cache URI string", async () => { + expect(await readCachedPayload("not-a-uri")).toBeNull(); + expect(await readCachedPayload("https://example.com")).toBeNull(); + expect(await readCachedPayload(null)).toBeNull(); + expect(await readCachedPayload(123)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// clearConversation — drop all entries for a conv-id +// --------------------------------------------------------------------------- + +describe("clearConversation(convId)", () => { + it("drops only the named conversation's entries", async () => { + const big = { rows: Array.from({ length: 500 }, (_, i) => ({ x: i, label: `row-${i}` })) }; + const a1 = await cacheToolResult({ payload: big, convId: "conv-a", sourceToolName: "t" }); + const a2 = await cacheToolResult({ payload: big, convId: "conv-a", sourceToolName: "t" }); + const b1 = await cacheToolResult({ payload: big, convId: "conv-b", sourceToolName: "t" }); + + await clearConversation("conv-a"); + + expect(await readCachedPayload(a1)).toBeNull(); + expect(await readCachedPayload(a2)).toBeNull(); + // conv-b entry survives. + expect(await readCachedPayload(b1)).toEqual(big); + }); +}); + +// --------------------------------------------------------------------------- +// evictOlderThan — age-based eviction +// --------------------------------------------------------------------------- + +describe("evictOlderThan({maxAgeMs})", () => { + // Note: fake-indexeddb interacts poorly with vi.useFakeTimers (the shim + // schedules transaction completion via setTimeout). Rather than mock the + // clock, we exercise the cursor logic against real timestamps and assert + // (a) the function runs without error on an empty store, and (b) entries + // newer than the cutoff are NOT evicted. Eviction-of-old-entries is + // covered by the implementation itself (cursor walks the addedAt index + // up to the cutoff) — exercising it precisely would require either a + // sleep (slow) or shim of Date.now (fragile under fake-indexeddb). + + it("runs without error on an empty store", async () => { + await expect( + evictOlderThan({ maxAgeMs: 1000 }), + ).resolves.toBeUndefined(); + }); + + it("keeps just-written entries (newer than any reasonable cutoff)", async () => { + const big = { rows: Array.from({ length: 500 }, (_, i) => ({ x: i, label: `row-${i}` })) }; + const uri = await cacheToolResult({ payload: big, convId: "c1", sourceToolName: "t" }); + + // Evict anything older than 10 hours — the entry we just wrote is + // milliseconds old, so it should survive. + await evictOlderThan({ maxAgeMs: 10 * 60 * 60 * 1000 }); + + expect(await readCachedPayload(uri)).toEqual(big); + }); +}); diff --git a/package-lock.json b/package-lock.json index 76f221e..fb92d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^25.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3268,6 +3269,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index bf8b200..dbe2dbb 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^4.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^25.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", From 0ecb2e79d4d45b011011215f348c3a861580a655 Mon Sep 17 00:00:00 2001 From: romer8 Date: Mon, 18 May 2026 20:02:00 -0600 Subject: [PATCH 2/5] feat(engine): wire cache write at tool-result site; thread cacheOptions through runChatSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md Unit 2. Instruments the tool-result write site at engine/index.js to call `cacheToolResult()` from Unit 1's cache module and inject `_cache_uri` into the LLM-visible envelope when (a) the host has opted in via the new `enableResultCache` runChatSession parameter, and (b) the serialized payload exceeds the threshold (4 KB default, matching v0.6.4's truncation cap). Three changes: 1. `runChatSession` signature gains two parameters: - `enableResultCache: boolean` (default false — npm consumers that don't opt in see no behavior change) - `conversationId: string` (default "default" — used as the conv-id segment of minted cache URIs; tethysapp-tethys_dash will pass the dashboard UUID once Unit 4 wires the host prop) These are composed into a `cacheOptions = { enabled, conversationId }` object threaded into both call sites of `processToolCalls`. 2. `processToolCalls` signature accepts `cacheOptions` in its options bag. At the tool-result write site (around the existing `_engine_dispatched` injection), when cache is enabled AND the result is object-shaped, calls `cacheToolResult({payload, convId, sourceToolName})` and injects the returned URI as `_cache_uri` on the LLM-visible envelope. 3. The cache write happens BEFORE the truncation pass, so the cached payload is always the ORIGINAL pre-truncation result. The truncation summary block now also preserves `_cache_uri` so the LLM gets the reference even when the bulk payload was dropped. This is exactly the case where the URI is most valuable — large responses that overflow the cap can still be passed forward by reference. Unit 3 (next) will add the substitution layer in `processToolCalls` that resolves URIs back to inline data before dispatch. Until Unit 3 lands, the `_cache_uri` is informational only — the LLM sees it but chatbox-core doesn't act on it. Tests: 5 new tests in cache-instrumentation.test.js covering: - default-off: no `_cache_uri` when cacheOptions is absent - enabled + oversized: `_cache_uri` injected with correct conv-id prefix - enabled + small: threshold heuristic skips the cache write - enabled + huge (truncation fires): `_cache_uri` preserved into summary - disabled + huge: truncation summary has no `_cache_uri` Full suite: 479 → 484 passed. --- engine/cache-instrumentation.test.js | 246 +++++++++++++++++++++++++++ engine/index.js | 59 ++++++- 2 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 engine/cache-instrumentation.test.js diff --git a/engine/cache-instrumentation.test.js b/engine/cache-instrumentation.test.js new file mode 100644 index 0000000..7b4172b --- /dev/null +++ b/engine/cache-instrumentation.test.js @@ -0,0 +1,246 @@ +/** + * engine/cache-instrumentation.test.js — coverage for Unit 2 of the MCP + * result-by-reference protocol (plan 2026-05-18-002). + * + * Verifies that when `cacheOptions.enabled === true` is threaded into + * `processToolCalls`, oversized tool results are written to IndexedDB + * and the LLM-visible envelope gains a `_cache_uri` field. Smaller + * results are passed through unchanged. The cache write is gated on + * the opt-in flag — disabled by default so existing consumers see no + * behavior change. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Install fake IndexedDB BEFORE importing the engine so cache.js binds +// to a usable globalThis.indexedDB at module load. +import "fake-indexeddb/auto"; + +import { processToolCalls } from "./index.js"; +import { __resetDbForTests } from "./cache.js"; +import { makeFakeClient } from "../test-helpers/fakeConn.js"; +import { MAX_TOOL_RESULT_CHARS } from "../config/index.js"; + +// --------------------------------------------------------------------------- +// Helpers (mirror engine-dispatched.test.js shape) +// --------------------------------------------------------------------------- + +function makeFreshState() { + return { + lastChartResult: null, + lastQueryResult: null, + lastQuerySQL: null, + lastListResult: null, + lastMapResult: null, + lastHydrofabricResult: null, + pendingVisualizations: [], + pendingLayerUpdates: [], + pendingPatches: [], + rejectedPatches: [], + toolCallsThisTurn: [], + }; +} + +function makeToolCall(name, args = {}, id = `call-${name}`) { + return { id, function: { name, arguments: args } }; +} + +function makeConnections(toolResultsByName) { + const callTool = vi.fn(async ({ name }) => { + const result = toolResultsByName[name]; + if (result === undefined) { + throw new Error(`Test fixture missing result for tool: ${name}`); + } + return { data: result }; + }); + const client = makeFakeClient({ callToolImpl: callTool }); + const connections = [{ client, transport: null, protocolUsed: "http" }]; + const toolServerMap = new Map( + Object.keys(toolResultsByName).map((name) => [name, 0]), + ); + return { connections, toolServerMap }; +} + +/** Pull the tool-result message the engine pushed for the call. */ +function getToolMessage(messages, toolName) { + return messages.find((m) => m.role === "tool" && m.tool_name === toolName); +} + +beforeEach(async () => { + // Per-test DB clear (same pattern as cache.test.js). + __resetDbForTests(); + await new Promise((resolve, reject) => { + const open = globalThis.indexedDB.open("chatbox-core-result-cache", 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("results")) { + const store = db.createObjectStore("results", { keyPath: "uri" }); + store.createIndex("convId", "convId", { unique: false }); + store.createIndex("addedAt", "addedAt", { unique: false }); + } + }; + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("results", "readwrite"); + tx.objectStore("results").clear(); + tx.oncomplete = () => { + db.close(); + __resetDbForTests(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + }); +}); + +// --------------------------------------------------------------------------- +// Behavior +// --------------------------------------------------------------------------- + +describe("processToolCalls — Unit 2 cache instrumentation", () => { + it("default-off: no `_cache_uri` injected when cacheOptions is absent", async () => { + const big = { rows: Array.from({ length: 500 }, (_, i) => ({ x: i, label: `r${i}` })) }; + const { connections, toolServerMap } = makeConnections({ big_tool: big }); + const messages = []; + const state = makeFreshState(); + + await processToolCalls( + [makeToolCall("big_tool")], + messages, + connections, + toolServerMap, + state, + "", + {}, + ); + + const toolMsg = getToolMessage(messages, "big_tool"); + expect(toolMsg).toBeDefined(); + expect(toolMsg.content).not.toMatch(/_cache_uri/); + }); + + it("enabled + oversized payload: `_cache_uri` injected into LLM-visible envelope", async () => { + const big = { rows: Array.from({ length: 500 }, (_, i) => ({ x: i, label: `r${i}` })) }; + const { connections, toolServerMap } = makeConnections({ big_tool: big }); + const messages = []; + const state = makeFreshState(); + + await processToolCalls( + [makeToolCall("big_tool")], + messages, + connections, + toolServerMap, + state, + "", + { + cacheOptions: { enabled: true, conversationId: "test-conv-1" }, + }, + ); + + const toolMsg = getToolMessage(messages, "big_tool"); + expect(toolMsg).toBeDefined(); + expect(toolMsg.content).toMatch(/_cache_uri/); + const parsed = JSON.parse(toolMsg.content); + expect(parsed._cache_uri).toMatch(/^mcp\+cache:\/\/test-conv-1\/[A-Za-z0-9_-]+$/); + }); + + it("enabled + small payload: no `_cache_uri` (threshold heuristic skips)", async () => { + const small = { ok: true, value: 42 }; + const { connections, toolServerMap } = makeConnections({ small_tool: small }); + const messages = []; + const state = makeFreshState(); + + await processToolCalls( + [makeToolCall("small_tool")], + messages, + connections, + toolServerMap, + state, + "", + { + cacheOptions: { enabled: true, conversationId: "test-conv-2" }, + }, + ); + + const toolMsg = getToolMessage(messages, "small_tool"); + expect(toolMsg).toBeDefined(); + expect(toolMsg.content).not.toMatch(/_cache_uri/); + }); + + it("enabled + truncated oversized result: `_cache_uri` preserved in truncation summary", async () => { + // Build a payload that exceeds MAX_TOOL_RESULT_CHARS (20000) so the + // truncation pass fires. The cache write happens BEFORE truncation, + // so the LLM sees the truncation summary + `_cache_uri` — which is + // exactly the case where the URI is most valuable (data dropped). + const huge = { + ok: true, + rows: 5000, + columns: ["time", "flow"], + data: Array.from({ length: 5000 }, (_, i) => ({ + time: `2026-05-18T${String(i % 24).padStart(2, "0")}:00:00.000000Z`, + flow: Math.random() * 100, + })), + }; + const { connections, toolServerMap } = makeConnections({ huge_tool: huge }); + const messages = []; + const state = makeFreshState(); + + await processToolCalls( + [makeToolCall("huge_tool")], + messages, + connections, + toolServerMap, + state, + "", + { + cacheOptions: { enabled: true, conversationId: "huge-conv" }, + }, + ); + + const toolMsg = getToolMessage(messages, "huge_tool"); + expect(toolMsg).toBeDefined(); + const parsed = JSON.parse(toolMsg.content); + + // Truncation fired — confirm the summary shape... + expect(parsed._truncated).toBe(true); + expect(parsed.rows).toBe(5000); + expect(parsed.columns).toEqual(["time", "flow"]); + // ...AND the cache URI survived into the summary. + expect(parsed._cache_uri).toMatch(/^mcp\+cache:\/\/huge-conv\/[A-Za-z0-9_-]+$/); + // Bulk payload dropped — `data` is not in the summary. + expect(parsed.data).toBeUndefined(); + }); + + it("disabled: oversized result truncates with metadata, no `_cache_uri`", async () => { + const huge = { + ok: true, + rows: 5000, + columns: ["time", "flow"], + data: Array.from({ length: 5000 }, (_, i) => ({ + time: `t${i}`, + flow: Math.random() * 100, + })), + }; + const { connections, toolServerMap } = makeConnections({ huge_tool: huge }); + const messages = []; + const state = makeFreshState(); + + await processToolCalls( + [makeToolCall("huge_tool")], + messages, + connections, + toolServerMap, + state, + "", + { + // No cacheOptions passed — default-off. + }, + ); + + const toolMsg = getToolMessage(messages, "huge_tool"); + const parsed = JSON.parse(toolMsg.content); + expect(parsed._truncated).toBe(true); + expect(parsed._cache_uri).toBeUndefined(); + }); +}); diff --git a/engine/index.js b/engine/index.js index b868c9e..d880d8c 100644 --- a/engine/index.js +++ b/engine/index.js @@ -28,6 +28,7 @@ import { LIST_TOOLS_BUDGET_MS, } from "./transports.js"; import { ERROR_KEYS } from "./mcpErrors.js"; +import { cacheToolResult } from "./cache.js"; import { trimConversation } from "../conversation/index.js"; import { buildGenericSystemMessage } from "../messages/index.js"; import { @@ -689,7 +690,14 @@ function _substituteLastUuidPlaceholders(value, lastReturnedUuids) { export async function processToolCalls( toolCalls, messages, connections, toolServerMap, state, originalUserText, - { toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus }, + { + toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus, + // MCP result-by-reference protocol (plan 2026-05-18-002). Disabled by + // default so npm consumers of @aquaveo/chatbox-core that don't opt in + // see no behavior change. tethysapp-tethys_dash sets enabled=true on + // its mount via Unit 4's host prop. + cacheOptions = { enabled: false, conversationId: "default" }, + }, ) { let hadError = false; let lastErr = null; @@ -880,6 +888,27 @@ export async function processToolCalls( // the engine's authoritative value last (object-key insertion order // makes the engine's value win even on engines that surface dupes). resultForLlm = { ...toolResult, _engine_dispatched: dispatchedUuids }; + + // Plan 2026-05-18-002 Unit 2 — MCP result-by-reference protocol. + // When enabled by the host (Chatbox.jsx's `enableResultCache` prop, + // threaded through Unit 4), oversized tool results are auto-cached + // in IndexedDB and a `_cache_uri` marker is injected into the + // LLM-visible envelope. Unit 3's substitution layer (in this same + // function above the dispatch call) resolves the URI back to inline + // data when the LLM passes it forward as `data_uri` or any other + // `*_uri` arg. The cache write happens BEFORE the truncation pass + // below so the cached payload is always the ORIGINAL, not the + // truncated summary the LLM ends up seeing for oversized results. + if (cacheOptions?.enabled) { + const cacheUri = await cacheToolResult({ + payload: toolResult, + convId: cacheOptions.conversationId || "default", + sourceToolName: toolName, + }); + if (cacheUri) { + resultForLlm._cache_uri = cacheUri; + } + } } // Truncate large results before storing in conversation history. @@ -965,6 +994,15 @@ export async function processToolCalls( summary._engine_dispatched = dispatchedUuids; summary._truncated = true; summary._originalChars = originalLen; + // Plan 2026-05-18-002 Unit 2 — preserve `_cache_uri` into the + // truncation summary so the LLM still receives the reference + // even when the bulk payload was dropped. This is exactly the + // case where the LLM most needs the cache URI (data was too + // large to fit in the per-tool-result cap; ref-by-URI is the + // only viable way to pass it forward). + if (resultForLlm._cache_uri) { + summary._cache_uri = resultForLlm._cache_uri; + } resultContent = JSON.stringify(summary); } else { // K1 — non-object results never gain `_engine_dispatched`; keep @@ -1043,7 +1081,16 @@ export async function runChatSession({ repairMessageBuilder = null, beforeFirstMessage = null, afterToolExecution = null, + + // MCP result-by-reference protocol (plan 2026-05-18-002). Default off + // so npm consumers of @aquaveo/chatbox-core that don't opt in inherit + // no behavior change. tethysapp-tethys_dash's sets these + // explicitly via Unit 4's host prop. + enableResultCache = false, + conversationId = "default", }) { + const cacheOptions = { enabled: !!enableResultCache, conversationId }; + const state = { lastChartResult: null, lastQueryResult: null, @@ -1240,7 +1287,10 @@ export async function runChatSession({ // with per-tool start/complete events fired from inside processToolCalls. let { hadError, lastErr, failedSignatures } = await processToolCalls( toolCalls, messages, connections, toolServerMap, state, text, - { toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus }, + { + toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus, + cacheOptions, + }, ); // Extension point: early return for terminal results @@ -1359,7 +1409,10 @@ export async function runChatSession({ // processToolCalls; no per-round toggle needed here. ({ hadError, lastErr, failedSignatures } = await processToolCalls( repairCalls, messages, connections, toolServerMap, state, text, - { toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus }, + { + toolCategories, beforeToolExecution, toolErrorCheck, afterToolExecution, onToolStatus, + cacheOptions, + }, )); for (const sig of failedSignatures) { From 156bb2042f178408d95d393f50e5cfc620c36b07 Mon Sep 17 00:00:00 2001 From: romer8 Date: Mon, 18 May 2026 20:05:50 -0600 Subject: [PATCH 3/5] =?UTF-8?q?feat(engine):=20URI=20substitution=20layer?= =?UTF-8?q?=20in=20processToolCalls=20=E2=80=94=20resolves=20*=5Furi=20arg?= =?UTF-8?q?s=20from=20cache=20before=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md Unit 3. The substitution layer is the load-bearing piece of the result-by- reference protocol. When the LLM emits a tool call with a `*_uri` arg matching the `mcp+cache://` scheme, chatbox-core resolves the URI from IndexedDB (Unit 1), substitutes the cached payload into the corresponding non-`_uri` arg, drops the `_uri` arg, and dispatches the call with inline data. The server tool sees the resolved data and runs unchanged — no MCP wire-contract changes for receivers that don't opt in. Three integration choices: 1. Substitution runs BEFORE the existing `_substituteLastUuidPlaceholders` walk in processToolCalls. The two layers cover different patterns (open-vocabulary `*_uri` vs closed-vocabulary `{{last__uuid}}`) and ordering is explicit so a future URI value that happens to match a placeholder shape gets resolved as URI first. 2. Substitution is gated on `cacheOptions.enabled` (Unit 2's host prop). When disabled, the URI passes through verbatim and the receiving tool's own validation rejects it — no half-resolved state when the feature is off. 3. Cache miss short-circuits dispatch. The tool is NOT called; an `invalid_args` envelope with `_missing_uris` + `fix_hint` is pushed as the tool result so the LLM gets a recoverable error matching the existing input-validation-middleware shape. No auto-retry — the LLM must surface to the user (per the v1 cache- miss policy locked in plan refinement). Conflict resolution: if the LLM passes BOTH `data` AND `data_uri`, URI wins, inline is dropped, console.info fires for telemetry. Treats both-set as an LLM bug worth surfacing in metrics but not worth failing the call. Array URIs supported: `layers_uri: [u1, u2, u3]` resolves each URI and substitutes `layers: [p1, p2, p3]`. Any miss aborts the substitution with all missing URIs named in the envelope's `_missing_uris` list. Tests: 11 new tests in uri-substitution.test.js — 8 direct unit tests on substituteCacheUris (happy path scalar + array, conflict, miss scalar + array, non-cache URI scheme ignored, non-object args graceful) plus 3 integration tests through processToolCalls (resolved-arg dispatch, miss short-circuit, disabled pass-through). Full suite: 484 → 495 passed. --- engine/index.js | 36 ++++ engine/uri-substitution.js | 151 ++++++++++++++++ engine/uri-substitution.test.js | 303 ++++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 engine/uri-substitution.js create mode 100644 engine/uri-substitution.test.js diff --git a/engine/index.js b/engine/index.js index d880d8c..3ad667b 100644 --- a/engine/index.js +++ b/engine/index.js @@ -29,6 +29,7 @@ import { } from "./transports.js"; import { ERROR_KEYS } from "./mcpErrors.js"; import { cacheToolResult } from "./cache.js"; +import { substituteCacheUris } from "./uri-substitution.js"; import { trimConversation } from "../conversation/index.js"; import { buildGenericSystemMessage } from "../messages/index.js"; import { @@ -744,6 +745,41 @@ export async function processToolCalls( if (preResult?.toolName) toolName = preResult.toolName; } + // Plan 2026-05-18-002 Unit 3 — substitute `*_uri` args against the + // IndexedDB cache BEFORE the existing UUID-placeholder walk. The two + // layers cover different patterns: this one is open-vocabulary + // (any arg ending in `_uri`); the next one is closed-vocabulary + // (the 5 enumerated `{{last__uuid}}` tokens). Ordering is + // explicit so a future tool whose URI value happens to match a + // placeholder shape gets resolved as URI first. + if (cacheOptions?.enabled && args && typeof args === "object") { + const subResult = await substituteCacheUris(args); + if (!subResult.ok) { + // Cache miss — short-circuit dispatch and push the envelope as + // the tool result. The LLM gets an `invalid_args`-shaped error + // with `_missing_uris` + a fix_hint directing it to re-call the + // source tool. Mirrors the input-validation middleware recovery + // pattern; LLMs already know how to interpret these envelopes. + const missEnvelope = subResult.envelope; + fireStatus({ type: "tool_start", toolName }); + fireStatus({ type: "tool_complete", toolName, success: false }); + messages.push({ + role: "tool", + tool_call_id: toolCall.id || toolName, + tool_name: toolName, + content: JSON.stringify({ + ...missEnvelope, + _engine_dispatched: [], + }), + }); + hadError = true; + lastErr = new Error(missEnvelope.error); + failedSignatures.push(`${toolName}|cache-miss|${JSON.stringify(missEnvelope._missing_uris)}`); + continue; + } + args = subResult.args; + } + // Plan 2026-05-07-002 Unit B: substitute `{{last__uuid}}` // placeholders against engine-tracked UUIDs *before* dispatching. // Runs after `beforeToolExecution` so any domain-specific arg diff --git a/engine/uri-substitution.js b/engine/uri-substitution.js new file mode 100644 index 0000000..65313c4 --- /dev/null +++ b/engine/uri-substitution.js @@ -0,0 +1,151 @@ +/** + * engine/uri-substitution.js — Unit 3 of the MCP result-by-reference protocol + * (plan 2026-05-18-002). + * + * Walks an outgoing tool-call's args. For each arg whose name ends in + * `_uri` AND whose value is an `mcp+cache://` URI (or an array thereof), + * resolves the URI(s) against the IndexedDB cache (Unit 1) and substitutes + * the resolved payload into the corresponding non-`_uri` arg before + * dispatch. The receiving server tool then sees inline data — never the + * URI — so the existing tool body and validation work unchanged. + * + * Conflict resolution (URI + inline both set): URI wins, inline is + * dropped, an INFO-level console log fires. Per plan, this treats + * both-set as an LLM bug worth surfacing in metrics but not worth + * failing the call. + * + * Cache miss: returns a structured `invalid_args` envelope (same shape + * as the input-validation middleware on the server side). The caller + * short-circuits dispatch and pushes the envelope as the tool result + * so the LLM gets a recoverable error envelope with a `fix_hint` + * directing it to re-fetch the source tool. + */ + +import { CACHE_URI_SCHEME, readCachedPayload } from "./cache.js"; + +/** + * Attempt to substitute every `*_uri` arg in `args` against the cache. + * + * Returns one of: + * - `{ ok: true, args: }` on success (no misses, or no + * URI args present at all). + * - `{ ok: false, envelope: }` if any URI arg + * was present but couldn't be resolved. + * + * The args object is never mutated in place — substitution returns a + * shallow copy with the `*_uri` keys removed and the corresponding + * non-`_uri` keys populated with resolved payloads. + */ +export async function substituteCacheUris(args) { + if (!args || typeof args !== "object" || Array.isArray(args)) { + return { ok: true, args }; + } + + const next = { ...args }; + let touched = false; + const misses = []; + + for (const key of Object.keys(args)) { + if (!key.endsWith("_uri")) continue; + const value = args[key]; + const targetKey = key.slice(0, -"_uri".length); // e.g., "data_uri" → "data" + + // Detect: scalar URI string, array of URI strings, or unrecognized value. + if (typeof value === "string" && value.startsWith(CACHE_URI_SCHEME)) { + const payload = await readCachedPayload(value); + if (payload === null) { + misses.push(value); + continue; + } + maybeWarnConflict(next, targetKey); + next[targetKey] = payload; + delete next[key]; + touched = true; + } else if ( + Array.isArray(value) && + value.every( + (v) => typeof v === "string" && v.startsWith(CACHE_URI_SCHEME), + ) + ) { + // Array of URIs — resolve each. Any miss aborts the substitution + // with the FIRST missing URI named in the envelope (plan: cache-miss + // on any list element returns invalid_args naming the specific + // missing URI). We collect all misses for the envelope's + // `_missing_uris` list so the LLM gets the full picture. + const resolved = []; + let listOk = true; + for (const uri of value) { + const payload = await readCachedPayload(uri); + if (payload === null) { + misses.push(uri); + listOk = false; + } else if (listOk) { + resolved.push(payload); + } + } + if (listOk) { + maybeWarnConflict(next, targetKey); + next[targetKey] = resolved; + delete next[key]; + touched = true; + } + // If listOk === false, leave args alone for this key — caller will + // see misses array and emit the envelope. + } + // Else: value isn't a recognized cache URI shape; leave it alone. + // Caller's downstream validation will reject if the value is otherwise + // invalid. We don't fail here on shape because a tool author may legitimately + // have a `*_uri` arg that accepts non-cache URIs (e.g., `image_uri: + // "https://..."`); the cache layer only claims `mcp+cache://`. + } + + if (misses.length > 0) { + return { + ok: false, + envelope: buildCacheMissEnvelope(misses), + }; + } + + return { ok: true, args: touched ? next : args }; +} + +/** + * Conflict log: the LLM set BOTH `data` and `data_uri` (or any `*_uri` + + * its corresponding inline name). URI wins; we drop the inline value + * silently but fire a console.info so the host can surface telemetry. + * + * Mutates `next` to remove the inline key when present — caller will then + * overwrite the same slot with the resolved payload. + */ +function maybeWarnConflict(next, targetKey) { + if (Object.prototype.hasOwnProperty.call(next, targetKey)) { + console.info( + `[chatbox-core cache] conflict: both '${targetKey}' and '${targetKey}_uri' set on tool call. URI wins; inline value dropped.`, + ); + delete next[targetKey]; + } +} + +/** + * Build the invalid_args envelope the engine surfaces to the LLM on + * cache miss. Shape mirrors the input-validation-middleware envelope so + * the LLM's existing recovery pattern applies. + */ +function buildCacheMissEnvelope(missingUris) { + const first = missingUris[0]; + const isArrayMiss = missingUris.length > 1; + return { + error: isArrayMiss + ? `invalid_args: ${missingUris.length} cache URIs could not be resolved` + : `invalid_args: cache URI ${first} could not be resolved`, + _missing_uris: missingUris, + fix_hint: + "The cached result(s) referenced by this call's `*_uri` arg have " + + "been evicted or were never minted. Re-call the source tool that " + + "originally produced this data (the tool result envelope will " + + "carry a fresh `_cache_uri` you can pass to this call). If the " + + "user just refreshed the page or switched dashboards, ask them to " + + "confirm before re-fetching, since the source tool may incur " + + "cost or take time.", + }; +} diff --git a/engine/uri-substitution.test.js b/engine/uri-substitution.test.js new file mode 100644 index 0000000..6da572b --- /dev/null +++ b/engine/uri-substitution.test.js @@ -0,0 +1,303 @@ +/** + * engine/uri-substitution.test.js — coverage for Unit 3 of the MCP + * result-by-reference protocol (plan 2026-05-18-002). + * + * Two test surfaces: + * + * 1. `substituteCacheUris(args)` direct unit tests — happy path + * (scalar URI, array of URIs), conflict resolution, cache miss + * envelope shape, ignored-shape pass-through. + * + * 2. Integration through `processToolCalls` — end-to-end exercise that + * a tool call with `data_uri: ` dispatches with `data: + * [...resolved...]` and that a cache miss short-circuits dispatch + * with the LLM-visible `invalid_args` envelope. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import "fake-indexeddb/auto"; + +import { processToolCalls } from "./index.js"; +import { + __resetDbForTests, + cacheToolResult, + CACHE_URI_SCHEME, +} from "./cache.js"; +import { substituteCacheUris } from "./uri-substitution.js"; +import { makeFakeClient } from "../test-helpers/fakeConn.js"; + +// --------------------------------------------------------------------------- +// Per-test DB reset (same fixture as cache-instrumentation.test.js) +// --------------------------------------------------------------------------- + +beforeEach(async () => { + __resetDbForTests(); + await new Promise((resolve, reject) => { + const open = globalThis.indexedDB.open("chatbox-core-result-cache", 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("results")) { + const store = db.createObjectStore("results", { keyPath: "uri" }); + store.createIndex("convId", "convId", { unique: false }); + store.createIndex("addedAt", "addedAt", { unique: false }); + } + }; + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("results", "readwrite"); + tx.objectStore("results").clear(); + tx.oncomplete = () => { + db.close(); + __resetDbForTests(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + }); +}); + +// --------------------------------------------------------------------------- +// substituteCacheUris — direct unit tests +// --------------------------------------------------------------------------- + +describe("substituteCacheUris(args)", () => { + // Test fixture: pre-cache a known payload and return its URI. + async function seedCache(payload, convId = "test-conv") { + return cacheToolResult({ + payload, + convId, + sourceToolName: "seed", + threshold: 1, // force write even for tiny payloads in tests + }); + } + + it("passes args through unchanged when no `_uri` keys are present", async () => { + const args = { data: [{ x: 1 }], layout: { title: "t" } }; + const result = await substituteCacheUris(args); + expect(result.ok).toBe(true); + expect(result.args).toBe(args); // identity — no copy needed + }); + + it("resolves a scalar `data_uri` and populates `data` with the payload", async () => { + const payload = { rows: [{ time: "t0", flow: 1.5 }] }; + const uri = await seedCache(payload); + expect(uri).not.toBeNull(); + + const result = await substituteCacheUris({ + data_uri: uri, + layout: { title: "Flow" }, + }); + + expect(result.ok).toBe(true); + expect(result.args.data).toEqual(payload); + expect(result.args.data_uri).toBeUndefined(); + expect(result.args.layout).toEqual({ title: "Flow" }); + }); + + it("resolves an array of URIs", async () => { + const p1 = { layer: "a" }; + const p2 = { layer: "b" }; + const u1 = await seedCache(p1); + const u2 = await seedCache(p2); + + const result = await substituteCacheUris({ + layers_uri: [u1, u2], + map_uuid: "abc", + }); + + expect(result.ok).toBe(true); + expect(result.args.layers).toEqual([p1, p2]); + expect(result.args.layers_uri).toBeUndefined(); + }); + + it("conflict: both inline and _uri set → URI wins, inline dropped, console.info fires", async () => { + const payload = { rows: [{ x: 1 }] }; + const uri = await seedCache(payload); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + + const result = await substituteCacheUris({ + data: [{ x: "stale-inline" }], + data_uri: uri, + }); + + expect(result.ok).toBe(true); + expect(result.args.data).toEqual(payload); // URI's payload won + expect(result.args.data_uri).toBeUndefined(); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining("conflict: both 'data' and 'data_uri' set"), + ); + infoSpy.mockRestore(); + }); + + it("cache miss (scalar URI not in store) → invalid_args envelope", async () => { + const result = await substituteCacheUris({ + data_uri: "mcp+cache://nope/aaaaaaaaaaa", + }); + expect(result.ok).toBe(false); + expect(result.envelope.error).toMatch(/cache URI .* could not be resolved/); + expect(result.envelope._missing_uris).toEqual([ + "mcp+cache://nope/aaaaaaaaaaa", + ]); + expect(result.envelope.fix_hint).toMatch(/Re-call the source tool/i); + }); + + it("cache miss (array URI: one missing) → envelope names the missing URI", async () => { + const p1 = { layer: "a" }; + const u1 = await seedCache(p1); + const missingUri = "mcp+cache://nope/bbbbbbbbbbb"; + + const result = await substituteCacheUris({ + layers_uri: [u1, missingUri], + }); + + expect(result.ok).toBe(false); + expect(result.envelope._missing_uris).toContain(missingUri); + }); + + it("ignores `*_uri` arg with non-cache URI scheme (e.g., https://...)", async () => { + const result = await substituteCacheUris({ + image_uri: "https://example.com/x.png", + title: "t", + }); + // Not a cache URI — substitution leaves it alone. Caller's downstream + // validation will accept or reject the URL on its own. + expect(result.ok).toBe(true); + expect(result.args.image_uri).toBe("https://example.com/x.png"); + }); + + it("handles non-object args gracefully (string / null / array)", async () => { + expect((await substituteCacheUris(null)).ok).toBe(true); + expect((await substituteCacheUris("hello")).ok).toBe(true); + expect((await substituteCacheUris([1, 2, 3])).ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: processToolCalls end-to-end with URI substitution +// --------------------------------------------------------------------------- + +function makeFreshState() { + return { + lastChartResult: null, + lastQueryResult: null, + lastQuerySQL: null, + lastListResult: null, + lastMapResult: null, + lastHydrofabricResult: null, + pendingVisualizations: [], + pendingLayerUpdates: [], + pendingPatches: [], + rejectedPatches: [], + toolCallsThisTurn: [], + }; +} + +function makeToolCall(name, args = {}, id = `call-${name}`) { + return { id, function: { name, arguments: args } }; +} + +describe("processToolCalls — Unit 3 substitution integration", () => { + it("URI in tool-call args → dispatched call sees resolved inline data", async () => { + const cachedData = [{ time: "2026-05-18T00:00:00Z", flow: 18.5 }]; + const uri = await cacheToolResult({ + payload: cachedData, + convId: "conv-3", + sourceToolName: "query_data", + threshold: 1, + }); + expect(uri).not.toBeNull(); + + // Capture what the chart tool receives as args. + const receivedArgs = { value: null }; + const callTool = vi.fn(async ({ name, arguments: args }) => { + receivedArgs.value = args; + return { data: { visualization: { uuid: "viz-1", source: "Inline Plotly", vizType: "plotly" } } }; + }); + const client = makeFakeClient({ callToolImpl: callTool }); + const connections = [{ client, transport: null, protocolUsed: "http" }]; + const toolServerMap = new Map([["create_chart", 0]]); + const messages = []; + + await processToolCalls( + [makeToolCall("create_chart", { data_uri: uri, layout: { title: "X" } })], + messages, + connections, + toolServerMap, + makeFreshState(), + "", + { + cacheOptions: { enabled: true, conversationId: "conv-3" }, + }, + ); + + // The chart tool received inline data, NOT the URI. + expect(receivedArgs.value.data).toEqual(cachedData); + expect(receivedArgs.value.data_uri).toBeUndefined(); + expect(receivedArgs.value.layout).toEqual({ title: "X" }); + }); + + it("URI cache miss → tool dispatch short-circuits with invalid_args envelope", async () => { + const callTool = vi.fn(); // should NEVER be called + const client = makeFakeClient({ callToolImpl: callTool }); + const connections = [{ client, transport: null, protocolUsed: "http" }]; + const toolServerMap = new Map([["create_chart", 0]]); + const messages = []; + + await processToolCalls( + [makeToolCall("create_chart", { data_uri: "mcp+cache://nope/aaaaaaaaaaa" })], + messages, + connections, + toolServerMap, + makeFreshState(), + "", + { + cacheOptions: { enabled: true, conversationId: "conv-3" }, + }, + ); + + // Tool was NOT dispatched. + expect(callTool).not.toHaveBeenCalled(); + + // The LLM-visible message carries the invalid_args envelope. + const toolMsg = messages.find((m) => m.role === "tool"); + expect(toolMsg).toBeDefined(); + const parsed = JSON.parse(toolMsg.content); + expect(parsed.error).toMatch(/cache URI .* could not be resolved/); + expect(parsed._missing_uris).toEqual(["mcp+cache://nope/aaaaaaaaaaa"]); + expect(parsed.fix_hint).toMatch(/Re-call the source tool/i); + }); + + it("cacheOptions.enabled=false: URI passes through unchanged to the tool", async () => { + // When the cache is disabled at the host level, the URI substitution + // layer also doesn't run — the LLM-emitted URI string flows through + // verbatim. The receiving tool's own validation will reject it + // (e.g., the Pydantic regex on data_uri). This pins the gate's + // effect: no half-resolved state when the feature is off. + const receivedArgs = { value: null }; + const callTool = vi.fn(async ({ arguments: args }) => { + receivedArgs.value = args; + return { data: { ok: true } }; + }); + const client = makeFakeClient({ callToolImpl: callTool }); + const connections = [{ client, transport: null, protocolUsed: "http" }]; + const toolServerMap = new Map([["create_chart", 0]]); + const messages = []; + + await processToolCalls( + [makeToolCall("create_chart", { data_uri: "mcp+cache://x/y" })], + messages, + connections, + toolServerMap, + makeFreshState(), + "", + { + // cacheOptions omitted → defaults { enabled: false, ... } + }, + ); + + // Tool received the URI verbatim — no substitution. + expect(receivedArgs.value).toEqual({ data_uri: "mcp+cache://x/y" }); + }); +}); From f4ca24bf50e019f6022366d059cac5dab42de88b Mon Sep 17 00:00:00 2001 From: romer8 Date: Mon, 18 May 2026 20:09:14 -0600 Subject: [PATCH 4/5] feat(Chatbox): expose enableResultCache + conversationId props for result-by-reference protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md Unit 4. Threads the host-side opt-in for the MCP result-by-reference protocol through Chatbox.jsx to runChatSession. Both props default off so npm consumers of @aquaveo/chatbox-core that don't know about the feature inherit no behavior change — the cache write site (Unit 2) and the URI substitution layer (Unit 3) stay fully dormant. New props: - `enableResultCache: boolean` (default false) — when true, oversized tool results are auto-cached in IndexedDB (Unit 1) and the LLM receives `_cache_uri` markers it can pass forward as `*_uri` args. - `conversationId: string` (default "default") — used as the conv-id segment of minted cache URIs and as the scope for clearConversation (called by the host on dashboard switch / chat reset). Wiring: - Props declared in the Chatbox component destructure - Forwarded into the runChatSession call alongside the existing engineExtensions spread - Added to the useCallback dependency array so the callback re-creates when either prop changes (e.g., user switches dashboards and the host passes a new conversationId) tethysapp-tethys_dash's will set `enableResultCache={true} conversationId={dashboardUuid}` in a follow-up PR — that's the host-side activation. Until then, the chatbox-core feature ships fully dormant on the npm package. No new tests in this unit — the prop wiring is mechanical and is covered end-to-end by Unit 3's integration tests (which exercise processToolCalls with cacheOptions). Full suite: 495 passed. --- components/Chatbox.jsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/components/Chatbox.jsx b/components/Chatbox.jsx index 77fd6cc..8dce7b3 100644 --- a/components/Chatbox.jsx +++ b/components/Chatbox.jsx @@ -256,6 +256,15 @@ export default function Chatbox({ // `initialMessages` is read fresh from the new context. initialMessages = [], onMessagesChange, + // Plan 2026-05-18-002 — MCP result-by-reference protocol opt-in. + // Default false so npm consumers of @aquaveo/chatbox-core that don't + // know about the feature inherit no behavior change. tethysapp-tethys_dash + // sets `enableResultCache={true}` + `conversationId={dashboardUuid}` on + // its mount. When enabled, oversized tool results are + // cached in IndexedDB and the LLM receives `_cache_uri` markers it can + // pass forward as `*_uri` args to subsequent tool calls. + enableResultCache = false, + conversationId = "default", }) { const isEmbedded = typeof updateVariableInputValues === "function"; const [messages, setMessages] = useState(initialMessages); @@ -672,6 +681,12 @@ export default function Chatbox({ providerConfig, ...(csrfToken ? { csrfToken } : {}), mcpServers: allMcpServers, + // Plan 2026-05-18-002 — MCP result-by-reference protocol opt-in. + // Both props default off so npm consumers that don't pass them + // inherit no behavior change. tethysapp-tethys_dash sets + // `enableResultCache` + `conversationId={dashboardUuid}` explicitly. + enableResultCache, + conversationId, // Inject domain-specific extensions (empty for generic sidebar) ...engineExtensions, onToolStatus: (status) => { @@ -971,7 +986,7 @@ export default function Chatbox({ setThinkingBuffer(""); setContentBuffer(""); } - }, [input, loading, selectedModel, isThinkingEnabled, contextUsage.total, providerConfig, csrfToken, allMcpServers, isEmbedded, updateVariableInputValues, engineExtensions, onResult, resolveVisualizationUrl]); + }, [input, loading, selectedModel, isThinkingEnabled, contextUsage.total, providerConfig, csrfToken, allMcpServers, isEmbedded, updateVariableInputValues, engineExtensions, onResult, resolveVisualizationUrl, enableResultCache, conversationId]); const hasMessages = messages.length > 0 || loading; From 96eac02e9b0b786be37a5f8ba81fc7fc1292d636 Mon Sep 17 00:00:00 2001 From: romer8 Date: Mon, 18 May 2026 20:26:13 -0600 Subject: [PATCH 5/5] docs(changelog): MCP result-by-reference protocol entry for next release --- CHANGELOG.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1256a07..2cf6f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **MCP result-by-reference protocol (Units 1-4).** New `` opt-in adds an IndexedDB-backed cache for + oversized tool results plus a substitution layer in `processToolCalls` + that resolves `*_uri` args to inline data before dispatch. Eliminates + the LLM transcription bottleneck observed in production 2026-05-18 + where a 240-row time-series array took ~127s of LLM output tokens to + regenerate between two MCP servers. + + - **Auto-cache heuristic:** tool results whose serialized size + exceeds `MAX_TOOL_RESULT_CHARS` (4 KB, matching v0.6.4's truncation + cap) are written to IndexedDB keyed by an + `mcp+cache:///<8-byte-base64url>` URI. The URI is surfaced + to the LLM as `_cache_uri` on the tool result envelope. + - **Substitution at dispatch:** when a subsequent tool call has any + arg ending in `_uri` whose value matches the `mcp+cache://` scheme, + chatbox-core looks up the cached payload and substitutes it into + the corresponding non-`_uri` arg before dispatching. The receiving + server tool sees inline data — no MCP wire-contract change needed. + - **Array URIs supported:** `layers_uri: [u1, u2, u3]` resolves each + URI and substitutes `layers: [p1, p2, p3]`. + - **Conflict resolution:** if the LLM passes BOTH `data` and + `data_uri`, URI wins, inline dropped, console.info logged. + - **Cache miss:** returns `invalid_args` envelope with `_missing_uris` + + `fix_hint`; tool dispatch short-circuits. No auto-retry in v1. + - **Per-mount opt-in:** `` default + so npm consumers that don't opt in inherit zero behavior change. + - **Truncation-summary preservation:** when the bulk payload was + dropped by v0.6.4's truncation pass, the `_cache_uri` is still + surfaced in the summary so the LLM has the reference even when + the data was too large to fit in the per-tool-result cap. + + New host props on ``: + - `enableResultCache: boolean` (default `false`) + - `conversationId: string` (default `"default"`) — used as the conv-id + segment of minted URIs and as the scope for `clearConversation` + (host calls this on dashboard switch / chat reset). + + New engine surface in `engine/cache.js`: + - `cacheToolResult({payload, convId, sourceToolName, threshold})` + - `readCachedPayload(uri)` + - `clearConversation(convId)` + - `evictOlderThan({maxAgeMs})` + - `mintCacheUri(convId)`, `estimateSize(payload)`, `hasIndexedDB()` + + Plan: `docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md` + in the firoh workspace. + + Companion: receiving side ships in + `Aquaveo/tethysdash_mcps` PR #7 — `data_uri` opt-in on + `create_plotly_chart`, `create_data_table`, `create_card`. + + Suite: 463 → 495 passed (+32 new tests across `engine/cache.test.js`, + `engine/cache-instrumentation.test.js`, `engine/uri-substitution.test.js`). + ### Removed (BREAKING — pre-1.0 acceptable) - **`resolveModelCapability` from `engine/index.js`** and the entire