Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Chatbox
enableResultCache>` 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://<conv-id>/<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:** `<Chatbox enableResultCache={false}>` 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 `<Chatbox>`:
- `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
Expand Down
17 changes: 16 additions & 1 deletion components/Chatbox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ChatSidebar> 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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;

Expand Down
246 changes: 246 additions & 0 deletions engine/cache-instrumentation.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading