From 4b3f519c5d6d8f912b34862cc31a59125e2a89be Mon Sep 17 00:00:00 2001 From: paoloredis Date: Thu, 24 Sep 2026 16:02:06 +0200 Subject: [PATCH 01/10] DOC-7113: Add a scripted Context Retriever interactive demo Adds a browser-only demo page for Redis Context Retriever, to promote the Redis Iris GA. Sample data from a fictional food delivery app (customers, restaurants, orders) is stored as JSON keys. The reader edits the data model, watches the MCP tools regenerate, and then watches a scripted agent answer six questions over JSON-RPC tools/call. No service is called; results are computed in the browser from the sample data. Tool names, argument schemas, and response envelopes copy what a live Context Retriever service returned. Relationship names and text/numeric conditions on the per-entity filter tool are modelled, not observed. - New page under develop/ai/context-engine/context-retriever, linked from the section overview. - New context-retriever-demo shortcode with its own JS and CSS. Co-Authored-By: Claude Opus 5.5 --- .../context-retriever/_index.md | 2 + .../context-retriever/interactive-demo.md | 31 + .../shortcodes/context-retriever-demo.html | 20 + static/css/context-retriever-demo.css | 189 ++++ static/js/context-retriever-demo.js | 805 ++++++++++++++++++ 5 files changed, 1047 insertions(+) create mode 100644 content/develop/ai/context-engine/context-retriever/interactive-demo.md create mode 100644 layouts/shortcodes/context-retriever-demo.html create mode 100644 static/css/context-retriever-demo.css create mode 100644 static/js/context-retriever-demo.js diff --git a/content/develop/ai/context-engine/context-retriever/_index.md b/content/develop/ai/context-engine/context-retriever/_index.md index b5e83bac30..2ca5e75ae0 100644 --- a/content/develop/ai/context-engine/context-retriever/_index.md +++ b/content/develop/ai/context-engine/context-retriever/_index.md @@ -70,6 +70,8 @@ Use the `ctxctl` CLI, the Python client, or the Redis Cloud UI to model your ent See the [Redis Cloud setup guide](/content/operate/iris/context-retriever/create-service.md) to create your first Context Retriever service. +To see how a data model becomes tools before you create a service, try the [interactive demo](/content/develop/ai/context-engine/context-retriever/interactive-demo.md). + Redis Context Retriever helps teams expose operational context to AI agents through schema-first retrieval. It models the entities, fields, keys, and relationships that matter to an agent workflow, then presents that context through a governed tool surface the agent can call at runtime. Context Retriever helps an AI Agent understand what business objects exist, how they connect, and which paths are safe to use. ## Overview diff --git a/content/develop/ai/context-engine/context-retriever/interactive-demo.md b/content/develop/ai/context-engine/context-retriever/interactive-demo.md new file mode 100644 index 0000000000..f07a1b956e --- /dev/null +++ b/content/develop/ai/context-engine/context-retriever/interactive-demo.md @@ -0,0 +1,31 @@ +--- +Title: Context Retriever interactive demo +alwaysopen: false +categories: +- docs +- develop +- ai +description: See how Context Retriever turns the keys in a Redis database into MCP tools that an agent calls to answer questions. +linkTitle: Interactive demo +weight: 20 +hideListLinks: true +--- + +Context Retriever reads a data model that you define once, and generates the tools an agent uses to query your data. The agent calls those tools over MCP (Model Context Protocol) and never connects to Redis itself. + +This demo runs in your browser with sample data from a food delivery app: customers, restaurants, and orders. It doesn't connect to a Context Retriever service, but the tool names, arguments, and responses follow the same shape a real service returns. + +{{< context-retriever-demo >}} + +## What the demo shows + +1. **Keys in Redis.** Each business object is a JSON document, and its key name starts with the object type, such as `restaurant:r201`. +2. **Data model to tools.** A key template such as `restaurant:{id}` tells Context Retriever which keys belong to an entity. The fields you index decide which tools it generates and which arguments those tools accept. +3. **Ask over MCP.** The agent lists the tools, picks one or more for each question, and combines the results into an answer. + +Try removing the index from a field in step 2, then ask a question that needs it. The server rejects the call, because the tool no longer accepts that field. + +## Next steps + +- [Create a Context Retriever service](/content/operate/iris/context-retriever/create-service.md) on Redis Cloud. +- Read the [Context Retriever overview](/content/develop/ai/context-engine/context-retriever/_index.md). diff --git a/layouts/shortcodes/context-retriever-demo.html b/layouts/shortcodes/context-retriever-demo.html new file mode 100644 index 0000000000..21ab600f05 --- /dev/null +++ b/layouts/shortcodes/context-retriever-demo.html @@ -0,0 +1,20 @@ +{{- /* + context-retriever-demo + ---------------------- + Scripted, dependency-free demo of Redis Context Retriever: sample customer + keys, a data model that generates MCP tools, and a scripted agent calling + those tools. Runs entirely in the browser; no service is called. + + Usage (no arguments): + {{< context-retriever-demo >}} +*/ -}} + +{{- if not (.Page.Store.Get "rcrAssetsEmitted") -}} + + +{{- .Page.Store.Set "rcrAssetsEmitted" true -}} +{{- end -}} + +
+ +
diff --git a/static/css/context-retriever-demo.css b/static/css/context-retriever-demo.css new file mode 100644 index 0000000000..9202180321 --- /dev/null +++ b/static/css/context-retriever-demo.css @@ -0,0 +1,189 @@ +/* ========================================================================= + Redis Context Retriever scripted demo + Scoped under `.rcr`. Palette uses the Redis design tokens, matching + vector-playground.css. + ========================================================================= */ + +.rcr { + --rcr-ink: #091a23; + --rcr-muted: #5c707a; + --rcr-faint: #8a99a0; + --rcr-line: #e8ebec; + --rcr-line-strong: #d9d9d9; + --rcr-surface: #ffffff; + --rcr-surface-2: #f9f9f9; + --rcr-code-bg: #f6f7f9; + --rcr-accent: #ff4438; + --rcr-accent-2: #5961ff; + --rcr-ok: #1a7f4b; + --rcr-bad: #c62828; + --rcr-focus: rgba(89, 97, 255, .28); + --rcr-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + container-type: inline-size; + color: var(--rcr-ink); + font-size: 15px; + line-height: 1.5; + margin: 1.5rem 0 2rem; + border: 1px solid var(--rcr-line); + border-radius: 12px; + background: var(--rcr-surface); + padding: 16px; +} +.rcr *, .rcr *::before, .rcr *::after { box-sizing: border-box; } +.rcr p { margin: 0; } +.rcr code, .rcr .rcr-mono { font-family: var(--rcr-mono); font-size: 12.5px; } +.rcr code { background: var(--rcr-code-bg); border-radius: 4px; padding: 1px 5px; } +.rcr button, .rcr select, .rcr input { font: inherit; color: inherit; } +.rcr button:focus-visible, .rcr select:focus-visible, .rcr input:focus-visible, .rcr summary:focus-visible { + outline: none; box-shadow: 0 0 0 3px var(--rcr-focus); border-radius: 6px; +} +.rcr [hidden] { display: none !important; } + +/* ---- tabs ---- */ +.rcr-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.rcr-tab { + display: inline-flex; align-items: center; gap: 8px; cursor: pointer; + background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); + border-radius: 999px; padding: 6px 14px; font-size: 13.5px; color: var(--rcr-muted); +} +.rcr-tab[aria-selected="true"] { background: var(--rcr-ink); border-color: var(--rcr-ink); color: #fff; } +.rcr-n { + width: 19px; height: 19px; border-radius: 50%; display: grid; place-items: center; + font-size: 11px; font-weight: 700; background: #fff; color: var(--rcr-accent-2); border: 1px solid var(--rcr-line-strong); +} +.rcr-tab[aria-selected="true"] .rcr-n { border-color: #fff; } + +.rcr-lede { color: var(--rcr-muted); margin-bottom: 14px !important; max-width: 70ch; } +.rcr-note { color: var(--rcr-muted); font-size: 13px; margin-top: 10px !important; } +.rcr-faint { color: var(--rcr-faint); font-size: 12px; } +.rcr-lbl { color: var(--rcr-faint); font-size: 11px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; } + +/* ---- layout ---- */ +.rcr-split { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; } +@container (min-width: 560px) { + .rcr-split { grid-template-columns: minmax(200px, 2fr) 3fr; } + .rcr .rcr-toolsbody { grid-template-columns: minmax(190px, 2fr) 3fr; } +} +.rcr-card { border: 1px solid var(--rcr-line); border-radius: 10px; background: var(--rcr-surface); padding: 12px; min-width: 0; } +.rcr-cardhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; font-size: 13.5px; margin-bottom: 8px; } +.rcr-count { background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); border-radius: 999px; padding: 0 8px; font-size: 11.5px; color: var(--rcr-muted); font-weight: 600; } +.rcr-next { display: flex; justify-content: flex-end; margin-top: 14px; } + +.rcr-btn { cursor: pointer; border-radius: 8px; padding: 7px 14px; border: 1px solid var(--rcr-line-strong); background: #fff; font-size: 13.5px; } +.rcr-btn-primary { background: var(--rcr-accent); border-color: var(--rcr-accent); color: #fff; font-weight: 600; } +.rcr-btn-primary:hover { filter: brightness(.95); } + +/* ---- code ---- */ +.rcr pre.rcr-code { + margin: 0; background: var(--rcr-code-bg) !important; border: 1px solid var(--rcr-line); border-radius: 8px; + padding: 10px 12px; font-family: var(--rcr-mono); font-size: 12.5px; line-height: 1.45; + overflow: auto; max-height: 360px; white-space: pre; color: var(--rcr-ink); +} +.rcr pre.rcr-code-sm { font-size: 11.5px; max-height: 260px; white-space: pre-wrap; overflow-wrap: anywhere; } +.rcr-jk { color: #3b44c9; } +.rcr-js { color: #1a7f4b; } +.rcr-jn { color: #b35900; } +.rcr-jl { color: #c62828; } + +/* ---- step 1: keys ---- */ +.rcr-keylist { max-height: 420px; overflow: auto; padding: 6px; } +.rcr-group summary { + display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 6px 8px; border-radius: 6px; list-style: none; +} +.rcr-group summary::-webkit-details-marker { display: none; } +.rcr-group summary::before { content: "\25B8"; color: var(--rcr-faint); font-size: 11px; } +.rcr-group[open] summary::before { content: "\25BE"; } +.rcr-group summary:hover { background: var(--rcr-surface-2); } +.rcr-type { margin-left: auto; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; color: var(--rcr-muted); background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); border-radius: 4px; padding: 0 5px; } +.rcr-type-json { color: var(--rcr-accent-2); } +.rcr-key { + display: block; width: 100%; text-align: left; cursor: pointer; background: none; border: 0; + padding: 3px 8px 3px 28px; border-radius: 6px; color: var(--rcr-muted); +} +.rcr-key:hover { background: var(--rcr-surface-2); color: var(--rcr-ink); } +.rcr-key.is-on { background: rgba(89, 97, 255, .09); color: var(--rcr-accent-2); } + +/* ---- step 2: model ---- */ +.rcr-subtabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--rcr-line); margin-bottom: 12px; } +.rcr-subtab { cursor: pointer; background: none; border: 0; border-bottom: 2px solid transparent; padding: 6px 10px; font-weight: 600; font-size: 13.5px; color: var(--rcr-muted); margin-bottom: -1px; } +.rcr-subtab[aria-selected="true"] { color: var(--rcr-ink); border-bottom-color: var(--rcr-accent); } +.rcr-entities { display: grid; gap: 12px; min-width: 0; } +.rcr-enthead { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; } +.rcr-entname { font-weight: 700; font-size: 15px; } +.rcr-tpl { display: inline-flex; align-items: center; gap: 6px; } +.rcr-input { border: 1px solid var(--rcr-line-strong); border-radius: 6px; padding: 3px 8px; width: 150px; background: #fff; } +.rcr-match { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 1px 9px; } +.rcr-match.is-ok { color: var(--rcr-ok); background: rgba(26, 127, 75, .09); } +.rcr-match.is-bad { color: var(--rcr-bad); background: rgba(198, 40, 40, .09); } +.rcr-fields { width: 100%; border-collapse: collapse; font-size: 13px; margin: 0; } +.rcr-fields th { text-align: left; color: var(--rcr-faint); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--rcr-line); } +.rcr-fields td { padding: 3px 6px; border-bottom: 1px solid var(--rcr-line); vertical-align: middle; } +.rcr-fields tr:last-child td { border-bottom: 0; } +.rcr-select { + -webkit-appearance: none; appearance: none; border: 1px solid var(--rcr-line-strong); border-radius: 6px; + padding: 1px 22px 1px 7px; font-size: 12.5px; cursor: pointer; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235c707a' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 7px center; +} +.rcr-pk { font-size: 10.5px; font-weight: 700; color: #fff; background: var(--rcr-accent-2); border-radius: 4px; padding: 0 5px; } +.rcr-rel { font-size: 12px; color: var(--rcr-accent-2); font-weight: 600; } +.rcr-rel::before { content: "\2192\00a0"; } + +.rcr-tools { margin-top: 12px; } +.rcr-toolsbody { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; } +.rcr-toollist { display: grid; gap: 2px; max-height: 330px; overflow: auto; align-content: start; } +.rcr-tool.is-mine .rcr-mono { font-weight: 700; } +.rcr-tooldetail { min-width: 0; } +.rcr-toolgroup { font-size: 11px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--rcr-faint); margin: 6px 0 2px; } +.rcr-tool { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; text-align: left; cursor: pointer; background: none; border: 0; border-radius: 6px; padding: 3px 8px; } +.rcr-tool:hover { background: var(--rcr-surface-2); } +.rcr-tool.is-on { background: rgba(89, 97, 255, .09); color: var(--rcr-accent-2); } +.rcr-tooldesc { font-size: 12.5px; color: var(--rcr-muted); margin-bottom: 8px !important; } +.rcr-flash { animation: rcr-flash .8s ease-out; } +@keyframes rcr-flash { from { box-shadow: 0 0 0 3px rgba(89, 97, 255, .35); } to { box-shadow: 0 0 0 0 rgba(89, 97, 255, 0); } } +.rcr-behind { margin-top: 12px; } +.rcr-behind summary { cursor: pointer; font-weight: 600; font-size: 13.5px; } +.rcr-behind .rcr-code { margin-top: 10px; } + +/* ---- step 3: ask ---- */ +.rcr-conn { margin-bottom: 12px; background: var(--rcr-surface-2); } +.rcr-connrow { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: center; } +.rcr-connrow code { background: #fff; overflow-wrap: anywhere; } +.rcr-connlog { margin-top: 10px; font-size: 13px; color: var(--rcr-muted); line-height: 1.9; } +.rcr-connlog code { margin-right: 2px; } +.rcr-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; margin-right: 3px; } +.rcr-dot.is-ok { background: var(--rcr-ok); } +.rcr-dot.is-bad { background: var(--rcr-bad); } +.rcr-dot.is-wait { background: var(--rcr-faint); animation: rcr-pulse 1s infinite; } +@keyframes rcr-pulse { 50% { opacity: .3; } } + +.rcr-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } +.rcr-chip { cursor: pointer; border: 1px solid var(--rcr-line-strong); background: #fff; border-radius: 999px; padding: 5px 12px; font-size: 13px; } +.rcr-chip:hover:not(:disabled) { border-color: var(--rcr-accent-2); color: var(--rcr-accent-2); } +.rcr-chip:disabled { opacity: .5; cursor: default; } + +.rcr-chat { display: grid; gap: 8px; } +.rcr-msg { max-width: 88%; border-radius: 12px; padding: 9px 13px; font-size: 14px; } +.rcr-msg p + p { margin-top: 6px; } +.rcr-msg ul { margin: 4px 0 0; padding-left: 18px; } +.rcr-msg li { margin: 2px 0; } +.rcr-user { justify-self: end; background: var(--rcr-ink); color: #fff; } +.rcr-agent { justify-self: start; background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); } +.rcr-thinking { display: inline-flex; gap: 4px; padding: 12px 14px; } +.rcr-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--rcr-faint); animation: rcr-pulse 1s infinite; } +.rcr-thinking span:nth-child(2) { animation-delay: .2s; } +.rcr-thinking span:nth-child(3) { animation-delay: .4s; } + +.rcr-call { border: 1px solid var(--rcr-line); border-left: 3px solid var(--rcr-accent-2); border-radius: 8px; background: #fff; max-width: 100%; min-width: 0; } +.rcr-call.is-err { border-left-color: var(--rcr-bad); } +.rcr-call > summary { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 7px 10px; list-style: none; min-width: 0; } +.rcr-call > summary::-webkit-details-marker { display: none; } +.rcr-callname { font-weight: 700; color: var(--rcr-accent-2); white-space: nowrap; } +.rcr-callargs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } +.rcr-status { margin-left: auto; font-size: 12px; color: var(--rcr-muted); white-space: nowrap; } +.rcr-callbody { padding: 0 10px 10px; display: grid; gap: 6px; } +.rcr-curl summary { cursor: pointer; font-size: 12.5px; color: var(--rcr-accent-2); margin: 4px 0; } + +@media (prefers-reduced-motion: reduce) { + .rcr-flash, .rcr-dot.is-wait, .rcr-thinking span { animation: none; } +} diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js new file mode 100644 index 0000000000..a0515be90d --- /dev/null +++ b/static/js/context-retriever-demo.js @@ -0,0 +1,805 @@ +/* ========================================================================= + Redis Context Retriever scripted demo + Dependency-free. Everything runs in the browser against an in-memory + sample dataset; no Context Retriever service is called. + + Tool names, argument shapes, descriptions, and response envelopes mirror + what a live Context Retriever MCP server returned (Sep 2026). Two parts are + modelled rather than observed: relationship names, and text_query / + numeric_conditions on the per-entity filter tool (observed on the set + tools only). + ========================================================================= */ +(function () { + "use strict"; + + /* ------------------------------------------------------------------ data */ + + /* A fictional food delivery app: customers, restaurants, and orders. */ + function ts(y, m, d, h) { return Math.floor(Date.UTC(y, m - 1, d, h || 12) / 1000); } + + var CUSTOMERS = [ + ["u101", "Maya Chen", "austin", "vegetarian", "thai", 1240, ts(2024, 5, 3)], + ["u102", "Liam Ortiz", "austin", "none", "bbq", 860, ts(2025, 1, 19)], + ["u103", "Priya Nair", "seattle", "vegan", "indian", 2310, ts(2023, 9, 12)], + ["u104", "Noah Williams", "denver", "none", "pizza", 410, ts(2025, 11, 2)], + ["u105", "Sofia Rossi", "seattle", "vegetarian", "italian", 1780, ts(2024, 2, 27)], + ["u106", "Ethan Brooks", "denver", "gluten_free", "sushi", 950, ts(2024, 8, 14)], + ["u107", "Aisha Khan", "austin", "halal", "mediterranean", 1530, ts(2023, 12, 6)], + ["u108", "Lucas Meyer", "denver", "none", "burgers", 120, ts(2026, 7, 21)] + ].map(function (r) { + return { id: r[0], name: r[1], email: r[1].toLowerCase().replace(/[^a-z]+/g, ".") + "@example.com", + city: r[2], dietary: r[3], favorite_cuisine: r[4], loyalty_points: r[5], joined_ts: r[6] }; + }); + + var RESTAURANTS = [ + ["r201", "Bangkok Street Kitchen", "thai", "austin", 4.7, 2, 30], + ["r202", "Smokehouse 512", "bbq", "austin", 4.4, 3, 40], + ["r203", "Green Bowl", "vegan", "seattle", 4.8, 2, 25], + ["r204", "Sakura Sushi Bar", "sushi", "denver", 4.6, 3, 35], + ["r205", "Nonna's Trattoria", "italian", "seattle", 4.5, 3, 45], + ["r206", "Slice Society", "pizza", "denver", 4.2, 1, 30], + ["r207", "Olive & Za'atar", "mediterranean", "austin", 4.6, 2, 30], + ["r208", "Spice Route", "indian", "seattle", 4.3, 2, 40], + ["r209", "Lotus Thai", "thai", "austin", 4.1, 1, 25] + ].map(function (r) { + return { id: r[0], name: r[1], cuisine: r[2], city: r[3], rating: r[4], price_level: r[5], avg_delivery_min: r[6] }; + }); + + var ORDERS = [ + ["o3001", "u101", "r201", "delivered", 34.5, ts(2026, 9, 2, 19), "Pad thai with tofu, spring rolls"], + ["o3002", "u101", "r207", "delivered", 28, ts(2026, 9, 10, 13), "Falafel wrap, hummus"], + ["o3003", "u101", "r209", "on_the_way", 22.75, ts(2026, 9, 24, 18), "Green curry without fish sauce, jasmine rice"], + ["o3004", "u102", "r202", "delivered", 61.2, ts(2026, 9, 5, 20), "Brisket platter, mac and cheese"], + ["o3005", "u102", "r201", "cancelled", 19.9, ts(2026, 8, 28, 12), "Pad see ew"], + ["o3006", "u103", "r203", "delivered", 24, ts(2026, 9, 12, 12), "Buddha bowl, oat latte"], + ["o3007", "u103", "r208", "delivered", 38.4, ts(2026, 9, 18, 19), "Chana masala, garlic naan"], + ["o3008", "u104", "r206", "delivered", 42, ts(2026, 9, 6, 20), "Two large pizzas"], + ["o3009", "u104", "r204", "delivered", 67.8, ts(2026, 9, 20, 19), "Omakase set for two"], + ["o3010", "u105", "r205", "delivered", 54.3, ts(2026, 9, 8, 20), "Mushroom risotto, tiramisu"], + ["o3011", "u105", "r203", "preparing", 21.5, ts(2026, 9, 24, 18), "Vegan lasagna"], + ["o3012", "u106", "r204", "delivered", 48.9, ts(2026, 9, 14, 19), "Salmon nigiri, miso soup"], + ["o3013", "u107", "r207", "delivered", 36.2, ts(2026, 9, 11, 13), "Chicken shawarma plate"], + ["o3014", "u107", "r201", "delivered", 26, ts(2026, 8, 30, 19), "Pad thai with chicken"], + ["o3015", "u108", "r206", "delivered", 18.5, ts(2026, 9, 15, 12), "Pepperoni slice combo"], + ["o3016", "u108", "r204", "on_the_way", 29.4, ts(2026, 9, 24, 18), "Spicy tuna roll, edamame"], + ["o3017", "u106", "r206", "delivered", 26, ts(2026, 9, 21, 20), "Gluten-free margherita"], + ["o3018", "u101", "r201", "delivered", 31, ts(2026, 9, 19, 19), "Pad thai with tofu, mango sticky rice"], + ["o3019", "u105", "r205", "delivered", 47.6, ts(2026, 9, 22, 20), "Eggplant parmigiana, focaccia"], + ["o3020", "u102", "r209", "delivered", 25.3, ts(2026, 9, 16, 13), "Drunken noodles, Thai iced tea"] + ].map(function (r) { + return { id: r[0], customer_id: r[1], restaurant_id: r[2], status: r[3], total: r[4], placed_ts: r[5], items: r[6] }; + }); + + var RECORDS = { Customer: CUSTOMERS, Restaurant: RESTAURANTS, Order: ORDERS }; + + /* Keys that exist in the sample database, including some that are not entities. */ + var KEYS = []; + CUSTOMERS.forEach(function (c) { KEYS.push({ key: "customer:" + c.id, type: "JSON", entity: "Customer", doc: c }); }); + RESTAURANTS.forEach(function (r) { KEYS.push({ key: "restaurant:" + r.id, type: "JSON", entity: "Restaurant", doc: r }); }); + ORDERS.forEach(function (o) { KEYS.push({ key: "order:" + o.id, type: "JSON", entity: "Order", doc: o }); }); + KEYS.push({ key: "cart:u104", type: "hash", doc: null }); + KEYS.push({ key: "stats:orders:hourly", type: "TSDB-TYPE", doc: null }); + + function defaultModel() { + return [ + { name: "Customer", template: "customer:{id}", desc: "A person who orders food through the app.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Customer ID." }, + { name: "name", type: "string", index: "text", desc: "Full name." }, + { name: "email", type: "string", index: "none", desc: "Contact email." }, + { name: "city", type: "string", index: "tag", desc: "Delivery city." }, + { name: "dietary", type: "string", index: "tag", desc: "Dietary preference: none, vegetarian, vegan, gluten_free, or halal." }, + { name: "favorite_cuisine", type: "string", index: "tag", desc: "Most-ordered cuisine." }, + { name: "loyalty_points", type: "number", index: "numeric", desc: "Loyalty points balance." }, + { name: "joined_ts", type: "number", index: "numeric", desc: "Signup time, Unix seconds." } + ] }, + { name: "Restaurant", template: "restaurant:{id}", desc: "A restaurant that delivers through the app.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Restaurant ID." }, + { name: "name", type: "string", index: "text", desc: "Restaurant name." }, + { name: "cuisine", type: "string", index: "tag", desc: "Cuisine type." }, + { name: "city", type: "string", index: "tag", desc: "City the restaurant delivers in." }, + { name: "rating", type: "number", index: "numeric", desc: "Average rating, 1 to 5." }, + { name: "price_level", type: "number", index: "numeric", desc: "Price level, 1 to 4." }, + { name: "avg_delivery_min", type: "number", index: "numeric", desc: "Average delivery time in minutes." } + ] }, + { name: "Order", template: "order:{id}", desc: "A delivery order placed by a customer at a restaurant.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Order ID." }, + { name: "customer_id", type: "string", index: "tag", rel: "Customer", desc: "Customer who placed the order." }, + { name: "restaurant_id", type: "string", index: "tag", rel: "Restaurant", desc: "Restaurant that made the order." }, + { name: "status", type: "string", index: "tag", desc: "preparing, on_the_way, delivered, or cancelled." }, + { name: "total", type: "number", index: "numeric", desc: "Order total in USD." }, + { name: "placed_ts", type: "number", index: "numeric", desc: "Order time, Unix seconds." }, + { name: "items", type: "string", index: "text", desc: "What was ordered." } + ] } + ]; + } + + /* --------------------------------------------------------------- helpers */ + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + function el(tag, cls, html) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + } + function lower(s) { return s.charAt(0).toLowerCase() + s.slice(1); } + function snake(s) { return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); } + function fmtDate(t) { return new Date(t * 1000).toISOString().slice(0, 10); } + function hash8(s) { + var h = 2166136261; + for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return ("00000000" + (h >>> 0).toString(16)).slice(-8); + } + + /* Pretty JSON with light syntax colouring. */ + function jsonHtml(v) { + var s = JSON.stringify(v, null, 2); + return esc(s).replace(/("(?:[^&]|&(?!quot;))*?")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?)/g, + function (m, str, colon, lit, num) { + if (str) return colon ? '' + str + "" + colon : '' + str + ""; + if (lit) return '' + lit + ""; + return '' + num + ""; + }); + } + + function templateRegex(t) { + if (t.indexOf("{id}") === -1) return null; + var parts = t.split("{id}").map(function (p) { return p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }); + return new RegExp("^" + parts.join("([^:]+)") + "$"); + } + + /* ---------------------------------------------------- model -> tool list */ + + function fieldsBy(ent, idx) { return ent.fields.filter(function (f) { return !f.pk && f.index === idx; }).map(function (f) { return f.name; }); } + + function relationships(model) { + var rels = []; + model.forEach(function (e) { + e.fields.forEach(function (f) { + if (!f.rel) return; + var target = model.filter(function (t) { return t.name === f.rel; })[0]; + if (!target) return; + rels.push({ name: e.name + "." + lower(f.rel), from: e.name, to: f.rel, via: f.name, reverse: false }); + rels.push({ name: f.rel + "." + snake(e.name) + "s", from: f.rel, to: e.name, via: f.name, reverse: true }); + }); + }); + return rels; + } + + function limitProps(max) { + return { + limit: { description: "Maximum number of results to return (default: 10, max: " + max + "). A larger request returns " + max + " results; use offset to page through the rest.", type: "number" }, + offset: { description: "Number of results to skip (default: 0)", type: "number" } + }; + } + + function conditionSchemas(tags, nums, texts) { + var p = {}; + if (tags.length) p.tag_conditions = { + description: "Exact-match tag conditions, ANDed with every other condition. Omit the parameter (or pass []) when there are none.", + type: "array", maxItems: 16, + items: { type: "object", additionalProperties: false, required: ["field", "value"], properties: { + field: { description: "The tag field this condition applies to.", type: "string", enum: tags }, + value: { description: "Exact TAG value to match.", type: "string" }, + exclude: { description: "When true, match records where this field is present and does not contain the exact value.", type: "boolean" } } } + }; + if (nums.length) p.numeric_conditions = { + description: "Inclusive numeric-range conditions, ANDed together.", + type: "array", maxItems: 16, + items: { type: "object", additionalProperties: false, required: ["field"], properties: { + field: { description: "Numeric field on the entity.", type: "string", enum: nums }, + min_value: { description: "Inclusive lower bound; omit for unbounded.", type: "number" }, + max_value: { description: "Inclusive upper bound; omit for unbounded.", type: "number" } } } + }; + if (texts.length) { + p.text_query = { description: "Lexical text to match on the entity's text fields (" + texts.join(", ") + ").", type: "string" }; + p.text_match_mode = { description: "How text_query terms combine: any (default), all, or phrase.", type: "string", enum: ["any", "all", "phrase"] }; + } + return p; + } + + function buildTools(model) { + var tools = []; + var rels = relationships(model); + var names = model.map(function (e) { return e.name; }); + model.forEach(function (e) { + var pk = e.fields.filter(function (f) { return f.pk; })[0]; + var tags = fieldsBy(e, "tag"), nums = fieldsBy(e, "numeric"), texts = fieldsBy(e, "text"); + var sn = snake(e.name); + var sample = (RECORDS[e.name] && RECORDS[e.name][0] || {}).id || "3"; + if (pk) tools.push({ name: "get_" + sn + "_by_id", entity: e.name, kind: "get", + description: "Get " + e.name + " by ID using a key lookup. " + e.desc + " ID field " + pk.name + ": " + pk.desc, + inputSchema: { type: "object", required: ["id"], properties: { id: { + description: "The ID of the " + e.name + " to retrieve. Pass only the key component, for example id=\"" + sample + "\"; do not pass the full Redis key " + e.template.replace("{id}", sample) + ".", + type: "string" } } } }); + if (tags.length || nums.length || texts.length) { + var parts = []; + if (tags.length) parts.push("tag_conditions"); + if (nums.length) parts.push("numeric_conditions"); + if (texts.length) parts.push("text_query"); + var lines = []; + if (tags.length) lines.push("Tag fields (tag_conditions, exact match): " + tags.join(", ") + "."); + if (nums.length) lines.push("Numeric fields (numeric_conditions, inclusive range): " + nums.join(", ") + "."); + if (texts.length) lines.push("Text fields (text_query, full-text): " + texts.join(", ") + "."); + var props = conditionSchemas(tags, nums, texts); + var lp = limitProps(100); props.limit = lp.limit; props.offset = lp.offset; + tools.push({ name: "filter_" + sn, entity: e.name, kind: "filter", + description: "Filter " + e.name + " by one or MORE field conditions in a single call; a record is returned only if it satisfies ALL conditions (AND) across " + parts.join(", ") + ". " + e.desc + " " + lines.join(" "), + inputSchema: { type: "object", properties: props } }); + } + if (tags.length || nums.length) { + var agg = tags.concat(nums); + var cp = { function: { description: "Aggregation function to execute. count takes no field; count_distinct requires one eligible field.", type: "string", enum: ["count", "count_distinct"] }, + field: { description: "Scalar TAG or NUMERIC field whose distinct values should be counted.", type: "string", enum: agg } }; + if (tags.length) cp.group_by = { description: "Optional scalar TAG field to group counts by.", type: "string", enum: tags }; + cp.limit = { description: "Maximum number of groups to return (default: 10)", type: "integer", minimum: 1, maximum: 100, default: 10 }; + tools.push({ name: "count_" + sn, entity: e.name, kind: "count", + description: "Count " + e.name + " records without retrieving individual records. " + e.desc, + inputSchema: { type: "object", additionalProperties: false, required: ["function"], properties: cp } }); + } + }); + var relText = rels.length ? rels.map(function (r) { return r.name; }).join(", ") : "this data model declares none"; + var setItem = { type: "object", additionalProperties: false, required: ["entity"], properties: { + entity: { description: "Which entity this set selects.", type: "string", enum: names }, + tag_conditions: { description: "Exact-match tag conditions, ANDed together.", type: "array" }, + numeric_conditions: { description: "Inclusive numeric-range conditions, ANDed together.", type: "array" }, + text_query: { description: "Lexical text to match on the entity's text fields.", type: "string" }, + expand_to: { description: "Optional relationship to follow after retrieving this set, bringing it to the related entity. Declared: " + relText + ".", type: "string" } } }; + var common = "Each set states which records it selects: the entity (" + names.join(", ") + ") plus tag_conditions, numeric_conditions or text_query. The whole matching set is used, not a page of it."; + function setTool(name, what) { + var p = { sets: { description: "Two or more sets to combine (at most 8), all resolving to the same entity.", type: "array", minItems: 2, maxItems: 8, items: setItem } }; + var lp = limitProps(100); p.limit = lp.limit; p.offset = lp.offset; + return { name: name, kind: "set", description: what + " All sets must resolve to the same entity; use expand_to on one of them to bring it to the same entity first. " + common, + inputSchema: { type: "object", additionalProperties: false, required: ["sets"], properties: p } }; + } + tools.push(setTool("intersect_results", "Return the records present in EVERY given set (AND) -- the conjunctive filter the per-entity tools cannot express.")); + tools.push(setTool("union_results", "Return the records present in ANY given set (OR).")); + tools.push(setTool("except_results", "Return the records in the FIRST set that are not in any of the others (set difference).")); + var ep = { set: setItem, relationship: { description: "Relationship to follow. Declared hops: " + relText + ".", type: "string" } }; + var elp = limitProps(100); ep.limit = elp.limit; ep.offset = elp.offset; + tools.push({ name: "expand_results", kind: "expand", + description: "Follow a declared relationship from one set to the related entity and return that entity's records. This is the only tool here that changes entity; use it when a question joins two entities. Relationships you can follow: " + relText + ".", + inputSchema: { type: "object", additionalProperties: false, required: ["set", "relationship"], properties: ep } }); + return tools; + } + + function indexCommands(model, surface) { + return model.filter(function (e) { return e.fields.some(function (f) { return f.index !== "none"; }); }).map(function (e) { + var prefix = e.template.split("{id}")[0]; + var schema = e.fields.filter(function (f) { return f.index !== "none"; }).map(function (f) { + return "'$." + f.name + "' AS " + f.name + " " + f.index.toUpperCase() + " INDEXMISSING"; + }); + return "FT.CREATE idx:" + surface + ":" + hash8(e.name + e.template) + ":" + lower(e.name) + + " ON JSON PREFIX 1 " + prefix + " SCHEMA \\\n " + schema.join(" \\\n "); + }); + } + + /* ------------------------------------------------------- query execution */ + + function entityOf(model, name) { return model.filter(function (e) { return e.name === name; })[0]; } + + /* Records an entity can see: JSON documents whose key matches its key template. */ + function rowsFor(ent) { + var re = templateRegex(ent.template); + if (!re) return []; + return KEYS.filter(function (k) { return k.type === "JSON" && re.test(k.key); }).map(function (k) { return k.doc; }); + } + + function McpError(msg) { this.message = msg; } + + function checkEnum(path, val, en) { + if (!en || en.indexOf(val) === -1) + throw new McpError("invalid params: validating \"arguments\": validating " + path + ": enum: " + val + " does not equal any of: [" + (en || []).join(" ") + "]"); + } + + function selectSet(model, set, pathRoot) { + var e = entityOf(model, set.entity); + if (!e) throw new McpError("invalid params: unknown entity " + set.entity); + var tags = fieldsBy(e, "tag"); + var nums = fieldsBy(e, "numeric"), texts = fieldsBy(e, "text"); + var rows = rowsFor(e).slice(); + (set.tag_conditions || []).forEach(function (c) { + checkEnum(pathRoot + "/tag_conditions/items/properties/field", c.field, tags); + rows = rows.filter(function (r) { return c.exclude ? r[c.field] !== c.value : r[c.field] === c.value; }); + }); + (set.numeric_conditions || []).forEach(function (c) { + checkEnum(pathRoot + "/numeric_conditions/items/properties/field", c.field, nums); + rows = rows.filter(function (r) { + return (c.min_value == null || r[c.field] >= c.min_value) && (c.max_value == null || r[c.field] <= c.max_value); + }); + }); + if (set.text_query) { + if (!texts.length) throw new McpError("invalid params: validating \"arguments\": validating " + pathRoot + ": unexpected additional properties [\"text_query\"]"); + var q = set.text_query.toLowerCase(), mode = set.text_match_mode || "any"; + if (/^".*"$/.test(q)) { mode = "phrase"; q = q.slice(1, -1); } + var terms = q.split(/\s+/).filter(Boolean); + rows = rows.filter(function (r) { + var hay = texts.map(function (f) { return String(r[f] || ""); }).join(" ").toLowerCase(); + var words = hay.split(/[^a-z0-9]+/); + if (mode === "phrase") return (" " + words.join(" ") + " ").indexOf(" " + terms.join(" ") + " ") !== -1; + var hit = function (t) { return words.indexOf(t) !== -1; }; + return mode === "all" ? terms.every(hit) : terms.some(hit); + }); + } + return { entity: e, rows: rows }; + } + + function follow(model, sel, relName, rels) { + var rel = rels.filter(function (r) { return r.name === relName; })[0]; + if (!rel || rel.from !== sel.entity.name) + throw new McpError("invalid params: relationship " + relName + " is not declared from " + sel.entity.name + ". Declared hops: " + (rels.map(function (r) { return r.name; }).join(", ") || "none")); + var target = entityOf(model, rel.to), rows; + if (!rel.reverse) { + var ids = sel.rows.map(function (r) { return r[rel.via]; }); + rows = rowsFor(target).filter(function (t) { return ids.indexOf(t.id) !== -1; }); + } else { + var src = sel.rows.map(function (r) { return r.id; }); + rows = rowsFor(target).filter(function (t) { return src.indexOf(t[rel.via]) !== -1; }); + } + return { entity: target, rows: rows }; + } + + function stamp(rec, surface) { + var out = { _retrieval: { source: surface, retrieved_at: new Date().toISOString(), source_version: 1 } }; + Object.keys(rec).sort().forEach(function (k) { out[k] = rec[k]; }); + return out; + } + + function page(rows, args) { + var limit = Math.min(args.limit || 10, 100), offset = args.offset || 0; + return { rows: rows.slice(offset, offset + limit), limit: limit, offset: offset, has_more: rows.length > offset + limit }; + } + + function execute(model, tools, surface, name, args) { + var tool = tools.filter(function (t) { return t.name === name; })[0]; + if (!tool) throw new McpError("unknown tool \"" + name + "\""); + var rels = relationships(model); + if (tool.kind === "get") { + var ent = entityOf(model, tool.entity); + var rec = rowsFor(ent).filter(function (r) { return r.id === args.id; })[0]; + if (!rec) return { error: "not found: " + ent.template.replace("{id}", args.id) }; + return stamp(rec, surface); + } + if (tool.kind === "filter") { + var props = tool.inputSchema.properties; + Object.keys(args).forEach(function (k) { + if (!props[k]) throw new McpError("invalid params: validating \"arguments\": validating root: unexpected additional properties [\"" + k + "\"]"); + }); + var sel = selectSet(model, { entity: tool.entity, tag_conditions: args.tag_conditions, numeric_conditions: args.numeric_conditions, text_query: args.text_query, text_match_mode: args.text_match_mode }, "/properties"); + var pg = page(sel.rows, args); + return { count: pg.rows.length, has_more: pg.has_more, limit: pg.limit, offset: pg.offset, + results: pg.rows.map(function (r) { return stamp(r, surface); }), total_count: sel.rows.length }; + } + if (tool.kind === "count") { + var ce = entityOf(model, tool.entity), rows = rowsFor(ce); + if (args.group_by) { + checkEnum("/properties/group_by", args.group_by, tool.inputSchema.properties.group_by && tool.inputSchema.properties.group_by.enum); + var g = {}; + rows.forEach(function (r) { g[r[args.group_by]] = (g[r[args.group_by]] || 0) + 1; }); + return { function: "count", group_by: args.group_by, + groups: Object.keys(g).sort().map(function (k) { return { value: g[k], group_value: k }; }), has_more: false }; + } + return { function: "count", value: rows.length }; + } + if (tool.kind === "set") { + var resolved = args.sets.map(function (s, i) { + var sl = selectSet(model, s, "/properties/sets/items/" + i); + return s.expand_to ? follow(model, sl, s.expand_to, rels) : sl; + }); + var ename = resolved[0].entity.name; + resolved.forEach(function (r) { + if (r.entity.name !== ename) throw new McpError("invalid params: all sets must resolve to the same entity (got " + ename + " and " + r.entity.name + ")"); + }); + var idLists = resolved.map(function (r) { return r.rows.map(function (x) { return x.id; }); }); + var ids; + if (name === "intersect_results") ids = idLists[0].filter(function (id) { return idLists.every(function (l) { return l.indexOf(id) !== -1; }); }); + else if (name === "union_results") { ids = []; idLists.forEach(function (l) { l.forEach(function (id) { if (ids.indexOf(id) === -1) ids.push(id); }); }); } + else ids = idLists[0].filter(function (id) { return idLists.slice(1).every(function (l) { return l.indexOf(id) === -1; }); }); + var pr = page(ids, args), tpl = resolved[0].entity.template; + return { complete: true, count: ids.length, entity: ename, has_more: pr.has_more, + ids: pr.rows.map(function (id) { return tpl.replace("{id}", id); }), limit: pr.limit, offset: pr.offset }; + } + if (tool.kind === "expand") { + var from = selectSet(model, args.set, "/properties/set"); + var to = follow(model, from, args.relationship, rels); + var pe = page(to.rows, args); + return { complete: true, count: to.rows.length, entity: to.entity.name, has_more: pe.has_more, + ids: pe.rows.map(function (r) { return to.entity.template.replace("{id}", r.id); }), limit: pe.limit, offset: pe.offset }; + } + throw new McpError("unsupported tool"); + } + + /* ------------------------------------------------------ scripted agent */ + + function idFromKey(k) { return k.slice(k.lastIndexOf(":") + 1); } + function usd2(n) { return "$" + Number(n).toFixed(2); } + var SEPT_START = ts(2026, 9, 1, 0), SEPT_END = ts(2026, 10, 1, 0) - 1; + function label(s) { return String(s).replace(/_/g, " "); } + function city(s) { return String(s).charAt(0).toUpperCase() + String(s).slice(1); } + + /* Look up each distinct restaurant once, the way an agent would. */ + function restaurantNames(call, orders) { + var names = {}; + orders.forEach(function (o) { + if (!names[o.restaurant_id]) names[o.restaurant_id] = call("get_restaurant_by_id", { id: o.restaurant_id }).name; + }); + return names; + } + + /* Each question drives tool calls through `call(name, args)` and returns the answer. */ + var QUESTIONS = [ + { q: "How many customers follow each diet?", + run: function (call) { + var r = call("count_customer", { function: "count", group_by: "dietary" }); + if (!r.groups.length) return "I found no customers. Check that the Customer key template matches your keys."; + return "Here's the split by dietary preference: " + r.groups.slice().sort(function (a, b) { return b.value - a.value; }) + .map(function (g) { return "**" + g.value + "** " + (g.group_value === "none" ? "with no restriction" : label(g.group_value)); }).join(", ") + "."; + } }, + { q: "Which Thai restaurants in Austin are rated 4.5 or higher?", + run: function (call) { + var r = call("filter_restaurant", { tag_conditions: [{ field: "cuisine", value: "thai" }, { field: "city", value: "austin" }], + numeric_conditions: [{ field: "rating", min_value: 4.5 }] }); + if (!r.total_count) return "No Thai restaurants in Austin are rated 4.5 or higher."; + return r.results.map(function (x) { + return "**" + x.name + "** is rated " + x.rating + ", with an average delivery time of " + x.avg_delivery_min + " minutes."; + }).join("\n"); + } }, + { q: "What has Maya Chen ordered this month?", + run: function (call) { + var c = call("filter_customer", { text_query: "Maya Chen", text_match_mode: "all" }); + if (!c.total_count) return "I couldn't find a customer called Maya Chen."; + var cust = c.results[0]; + var o = call("filter_order", { tag_conditions: [{ field: "customer_id", value: cust.id }], + numeric_conditions: [{ field: "placed_ts", min_value: SEPT_START, max_value: SEPT_END }] }); + if (!o.total_count) return cust.name + " hasn't ordered anything this month."; + var names = restaurantNames(call, o.results); + o.results.sort(function (a, b) { return a.placed_ts - b.placed_ts; }); + return cust.name + " has placed **" + o.total_count + "** orders this month:\n" + o.results.map(function (x) { + return "- " + fmtDate(x.placed_ts) + ": " + x.items + " from " + names[x.restaurant_id] + ", " + usd2(x.total) + " (" + label(x.status) + ")"; + }).join("\n") + "\n\nHer profile says she's " + label(cust.dietary) + " and her favorite cuisine is " + label(cust.favorite_cuisine) + "."; + } }, + { q: "Which vegetarian customers have ordered from Green Bowl?", + run: function (call) { + var r = call("filter_restaurant", { text_query: "Green Bowl", text_match_mode: "phrase" }); + if (!r.total_count) return "I couldn't find a restaurant called Green Bowl."; + var rest = r.results[0]; + var s = call("intersect_results", { sets: [ + { entity: "Order", tag_conditions: [{ field: "restaurant_id", value: rest.id }], expand_to: "Order.customer" }, + { entity: "Customer", tag_conditions: [{ field: "dietary", value: "vegetarian" }] }] }); + if (!s.count) return "No vegetarian customers have ordered from " + rest.name + "."; + var people = s.ids.slice(0, 3).map(function (k) { var c = call("get_customer_by_id", { id: idFromKey(k) }); return "- " + c.name + " (" + city(c.city) + ", " + c.loyalty_points + " loyalty points)"; }); + return "**" + s.count + "** vegetarian customer" + (s.count === 1 ? " has" : "s have") + " ordered from " + rest.name + ":\n" + people.join("\n"); + } }, + { q: "Find orders that include pad thai.", + run: function (call) { + var r = call("filter_order", { text_query: "pad thai", text_match_mode: "phrase" }); + if (!r.total_count) return "No orders include pad thai."; + return "**" + r.total_count + "** orders include pad thai:\n" + r.results.map(function (x) { + return "- " + x.id + ": " + x.items + ", " + usd2(x.total) + " (" + fmtDate(x.placed_ts) + ")"; + }).join("\n"); + } }, + { q: "Which orders are being prepared or on the way right now?", + run: function (call) { + var s = call("union_results", { sets: [ + { entity: "Order", tag_conditions: [{ field: "status", value: "preparing" }] }, + { entity: "Order", tag_conditions: [{ field: "status", value: "on_the_way" }] }] }); + if (!s.count) return "No orders are in progress."; + var orders = s.ids.slice(0, 5).map(function (k) { return call("get_order_by_id", { id: idFromKey(k) }); }); + var names = restaurantNames(call, orders); + return "**" + s.count + "** orders are in progress:\n" + orders.map(function (x) { + return "- " + x.id + ": " + x.items + " from " + names[x.restaurant_id] + " (" + label(x.status) + ")"; + }).join("\n"); + } } + ]; + + function mdLite(s) { + return s.split("\n").map(function (line) { + var h = esc(line).replace(/\*\*(.+?)\*\*/g, "$1"); + return line.indexOf("- ") === 0 ? "
  • " + h.slice(2) + "
  • " : (h ? "

    " + h + "

    " : ""); + }).join("").replace(/(
  • .*?<\/li>)+/g, function (m) { return "
      " + m + "
    "; }); + } + + /* ------------------------------------------------------------------- UI */ + + function init(root) { + var state = { model: defaultModel(), tools: [], surface: "3f8c2a1e-demo", selectedKey: null, selectedTool: null, busy: false, connected: false }; + state.tools = buildTools(state.model); + var ENDPOINT = "https://.context-surfaces.redis.io/mcp"; + + root.innerHTML = ""; + var tabs = el("div", "rcr-tabs"); + tabs.setAttribute("role", "tablist"); + var TABS = [["keys", "1", "Keys in Redis"], ["model", "2", "Data model to tools"], ["ask", "3", "Ask over MCP"]]; + var panels = {}; + TABS.forEach(function (t, i) { + var b = el("button", "rcr-tab", '' + t[1] + "" + esc(t[2])); + b.type = "button"; b.setAttribute("role", "tab"); b.dataset.tab = t[0]; + b.setAttribute("aria-selected", i === 0 ? "true" : "false"); + b.addEventListener("click", function () { show(t[0]); }); + tabs.appendChild(b); + panels[t[0]] = el("div", "rcr-panel"); + panels[t[0]].setAttribute("role", "tabpanel"); + if (i) panels[t[0]].hidden = true; + }); + root.appendChild(tabs); + Object.keys(panels).forEach(function (k) { root.appendChild(panels[k]); }); + + function show(name) { + Array.prototype.forEach.call(tabs.children, function (b) { b.setAttribute("aria-selected", b.dataset.tab === name ? "true" : "false"); }); + Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== name; }); + if (name === "keys") renderKeys(); + if (name === "model") renderModel(); + if (name === "ask") renderAsk(); + } + + function next(label, target) { + var b = el("button", "rcr-btn rcr-btn-primary", esc(label)); + b.type = "button"; + b.addEventListener("click", function () { show(target); }); + var w = el("div", "rcr-next"); w.appendChild(b); return w; + } + + /* ---- step 1: keys ---- */ + function renderKeys() { + var p = panels.keys; p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "This sample database belongs to a food delivery app. It stores customers, restaurants, and orders as JSON documents. Each business object is one key, and the key name starts with the object type. Select a key to see its document.")); + var grid = el("div", "rcr-split"); + var list = el("div", "rcr-card rcr-keylist"); + var groups = {}; + KEYS.forEach(function (k) { var pre = k.key.split(":")[0]; (groups[pre] = groups[pre] || []).push(k); }); + Object.keys(groups).forEach(function (pre) { + var g = groups[pre]; + var d = el("details", "rcr-group"); + if (!state.selectedKey && pre === "customer") d.open = true; + if (state.selectedKey && state.selectedKey.key.indexOf(pre + ":") === 0) d.open = true; + d.appendChild(el("summary", "", '' + esc(pre) + ":*" + g.length + "" + esc(g[0].type) + "")); + g.forEach(function (k) { + var b = el("button", "rcr-key" + (state.selectedKey === k ? " is-on" : ""), '' + esc(k.key) + ""); + b.type = "button"; + b.addEventListener("click", function () { state.selectedKey = k; renderKeys(); }); + d.appendChild(b); + }); + list.appendChild(d); + }); + var view = el("div", "rcr-card rcr-docview"); + var k = state.selectedKey || KEYS[0]; + view.appendChild(el("div", "rcr-cardhead", '' + esc(k.key) + '' + esc(k.type) + "")); + if (k.doc && k.type === "JSON") { + view.appendChild(el("pre", "rcr-code", jsonHtml(k.doc))); + view.appendChild(el("p", "rcr-note", "Entity " + esc(k.entity) + ". Key template " + esc(k.entity.toLowerCase()) + ":{id} matches this key with {id} = " + esc(k.doc.id) + ".")); + } else { + view.appendChild(el("p", "rcr-note", "This key is a " + esc(k.type) + ", not a JSON document. Context Retriever entities are JSON documents, so it isn't part of the data model.")); + } + grid.appendChild(list); grid.appendChild(view); p.appendChild(grid); + p.appendChild(next("Next: define the data model", "model")); + } + + /* ---- step 2: model -> tools ---- */ + function renderModel() { + var p = panels.model; p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template or an index type and watch the tools change.")); + if (!state.entityTab) state.entityTab = state.model[0].name; + var sub = el("div", "rcr-subtabs"); + sub.setAttribute("role", "tablist"); + state.model.forEach(function (ent) { + var b = el("button", "rcr-subtab", esc(ent.name) + ' ' + esc(ent.template) + ""); + b.type = "button"; b.setAttribute("role", "tab"); + b.setAttribute("aria-selected", ent.name === state.entityTab ? "true" : "false"); + b.addEventListener("click", function () { state.entityTab = ent.name; renderModel(); }); + sub.appendChild(b); + }); + p.appendChild(sub); + var ent = entityOf(state.model, state.entityTab); + p.appendChild(entityCard(ent)); + var right = el("div", "rcr-card rcr-tools"); + p.appendChild(right); + renderTools(right); + var idx = el("details", "rcr-card rcr-behind"); + idx.appendChild(el("summary", "", "Behind the scenes: the search indexes the service creates")); + idx.appendChild(el("pre", "rcr-code", esc(indexCommands(state.model, state.surface).join("\n\n") || "No indexed fields, so no index is created."))); + idx.appendChild(el("p", "rcr-note", "Indexes use the INDEXMISSING option, so the database must run Redis 7.4 or later. The index names are illustrative.")); + p.appendChild(idx); + p.appendChild(next("Next: ask questions over MCP", "ask")); + } + + function refreshTools() { + state.tools = buildTools(state.model); + state.connected = false; + var right = panels.model.querySelector(".rcr-tools"); + if (right) { renderTools(right); right.classList.remove("rcr-flash"); void right.offsetWidth; right.classList.add("rcr-flash"); } + var beh = panels.model.querySelector(".rcr-behind pre"); + if (beh) beh.textContent = indexCommands(state.model, state.surface).join("\n\n") || "No indexed fields, so no index is created."; + } + + function entityCard(ent) { + var card = el("div", "rcr-card rcr-entity"); + var head = el("div", "rcr-enthead"); + head.appendChild(el("span", "rcr-entname", esc(ent.name))); + var tplWrap = el("label", "rcr-tpl", 'Key template'); + var inp = el("input", "rcr-input rcr-mono"); + inp.value = ent.template; inp.spellcheck = false; inp.setAttribute("aria-label", ent.name + " key template"); + var match = el("span", "rcr-match"); + function updateMatch() { + var re = templateRegex(inp.value); + var n = re ? KEYS.filter(function (k) { return re.test(k.key); }).length : 0; + match.className = "rcr-match " + (re && n ? "is-ok" : "is-bad"); + match.textContent = !re ? "Missing {id}" : n ? "Matches " + n + " keys" : "Matches no keys"; + } + inp.addEventListener("input", function () { + ent.template = inp.value; updateMatch(); refreshTools(); + var on = panels.model.querySelector('.rcr-subtab[aria-selected="true"] .rcr-mono'); + if (on) on.textContent = inp.value; + }); + updateMatch(); + tplWrap.appendChild(inp); head.appendChild(tplWrap); head.appendChild(match); + card.appendChild(head); + var tbl = el("table", "rcr-fields"); + tbl.innerHTML = "FieldPKTypeIndexRelated"; + var tb = el("tbody"); + ent.fields.forEach(function (f) { + var tr = el("tr"); + tr.appendChild(el("td", "rcr-mono", esc(f.name))); + tr.appendChild(el("td", "", f.pk ? 'PK' : "")); + tr.appendChild(el("td", "", esc(f.type))); + var td = el("td"); + if (f.pk) td.appendChild(el("span", "rcr-faint", "key lookup")); + else { + var sel = el("select", "rcr-select"); + sel.setAttribute("aria-label", ent.name + " " + f.name + " index type"); + (f.type === "number" ? ["none", "numeric"] : ["none", "tag", "text"]).forEach(function (o) { + var op = el("option", "", o); op.value = o; if (o === f.index) op.selected = true; sel.appendChild(op); + }); + sel.addEventListener("change", function () { f.index = sel.value; refreshTools(); }); + td.appendChild(sel); + } + tr.appendChild(td); + tr.appendChild(el("td", "", f.rel ? '' + esc(f.rel) + "" : "")); + tb.appendChild(tr); + }); + tbl.appendChild(tb); card.appendChild(tbl); + return card; + } + + function renderTools(box) { + box.innerHTML = ""; + box.appendChild(el("div", "rcr-cardhead", "Generated MCP tools" + state.tools.length + "")); + var list = el("div", "rcr-toollist"); + if (!state.selectedTool || !state.tools.some(function (t) { return t.name === state.selectedTool; })) state.selectedTool = state.tools[0].name; + var groups = [["Per entity", state.tools.filter(function (t) { return t.entity; })], ["Across entities", state.tools.filter(function (t) { return !t.entity; })]]; + groups.forEach(function (g) { + list.appendChild(el("div", "rcr-toolgroup", esc(g[0]))); + g[1].forEach(function (t) { + var b = el("button", "rcr-tool" + (t.name === state.selectedTool ? " is-on" : "") + (t.entity && t.entity === state.entityTab ? " is-mine" : ""), '' + esc(t.name) + "" + (t.entity ? '' + esc(t.entity) + "" : "")); + b.type = "button"; + b.addEventListener("click", function () { state.selectedTool = t.name; renderTools(box); }); + list.appendChild(b); + }); + }); + var body = el("div", "rcr-toolsbody"); + body.appendChild(list); + box.appendChild(body); + var t = state.tools.filter(function (x) { return x.name === state.selectedTool; })[0]; + var det = el("div", "rcr-tooldetail"); + det.appendChild(el("p", "rcr-tooldesc", esc(t.description))); + det.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(t.inputSchema))); + body.appendChild(det); + } + + /* ---- step 3: ask ---- */ + function renderAsk() { + var p = panels.ask; p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "An agent connects to the service's MCP endpoint with an agent key, lists the tools, and picks the right ones for each question. The agent never connects to Redis. Pick a question to watch the calls.")); + var conn = el("div", "rcr-card rcr-conn"); + conn.appendChild(el("div", "rcr-connrow", 'MCP endpoint' + esc(ENDPOINT) + 'HeaderX-API-Key: <your-agent-key>')); + var log = el("div", "rcr-connlog"); + conn.appendChild(log); + p.appendChild(conn); + var chips = el("div", "rcr-chips"); + QUESTIONS.forEach(function (q) { + var b = el("button", "rcr-chip", esc(q.q)); b.type = "button"; + b.addEventListener("click", function () { ask(q); }); + chips.appendChild(b); + }); + p.appendChild(chips); + var chat = el("div", "rcr-chat"); chat.setAttribute("aria-live", "polite"); + p.appendChild(chat); + handshake(log); + } + + function wait(ms) { return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); }); } + var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + function handshake(log) { + if (state.connected) { log.innerHTML = connectedLine(); return; } + log.innerHTML = ' initialize ...'; + wait(450).then(function () { + log.innerHTML = ' initialize 200 OK, protocol 2025-06-18   tools/list ...'; + return wait(450); + }).then(function () { state.connected = true; log.innerHTML = connectedLine(); }); + } + function connectedLine() { + return ' Connected. tools/list returned ' + state.tools.length + " tools: " + + state.tools.map(function (t) { return '' + esc(t.name) + ""; }).join(" "); + } + + function curlFor(name, args, id) { + var body = JSON.stringify({ jsonrpc: "2.0", id: id, method: "tools/call", params: { name: name, arguments: args } }); + return "curl -X POST " + ENDPOINT + " \\\n -H \"X-API-Key: \" \\\n -H \"Content-Type: application/json\" \\\n -H \"Accept: application/json, text/event-stream\" \\\n -d '" + body + "'"; + } + + function ask(q) { + if (state.busy) return; + state.busy = true; + var chips = panels.ask.querySelectorAll(".rcr-chip"); + Array.prototype.forEach.call(chips, function (c) { c.disabled = true; }); + var chat = panels.ask.querySelector(".rcr-chat"); + chat.innerHTML = ""; + chat.appendChild(el("div", "rcr-msg rcr-user", esc(q.q))); + var thinking = el("div", "rcr-msg rcr-agent rcr-thinking", ""); + chat.appendChild(thinking); + + /* Run the script synchronously to get the calls, then replay with timing. */ + var calls = [], answer, rpcId = 3; + try { + answer = q.run(function (name, args) { + var entry = { name: name, args: args, id: rpcId++ }; + calls.push(entry); + try { entry.result = execute(state.model, state.tools, state.surface, name, args); } + catch (e) { if (e instanceof McpError) { entry.error = e.message; throw e; } throw e; } + return entry.result; + }); + } catch (e) { + if (!(e instanceof McpError)) throw e; + var last = calls[calls.length - 1]; + answer = "I couldn't answer that. The server rejected my call to `" + last.name + "`: the data model doesn't index the field I needed. Go back to step 2, index it, and ask again."; + } + + var chain = wait(700); + calls.forEach(function (c) { + chain = chain.then(function () { + if (!thinking.parentNode) chat.appendChild(thinking); + chat.insertBefore(callCard(c), thinking); + return wait(650); + }); + }); + chain.then(function () { + thinking.remove(); + chat.appendChild(el("div", "rcr-msg rcr-agent", mdLite(answer).replace(/`([^`]+)`/g, "$1"))); + state.busy = false; + Array.prototype.forEach.call(chips, function (c) { c.disabled = false; }); + }); + } + + function callCard(c) { + var d = el("details", "rcr-call" + (c.error ? " is-err" : "")); + var status = c.error ? ' error' : ' ok'; + d.appendChild(el("summary", "", 'tools/call ' + esc(c.name) + ' ' + esc(JSON.stringify(c.args)) + "" + status + "")); + var body = el("div", "rcr-callbody"); + var req = { jsonrpc: "2.0", id: c.id, method: "tools/call", params: { name: c.name, arguments: c.args } }; + var res = c.error ? { jsonrpc: "2.0", id: c.id, error: { code: -32602, message: c.error } } + : { jsonrpc: "2.0", id: c.id, result: { content: [{ type: "text", text: JSON.stringify(c.result) }] } }; + body.appendChild(el("div", "rcr-lbl", "Request")); + body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(req))); + body.appendChild(el("div", "rcr-lbl", c.error ? "Response" : "Response (the tool result, parsed from content[0].text)")); + body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(c.error ? res : c.result))); + var cd = el("details", "rcr-curl"); + cd.appendChild(el("summary", "", "Run this call yourself with curl")); + cd.appendChild(el("pre", "rcr-code rcr-code-sm", esc(curlFor(c.name, c.args, c.id)))); + body.appendChild(cd); + d.appendChild(body); + return d; + } + + renderKeys(); + } + + function boot() { Array.prototype.forEach.call(document.querySelectorAll(".rcr[data-rcr]"), init); } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot(); + + /* Exposed for tests. */ + window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError }; +})(); From 57d4e6198789e5adf773bfd1ba43a815d4bcdc8d Mon Sep 17 00:00:00 2001 From: paoloredis Date: Thu, 24 Sep 2026 16:39:35 +0200 Subject: [PATCH 02/10] DOC-7113: Fix the demo's midnight timestamps and keep the Ask chat across tabs - ts() treated an explicit hour of 0 as missing and used noon, so the "this month" bounds ran noon to noon. Only a missing hour now defaults to noon. - The Ask panel was rebuilt on every visit, which dropped the chat and left the question buttons dead until an orphaned replay finished. It is now built once, so a replay keeps playing across tab switches; returning reconnects only when the data model changed the tools. Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index a0515be90d..8a64008db3 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -15,7 +15,7 @@ /* ------------------------------------------------------------------ data */ /* A fictional food delivery app: customers, restaurants, and orders. */ - function ts(y, m, d, h) { return Math.floor(Date.UTC(y, m - 1, d, h || 12) / 1000); } + function ts(y, m, d, h) { return Math.floor(Date.UTC(y, m - 1, d, h == null ? 12 : h) / 1000); } var CUSTOMERS = [ ["u101", "Maya Chen", "austin", "vegetarian", "thai", 1240, ts(2024, 5, 3)], @@ -690,8 +690,15 @@ } /* ---- step 3: ask ---- */ + /* Built once, so a replay keeps playing and the conversation survives tab + switches. On return, reconnect only if the data model changed the tools. */ function renderAsk() { - var p = panels.ask; p.innerHTML = ""; + var p = panels.ask; + if (p.dataset.built) { + if (!state.connected) handshake(p.querySelector(".rcr-connlog")); + return; + } + p.dataset.built = "1"; p.appendChild(el("p", "rcr-lede", "An agent connects to the service's MCP endpoint with an agent key, lists the tools, and picks the right ones for each question. The agent never connects to Redis. Pick a question to watch the calls.")); var conn = el("div", "rcr-card rcr-conn"); conn.appendChild(el("div", "rcr-connrow", 'MCP endpoint' + esc(ENDPOINT) + 'HeaderX-API-Key: <your-agent-key>')); From 11af06f73217f7c2e4d30fb7e8596c084ebac31f Mon Sep 17 00:00:00 2001 From: paoloredis Date: Thu, 24 Sep 2026 16:57:15 +0200 Subject: [PATCH 03/10] DOC-7113: Report failed ID lookups instead of answering with undefined get_*_by_id returned a not-found object, and the scripted agent read .name from it, so editing a key template to match no keys produced answers like "from undefined". A missing record now fails the call as a tool error (result with isError: true, the shape the live server uses for failed lookups), and the agent says which lookup failed and points at that entity's key template. Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 8a64008db3..dba500132e 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -289,7 +289,11 @@ return KEYS.filter(function (k) { return k.type === "JSON" && re.test(k.key); }).map(function (k) { return k.doc; }); } + /* Protocol-level rejection (bad arguments): a JSON-RPC error. */ function McpError(msg) { this.message = msg; } + /* Tool-level failure (the call ran but failed): a result with isError: true, + the way the live server reports a failed lookup. */ + function ToolError(msg) { this.message = msg; } function checkEnum(path, val, en) { if (!en || en.indexOf(val) === -1) @@ -361,7 +365,7 @@ if (tool.kind === "get") { var ent = entityOf(model, tool.entity); var rec = rowsFor(ent).filter(function (r) { return r.id === args.id; })[0]; - if (!rec) return { error: "not found: " + ent.template.replace("{id}", args.id) }; + if (!rec) throw new ToolError("get by ID failed: failed to get document: key " + ent.template.replace("{id}", args.id) + " not found"); return stamp(rec, surface); } if (tool.kind === "filter") { @@ -756,13 +760,23 @@ var entry = { name: name, args: args, id: rpcId++ }; calls.push(entry); try { entry.result = execute(state.model, state.tools, state.surface, name, args); } - catch (e) { if (e instanceof McpError) { entry.error = e.message; throw e; } throw e; } + catch (e) { + if (e instanceof McpError) entry.error = e.message; + else if (e instanceof ToolError) entry.toolError = e.message; + throw e; + } return entry.result; }); } catch (e) { - if (!(e instanceof McpError)) throw e; + if (!(e instanceof McpError) && !(e instanceof ToolError)) throw e; var last = calls[calls.length - 1]; - answer = "I couldn't answer that. The server rejected my call to `" + last.name + "`: the data model doesn't index the field I needed. Go back to step 2, index it, and ask again."; + if (e instanceof ToolError) { + var t = state.tools.filter(function (x) { return x.name === last.name; })[0]; + answer = "I couldn't answer that. My call to `" + last.name + "` failed because the record wasn't found. Check the " + + (t && t.entity ? t.entity : "entity") + " key template in step 2: it has to match your keys."; + } else { + answer = "I couldn't answer that. The server rejected my call to `" + last.name + "`: the data model doesn't index the field I needed. Go back to step 2, index it, and ask again."; + } } var chain = wait(700); @@ -782,17 +796,19 @@ } function callCard(c) { - var d = el("details", "rcr-call" + (c.error ? " is-err" : "")); - var status = c.error ? ' error' : ' ok'; + var failed = c.error || c.toolError; + var d = el("details", "rcr-call" + (failed ? " is-err" : "")); + var status = failed ? ' error' : ' ok'; d.appendChild(el("summary", "", 'tools/call ' + esc(c.name) + ' ' + esc(JSON.stringify(c.args)) + "" + status + "")); var body = el("div", "rcr-callbody"); var req = { jsonrpc: "2.0", id: c.id, method: "tools/call", params: { name: c.name, arguments: c.args } }; var res = c.error ? { jsonrpc: "2.0", id: c.id, error: { code: -32602, message: c.error } } + : c.toolError ? { jsonrpc: "2.0", id: c.id, result: { content: [{ type: "text", text: "Error executing tool: " + c.toolError }], isError: true } } : { jsonrpc: "2.0", id: c.id, result: { content: [{ type: "text", text: JSON.stringify(c.result) }] } }; body.appendChild(el("div", "rcr-lbl", "Request")); body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(req))); - body.appendChild(el("div", "rcr-lbl", c.error ? "Response" : "Response (the tool result, parsed from content[0].text)")); - body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(c.error ? res : c.result))); + body.appendChild(el("div", "rcr-lbl", failed ? "Response" : "Response (the tool result, parsed from content[0].text)")); + body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(failed ? res : c.result))); var cd = el("details", "rcr-curl"); cd.appendChild(el("summary", "", "Run this call yourself with curl")); cd.appendChild(el("pre", "rcr-code rcr-code-sm", esc(curlFor(c.name, c.args, c.id)))); @@ -808,5 +824,5 @@ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot(); /* Exposed for tests. */ - window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError }; + window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError, ToolError: ToolError }; })(); From e46b13197ad089146ef9b1f83163c47a7350f7d6 Mon Sep 17 00:00:00 2001 From: paoloredis Date: Thu, 24 Sep 2026 17:15:45 +0200 Subject: [PATCH 04/10] DOC-7113: Stop the demo's form reset from overriding component styles The button/select/input reset (font and color: inherit) had higher specificity than single-class rules, so the red CTA buttons rendered dark ink on red at 15px, and tabs, sub-tabs, chips, and selects lost their sizes and colors. The reset is now wrapped in :where() so it has zero specificity; computed styles checked in the browser now match the intended values. Co-Authored-By: Claude Opus 5.5 --- static/css/context-retriever-demo.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/css/context-retriever-demo.css b/static/css/context-retriever-demo.css index 9202180321..ea884f3495 100644 --- a/static/css/context-retriever-demo.css +++ b/static/css/context-retriever-demo.css @@ -34,7 +34,8 @@ .rcr p { margin: 0; } .rcr code, .rcr .rcr-mono { font-family: var(--rcr-mono); font-size: 12.5px; } .rcr code { background: var(--rcr-code-bg); border-radius: 4px; padding: 1px 5px; } -.rcr button, .rcr select, .rcr input { font: inherit; color: inherit; } +/* Zero-specificity reset, so component classes such as .rcr-btn-primary win. */ +:where(.rcr) :where(button, select, input) { font: inherit; color: inherit; } .rcr button:focus-visible, .rcr select:focus-visible, .rcr input:focus-visible, .rcr summary:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--rcr-focus); border-radius: 6px; } From bcb3d966e38594f25b50bb328d7c1dee26e52f1a Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 10:47:27 +0200 Subject: [PATCH 05/10] DOC-7113: Match the demo's index options to what the service creates The "Behind the scenes" panel added INDEXMISSING to every indexed field, while its note said only the index names were illustrative. The live service varies the options by field type, so the panel now does too: - TAG fields: TAG INDEXMISSING - NUMERIC fields: NUMERIC SORTABLE - TEXT fields: plain TEXT The note now says tag fields are the ones that use INDEXMISSING, so the Redis 7.4 requirement still holds, and that tag fields used for access control also get INDEXEMPTY, which the demo doesn't include. Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index dba500132e..35b15514ca 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -267,11 +267,16 @@ return tools; } + /* Field options as the live service creates them: TAG fields get + INDEXMISSING (plus INDEXEMPTY when used for access control, which this + demo doesn't model), NUMERIC fields are SORTABLE, TEXT fields are plain. */ + var INDEX_OPTIONS = { tag: "TAG INDEXMISSING", numeric: "NUMERIC SORTABLE", text: "TEXT" }; + function indexCommands(model, surface) { return model.filter(function (e) { return e.fields.some(function (f) { return f.index !== "none"; }); }).map(function (e) { var prefix = e.template.split("{id}")[0]; var schema = e.fields.filter(function (f) { return f.index !== "none"; }).map(function (f) { - return "'$." + f.name + "' AS " + f.name + " " + f.index.toUpperCase() + " INDEXMISSING"; + return "'$." + f.name + "' AS " + f.name + " " + INDEX_OPTIONS[f.index]; }); return "FT.CREATE idx:" + surface + ":" + hash8(e.name + e.template) + ":" + lower(e.name) + " ON JSON PREFIX 1 " + prefix + " SCHEMA \\\n " + schema.join(" \\\n "); @@ -605,7 +610,8 @@ var idx = el("details", "rcr-card rcr-behind"); idx.appendChild(el("summary", "", "Behind the scenes: the search indexes the service creates")); idx.appendChild(el("pre", "rcr-code", esc(indexCommands(state.model, state.surface).join("\n\n") || "No indexed fields, so no index is created."))); - idx.appendChild(el("p", "rcr-note", "Indexes use the INDEXMISSING option, so the database must run Redis 7.4 or later. The index names are illustrative.")); + idx.appendChild(el("p", "rcr-note", "Tag fields use the INDEXMISSING option, so the database must run Redis 7.4 or later. " + + "Tag fields used for access control, which this demo doesn't include, also get INDEXEMPTY. The index names are illustrative.")); p.appendChild(idx); p.appendChild(next("Next: ask questions over MCP", "ask")); } @@ -824,5 +830,6 @@ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot(); /* Exposed for tests. */ - window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError, ToolError: ToolError }; + window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError, ToolError: ToolError, + indexCommands: indexCommands }; })(); From e55827f7d25c1758f1f656db2190fa53b99ccb5a Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 11:41:21 +0200 Subject: [PATCH 06/10] DOC-7113: Stop the Maya Chen answer from reordering the tool's result To list Maya's orders by date, the scripted agent sorted the filter_order result in place. That array is also what the tools/call card shows as the tool's response, so the card showed the orders in date order (o3018 before o3003) instead of the order the tool returned. The answer now sorts a copy, as the diet question already did. A check over all six questions confirms none of them changes a stored tool result. Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 35b15514ca..84d73cdfe6 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -466,8 +466,9 @@ numeric_conditions: [{ field: "placed_ts", min_value: SEPT_START, max_value: SEPT_END }] }); if (!o.total_count) return cust.name + " hasn't ordered anything this month."; var names = restaurantNames(call, o.results); - o.results.sort(function (a, b) { return a.placed_ts - b.placed_ts; }); - return cust.name + " has placed **" + o.total_count + "** orders this month:\n" + o.results.map(function (x) { + /* Sort a copy: o.results is also what the call card shows as the tool's response. */ + var byDate = o.results.slice().sort(function (a, b) { return a.placed_ts - b.placed_ts; }); + return cust.name + " has placed **" + o.total_count + "** orders this month:\n" + byDate.map(function (x) { return "- " + fmtDate(x.placed_ts) + ": " + x.items + " from " + names[x.restaurant_id] + ", " + usd2(x.total) + " (" + label(x.status) + ")"; }).join("\n") + "\n\nHer profile says she's " + label(cust.dietary) + " and her favorite cuisine is " + label(cust.favorite_cuisine) + "."; } }, From 55ef79119420230cb475ddb04623ead8bd5e4359 Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 12:18:55 +0200 Subject: [PATCH 07/10] DOC-7113: Count only JSON keys in the demo's key-template badge The badge counted every key a template matched, but queries and the generated FT.CREATE ... ON JSON index only use JSON documents. Pointing a template at cart:u104 (a hash) showed a green match, then lookups and filters returned nothing. The badge now counts JSON documents only, and a template that matches only other key types shows "Matches 1 key, but not JSON" in red, with a tooltip that Context Retriever only reads JSON. Also fixes "Matches 1 keys". Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 84d73cdfe6..8b615dce8b 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -634,11 +634,17 @@ var inp = el("input", "rcr-input rcr-mono"); inp.value = ent.template; inp.spellcheck = false; inp.setAttribute("aria-label", ent.name + " key template"); var match = el("span", "rcr-match"); + /* Count only JSON documents, the keys queries and the ON JSON index use. + A template that only hits other key types says so instead of going green. */ function updateMatch() { var re = templateRegex(inp.value); - var n = re ? KEYS.filter(function (k) { return re.test(k.key); }).length : 0; - match.className = "rcr-match " + (re && n ? "is-ok" : "is-bad"); - match.textContent = !re ? "Missing {id}" : n ? "Matches " + n + " keys" : "Matches no keys"; + var hits = re ? KEYS.filter(function (k) { return re.test(k.key); }) : []; + var json = hits.filter(function (k) { return k.type === "JSON"; }).length, other = hits.length - json; + var keys = function (n) { return n + (n === 1 ? " key" : " keys"); }; + match.className = "rcr-match " + (json ? "is-ok" : "is-bad"); + match.textContent = !re ? "Missing {id}" : json ? "Matches " + keys(json) : + other ? "Matches " + keys(other) + ", but not JSON" : "Matches no keys"; + match.title = !json && other ? "Context Retriever only reads JSON documents." : ""; } inp.addEventListener("input", function () { ent.template = inp.value; updateMatch(); refreshTools(); From c8dc0b28324e968d18bfb42bf08badc70c39ebc9 Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 13:31:36 +0200 Subject: [PATCH 08/10] DOC-7113: Re-detect an entity's fields when its key template changes Editing a key template kept the entity's fields, so pointing Customer at cart:u104 still listed the eight customer fields, which describe documents the template no longer reaches. Changing a template now re-detects the fields from the JSON documents it matches, as Auto-detect fields does in the console: restaurant:{id} gives restaurant fields, and a template that matches no JSON documents gives none, so the entity gets no tools. Index edits are kept per document type, so switching the template back restores them. If the agent needs a tool that no longer exists, it now says the entity has no fields instead of blaming an index. Co-Authored-By: Claude Opus 5.5 --- static/css/context-retriever-demo.css | 2 + static/js/context-retriever-demo.js | 85 +++++++++++++++++++-------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/static/css/context-retriever-demo.css b/static/css/context-retriever-demo.css index ea884f3495..24bb44e6a7 100644 --- a/static/css/context-retriever-demo.css +++ b/static/css/context-retriever-demo.css @@ -121,6 +121,8 @@ .rcr-fields th { text-align: left; color: var(--rcr-faint); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--rcr-line); } .rcr-fields td { padding: 3px 6px; border-bottom: 1px solid var(--rcr-line); vertical-align: middle; } .rcr-fields tr:last-child td { border-bottom: 0; } +.rcr .rcr-fieldsnote { font-size: 12px; color: var(--rcr-faint); margin: 0 0 6px; } +.rcr .rcr-nofields td { color: var(--rcr-muted); font-size: 12.5px; padding: 10px 6px; } .rcr-select { -webkit-appearance: none; appearance: none; border: 1px solid var(--rcr-line-strong); border-radius: 6px; padding: 1px 22px 1px 7px; font-size: 12.5px; cursor: pointer; diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 8b615dce8b..2e9f866ea6 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -287,6 +287,21 @@ function entityOf(model, name) { return model.filter(function (e) { return e.name === name; })[0]; } + /* Fields detected from the JSON documents a template matches, the way + Auto-detect fields works in the console: the field set of each document + type found, merged by name. Returns the document types too, so an entity + can tell when its template starts pointing at different documents. */ + function detectFields(template) { + var re = templateRegex(template), kinds = [], names = {}, fields = []; + if (re) KEYS.forEach(function (k) { if (k.type === "JSON" && re.test(k.key) && kinds.indexOf(k.entity) === -1) kinds.push(k.entity); }); + kinds.forEach(function (kind) { + defaultModel().filter(function (e) { return e.name === kind; })[0].fields.forEach(function (f) { + if (!names[f.name]) { names[f.name] = true; fields.push(f); } + }); + }); + return { kind: kinds.join("+"), fields: fields }; + } + /* Records an entity can see: JSON documents whose key matches its key template. */ function rowsFor(ent) { var re = templateRegex(ent.template); @@ -591,7 +606,7 @@ /* ---- step 2: model -> tools ---- */ function renderModel() { var p = panels.model; p.innerHTML = ""; - p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template or an index type and watch the tools change.")); + p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template to point the entity at other documents, or change an index type, and watch the tools change.")); if (!state.entityTab) state.entityTab = state.model[0].name; var sub = el("div", "rcr-subtabs"); sub.setAttribute("role", "tablist"); @@ -647,38 +662,58 @@ match.title = !json && other ? "Context Retriever only reads JSON documents." : ""; } inp.addEventListener("input", function () { - ent.template = inp.value; updateMatch(); refreshTools(); + ent.template = inp.value; + var d = detectFields(inp.value), was = ent.kind == null ? ent.name : ent.kind; + if (d.kind !== was) { + /* Different documents: detect their fields, and keep this entity's + edits for the old ones in case the template comes back. */ + ent.saved = ent.saved || {}; + ent.saved[was] = ent.fields; + ent.fields = ent.saved[d.kind] || d.fields; + ent.kind = d.kind; + renderFields(); + } + updateMatch(); refreshTools(); var on = panels.model.querySelector('.rcr-subtab[aria-selected="true"] .rcr-mono'); if (on) on.textContent = inp.value; }); updateMatch(); tplWrap.appendChild(inp); head.appendChild(tplWrap); head.appendChild(match); card.appendChild(head); + card.appendChild(el("p", "rcr-fieldsnote", "Fields are detected from the JSON documents this template matches, as with Auto-detect fields in the console.")); var tbl = el("table", "rcr-fields"); tbl.innerHTML = "FieldPKTypeIndexRelated"; var tb = el("tbody"); - ent.fields.forEach(function (f) { - var tr = el("tr"); - tr.appendChild(el("td", "rcr-mono", esc(f.name))); - tr.appendChild(el("td", "", f.pk ? 'PK' : "")); - tr.appendChild(el("td", "", esc(f.type))); - var td = el("td"); - if (f.pk) td.appendChild(el("span", "rcr-faint", "key lookup")); - else { - var sel = el("select", "rcr-select"); - sel.setAttribute("aria-label", ent.name + " " + f.name + " index type"); - (f.type === "number" ? ["none", "numeric"] : ["none", "tag", "text"]).forEach(function (o) { - var op = el("option", "", o); op.value = o; if (o === f.index) op.selected = true; sel.appendChild(op); - }); - sel.addEventListener("change", function () { f.index = sel.value; refreshTools(); }); - td.appendChild(sel); - } - tr.appendChild(td); - tr.appendChild(el("td", "", f.rel ? '' + esc(f.rel) + "" : "")); - tb.appendChild(tr); - }); tbl.appendChild(tb); card.appendChild(tbl); + renderFields(); return card; + + function renderFields() { + tb.innerHTML = ""; + if (!ent.fields.length) { + tb.appendChild(el("tr", "rcr-nofields", 'No fields. The template matches no JSON documents, so there is nothing to detect.')); + } + ent.fields.forEach(function (f) { + var tr = el("tr"); + tr.appendChild(el("td", "rcr-mono", esc(f.name))); + tr.appendChild(el("td", "", f.pk ? 'PK' : "")); + tr.appendChild(el("td", "", esc(f.type))); + var td = el("td"); + if (f.pk) td.appendChild(el("span", "rcr-faint", "key lookup")); + else { + var sel = el("select", "rcr-select"); + sel.setAttribute("aria-label", ent.name + " " + f.name + " index type"); + (f.type === "number" ? ["none", "numeric"] : ["none", "tag", "text"]).forEach(function (o) { + var op = el("option", "", o); op.value = o; if (o === f.index) op.selected = true; sel.appendChild(op); + }); + sel.addEventListener("change", function () { f.index = sel.value; refreshTools(); }); + td.appendChild(sel); + } + tr.appendChild(td); + tr.appendChild(el("td", "", f.rel ? '' + esc(f.rel) + "" : "")); + tb.appendChild(tr); + }); + } } function renderTools(box) { @@ -787,6 +822,10 @@ var t = state.tools.filter(function (x) { return x.name === last.name; })[0]; answer = "I couldn't answer that. My call to `" + last.name + "` failed because the record wasn't found. Check the " + (t && t.entity ? t.entity : "entity") + " key template in step 2: it has to match your keys."; + } else if (/^unknown tool/.test(e.message)) { + var owner = state.model.filter(function (x) { return last.name.replace(/^(get_|filter_|count_)/, "").replace(/_by_id$/, "") === snake(x.name); })[0]; + answer = "I couldn't answer that. The service has no `" + last.name + "` tool, because the " + (owner ? owner.name : "entity") + + " entity has no fields. Check its key template in step 2: it has to match JSON documents."; } else { answer = "I couldn't answer that. The server rejected my call to `" + last.name + "`: the data model doesn't index the field I needed. Go back to step 2, index it, and ask again."; } @@ -838,5 +877,5 @@ /* Exposed for tests. */ window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError, ToolError: ToolError, - indexCommands: indexCommands }; + indexCommands: indexCommands, detectFields: detectFields }; })(); From 9805dacf93f56f0279e61e83cd38d76c47973c09 Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 13:39:59 +0200 Subject: [PATCH 09/10] DOC-7113: Reset an edited key template when leaving the entity's sub-tab An edited key template stayed in place after switching to another entity, so coming back to Customer still showed cart:{id} and no fields. Switching sub-tabs now puts the template of the entity being left back to its default, with its default fields. Index choices made on the entity's own documents are kept; anything set while the template pointed at other documents is dropped. Going to the Ask step keeps the edit, so readers can still see what a broken template does to the agent. Co-Authored-By: Claude Opus 5.5 --- static/js/context-retriever-demo.js | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 2e9f866ea6..52a9ffda32 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -614,7 +614,13 @@ var b = el("button", "rcr-subtab", esc(ent.name) + ' ' + esc(ent.template) + ""); b.type = "button"; b.setAttribute("role", "tab"); b.setAttribute("aria-selected", ent.name === state.entityTab ? "true" : "false"); - b.addEventListener("click", function () { state.entityTab = ent.name; renderModel(); }); + b.addEventListener("click", function () { + if (ent.name === state.entityTab) return; + var leaving = entityOf(state.model, state.entityTab); + if (leaving && resetTemplate(leaving)) { state.tools = buildTools(state.model); state.connected = false; } + state.entityTab = ent.name; + renderModel(); + }); sub.appendChild(b); }); p.appendChild(sub); @@ -632,6 +638,30 @@ p.appendChild(next("Next: ask questions over MCP", "ask")); } + /* Point an entity at the documents a template matches. When they're + different documents, re-detect the fields, keeping the edits made for + the old ones in case the template comes back. True if the fields changed. */ + function setTemplate(ent, value) { + ent.template = value; + var d = detectFields(value), was = ent.kind == null ? ent.name : ent.kind; + if (d.kind === was) return false; + ent.saved = ent.saved || {}; + ent.saved[was] = ent.fields; + ent.fields = ent.saved[d.kind] || d.fields; + ent.kind = d.kind; + return true; + } + /* Leaving an entity's sub-tab puts its template back to the default. Index + choices made on its own documents stay; anything set while the template + pointed elsewhere is dropped. True if something changed. */ + function resetTemplate(ent) { + var def = defaultModel().filter(function (e) { return e.name === ent.name; })[0]; + if (!def || ent.template === def.template) return false; + setTemplate(ent, def.template); + ent.saved = {}; + return true; + } + function refreshTools() { state.tools = buildTools(state.model); state.connected = false; @@ -662,17 +692,7 @@ match.title = !json && other ? "Context Retriever only reads JSON documents." : ""; } inp.addEventListener("input", function () { - ent.template = inp.value; - var d = detectFields(inp.value), was = ent.kind == null ? ent.name : ent.kind; - if (d.kind !== was) { - /* Different documents: detect their fields, and keep this entity's - edits for the old ones in case the template comes back. */ - ent.saved = ent.saved || {}; - ent.saved[was] = ent.fields; - ent.fields = ent.saved[d.kind] || d.fields; - ent.kind = d.kind; - renderFields(); - } + if (setTemplate(ent, inp.value)) renderFields(); updateMatch(); refreshTools(); var on = panels.model.querySelector('.rcr-subtab[aria-selected="true"] .rcr-mono'); if (on) on.textContent = inp.value; From c705253cbeedd7f1cc9b2f4f4aed847fc0d4b91f Mon Sep 17 00:00:00 2001 From: paoloredis Date: Mon, 28 Sep 2026 14:04:19 +0200 Subject: [PATCH 10/10] DOC-7113: Keep edited key templates, and add a Reset button instead Resetting a template whenever the reader switched entities undid the point of editing it: a changed or broken template is how readers see what it does to the agent's answers. Edits now stay across sub-tab and step switches. A Reset button next to the template, shown only while it differs from the default, puts it back with its default fields. Index choices made on the entity's own documents are kept, as before. Co-Authored-By: Claude Opus 5.5 --- static/css/context-retriever-demo.css | 1 + static/js/context-retriever-demo.js | 29 +++++++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/static/css/context-retriever-demo.css b/static/css/context-retriever-demo.css index 24bb44e6a7..7a1bdc1302 100644 --- a/static/css/context-retriever-demo.css +++ b/static/css/context-retriever-demo.css @@ -117,6 +117,7 @@ .rcr-match { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 1px 9px; } .rcr-match.is-ok { color: var(--rcr-ok); background: rgba(26, 127, 75, .09); } .rcr-match.is-bad { color: var(--rcr-bad); background: rgba(198, 40, 40, .09); } +.rcr .rcr-reset { padding: 2px 10px; font-size: 12.5px; border-radius: 6px; } .rcr-fields { width: 100%; border-collapse: collapse; font-size: 13px; margin: 0; } .rcr-fields th { text-align: left; color: var(--rcr-faint); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--rcr-line); } .rcr-fields td { padding: 3px 6px; border-bottom: 1px solid var(--rcr-line); vertical-align: middle; } diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js index 52a9ffda32..f1e0c749c2 100644 --- a/static/js/context-retriever-demo.js +++ b/static/js/context-retriever-demo.js @@ -606,7 +606,7 @@ /* ---- step 2: model -> tools ---- */ function renderModel() { var p = panels.model; p.innerHTML = ""; - p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template to point the entity at other documents, or change an index type, and watch the tools change.")); + p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template to point the entity at other documents, or change an index type, and watch the tools change. Reset puts an edited template back.")); if (!state.entityTab) state.entityTab = state.model[0].name; var sub = el("div", "rcr-subtabs"); sub.setAttribute("role", "tablist"); @@ -616,8 +616,6 @@ b.setAttribute("aria-selected", ent.name === state.entityTab ? "true" : "false"); b.addEventListener("click", function () { if (ent.name === state.entityTab) return; - var leaving = entityOf(state.model, state.entityTab); - if (leaving && resetTemplate(leaving)) { state.tools = buildTools(state.model); state.connected = false; } state.entityTab = ent.name; renderModel(); }); @@ -651,7 +649,7 @@ ent.kind = d.kind; return true; } - /* Leaving an entity's sub-tab puts its template back to the default. Index + /* The Reset button puts an entity's template back to the default. Index choices made on its own documents stay; anything set while the template pointed elsewhere is dropped. True if something changed. */ function resetTemplate(ent) { @@ -691,14 +689,29 @@ other ? "Matches " + keys(other) + ", but not JSON" : "Matches no keys"; match.title = !json && other ? "Context Retriever only reads JSON documents." : ""; } - inp.addEventListener("input", function () { - if (setTemplate(ent, inp.value)) renderFields(); + var def = defaultModel().filter(function (e) { return e.name === ent.name; })[0].template; + var reset = el("button", "rcr-btn rcr-reset", "Reset"); + reset.type = "button"; + reset.title = "Put the key template back to " + def; + reset.setAttribute("aria-label", "Reset the " + ent.name + " key template to " + def); + function templateChanged() { updateMatch(); refreshTools(); + reset.hidden = ent.template === def; var on = panels.model.querySelector('.rcr-subtab[aria-selected="true"] .rcr-mono'); - if (on) on.textContent = inp.value; + if (on) on.textContent = ent.template; + } + inp.addEventListener("input", function () { + if (setTemplate(ent, inp.value)) renderFields(); + templateChanged(); + }); + reset.addEventListener("click", function () { + if (resetTemplate(ent)) { inp.value = ent.template; renderFields(); } + templateChanged(); + inp.focus(); }); updateMatch(); - tplWrap.appendChild(inp); head.appendChild(tplWrap); head.appendChild(match); + reset.hidden = ent.template === def; + tplWrap.appendChild(inp); head.appendChild(tplWrap); head.appendChild(match); head.appendChild(reset); card.appendChild(head); card.appendChild(el("p", "rcr-fieldsnote", "Fields are detected from the JSON documents this template matches, as with Auto-detect fields in the console.")); var tbl = el("table", "rcr-fields");