@konneal/engine 0.2.136 → 0.2.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ask-N5I35VCK.js → ask-UL5NL5O7.js} +6 -5
- package/dist/{chunk-YLBPABDQ.js → chunk-4GNKOTIK.js} +48 -38
- package/dist/{chunk-MWOY24B6.js → chunk-5WITTJQD.js} +4 -4
- package/dist/{chunk-TH5XGPD5.js → chunk-LSZTJI7V.js} +28 -56
- package/dist/chunk-NI4CIK6W.js +57 -0
- package/dist/{chunk-J6LBR6GI.js → chunk-RKNN3WAV.js} +316 -20
- package/dist/{chunk-7AXXR7GD.js → chunk-XH5QGX2O.js} +7 -5
- package/dist/dbtools.d.ts +67 -0
- package/dist/env-LMHGNJTN.js +18 -0
- package/dist/{search-LFS5HHMZ.js → search-VEGQBEFC.js} +4 -3
- package/dist/{tools-O4JYGVSR.js → tools-EZRLYLTM.js} +3 -2
- package/dist/tools.d.ts +4 -3
- package/dist/worker_public/src/index.js +13 -11
- package/package.json +1 -1
- package/workers/worker_public/src/ask.ts +42 -18
- package/workers/worker_public/src/dbtools.ts +343 -0
- package/workers/worker_public/src/tools.ts +60 -14
|
@@ -1426,28 +1426,52 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1426
1426
|
// the tool call — one reader, one tool, no parallel extraction racing a
|
|
1427
1427
|
// second vision pass that drifts hour to hour. The old probe dropped the
|
|
1428
1428
|
// image-bearing message, which is why the private bridge had to exist.
|
|
1429
|
-
if (multiTurn &&
|
|
1429
|
+
if (multiTurn && env.DB) {
|
|
1430
1430
|
try {
|
|
1431
1431
|
const imageTurn = typeof withImage === "string";
|
|
1432
1432
|
if (imageTurn) mark("Reading the photograph to identify the instrument");
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1433
|
+
// the loop v2 (the owner's 2026-10-12 direction): the MODEL decides
|
|
1434
|
+
// whether and what to query — no intent regex arms it. Three
|
|
1435
|
+
// bounded rounds; each result joins the messages ATTRIBUTED and the
|
|
1436
|
+
// next probe re-reads them (the model composes db.find predicates
|
|
1437
|
+
// over the published schema and corrects from schema-named
|
|
1438
|
+
// errors). Cross-turn memory: the conversation history above
|
|
1439
|
+
// already carries the prior answers (which quote their tool
|
|
1440
|
+
// results) — a follow-up reuses them instead of re-querying.
|
|
1441
|
+
// Native function-calling rides alongside the line protocol: when
|
|
1442
|
+
// the binding returns tool_calls, they dispatch identically.
|
|
1443
|
+
for (let round = 0; round < 3; round++) {
|
|
1444
|
+
const imageNote = imageTurn && round === 0
|
|
1445
|
+
? "A photograph of the instrument is attached after this message. Transcribe the maker, model and any printed certificate number you can read FROM THE PHOTOGRAPH ITSELF, then emit the TOOL line with those tokens as the query — never invented, and never tokens taken from any register content elsewhere in the context (a register row's holder is not the plate's maker; observed live 2026-10-04)."
|
|
1446
|
+
: "";
|
|
1447
|
+
const continueNote = round > 0
|
|
1448
|
+
? "The tool results above are in hand. Answer now without a TOOL line, or issue exactly one more TOOL line if one more query is genuinely needed."
|
|
1449
|
+
: "";
|
|
1450
|
+
const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: [TOOL_DECLARATION, imageNote, continueNote, q.query].filter(Boolean).join("\n\n") }];
|
|
1451
|
+
if (imageTurn && round === 0) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
|
|
1452
|
+
const probe = await (env as any).AI.run(model, {
|
|
1453
|
+
messages: probeMessages,
|
|
1454
|
+
max_tokens: 700,
|
|
1455
|
+
reasoning_effort: imageTurn && round === 0 ? "high" : "low",
|
|
1456
|
+
});
|
|
1457
|
+
const probeText = typeof probe?.response === "string" ? probe?.response : probe?.choices?.[0]?.message?.content ?? "";
|
|
1458
|
+
probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
|
|
1459
|
+
const nativeCall = Array.isArray((probe as any)?.tool_calls) ? (probe as any).tool_calls[0]?.function : null;
|
|
1460
|
+
const call = nativeCall
|
|
1461
|
+
? {
|
|
1462
|
+
name: String(nativeCall.name ?? ""),
|
|
1463
|
+
args: (() => {
|
|
1464
|
+
try { return typeof nativeCall.arguments === "string" ? JSON.parse(nativeCall.arguments) : (nativeCall.arguments ?? {}); } catch { return {}; }
|
|
1465
|
+
})(),
|
|
1466
|
+
}
|
|
1467
|
+
: parseToolCall(probeText);
|
|
1468
|
+
if (!call || !call.name) break; // the model chose to answer
|
|
1445
1469
|
const result = await runTool(env, call);
|
|
1446
|
-
if (result)
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1470
|
+
if (!result) break;
|
|
1471
|
+
mark(`${result.name} — ${result.query}`.slice(0, 140));
|
|
1472
|
+
messages.push({ role: "system", content: toolNote(result) });
|
|
1473
|
+
pendingTools.push(result);
|
|
1474
|
+
console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
|
|
1451
1475
|
}
|
|
1452
1476
|
} catch {
|
|
1453
1477
|
/* the loop is optional — the answer proceeds without the tool */
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// The data-plane grammar (the owner's 2026-10-12 direction, "the model
|
|
2
|
+
// decides what and how to look for data"): instead of pre-enumerated
|
|
3
|
+
// operation menus, the agent gets the PUBLISHED SCHEMA and composes
|
|
4
|
+
// typed predicates over it. db.schema answers "what is queryable";
|
|
5
|
+
// db.find answers the question. The engine compiles predicates to
|
|
6
|
+
// parameterized SQL — the model never writes SQL, identifiers never
|
|
7
|
+
// interpolate from model input, values always bind. BIRD-INTERACT
|
|
8
|
+
// (2026) grounding: interactive data questions collapse without schema
|
|
9
|
+
// linkage, so every validation error NAMES the valid columns.
|
|
10
|
+
|
|
11
|
+
export interface CatalogColumn {
|
|
12
|
+
name: string;
|
|
13
|
+
type: "text" | "number";
|
|
14
|
+
description: string;
|
|
15
|
+
/** ordering/comparison compiles CAST(col AS INTEGER) — textual columns
|
|
16
|
+
* carrying numeric content (certificates.year is TEXT in D1) */
|
|
17
|
+
numeric_cast?: boolean;
|
|
18
|
+
/** excluded from the default select (heavy bodies); still selectable by name */
|
|
19
|
+
wide?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CatalogTable {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
columns: CatalogColumn[];
|
|
26
|
+
default_select: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// PUBLISHED tables only — public corpus data. Everything holding user
|
|
30
|
+
// or operational state (api_keys, queries, spend, feedback, memories,
|
|
31
|
+
// projects, project_files, conversations, messages, shared_conversations,
|
|
32
|
+
// conversation_entities, attachments, model_plane_meta, ask retries and
|
|
33
|
+
// usage caches) is deliberately absent: db.find cannot reach it.
|
|
34
|
+
export const DB_CATALOG: CatalogTable[] = [
|
|
35
|
+
{
|
|
36
|
+
name: "certificates",
|
|
37
|
+
description:
|
|
38
|
+
"The certificate register (a snapshot, not live certification status): one row per issued certificate across the recommendation families.",
|
|
39
|
+
columns: [
|
|
40
|
+
{ name: "num", type: "text", description: "the printed certificate number, e.g. R60/2021-A-NL1-26.12 (family/prefix per recommendation)" },
|
|
41
|
+
{ name: "family", type: "text", description: "the recommendation family number, e.g. '60' for R 60" },
|
|
42
|
+
{ name: "holder", type: "text", description: "the certificate holder (manufacturer)" },
|
|
43
|
+
{ name: "model", type: "text", description: "the instrument model designation" },
|
|
44
|
+
{ name: "year", type: "number", description: "the issue year", numeric_cast: true },
|
|
45
|
+
{ name: "status", type: "text", description: "Valid | Suspended | Withdrawn" },
|
|
46
|
+
{ name: "pdf_key", type: "text", description: "the /cert-pdf path suffix when the PDF is on file, else empty" },
|
|
47
|
+
],
|
|
48
|
+
default_select: ["num", "family", "holder", "model", "year", "status", "pdf_key"],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "documents",
|
|
52
|
+
description:
|
|
53
|
+
"The document identity registry: every publication edition with its family, part, and derived edition status (an edition with a successor is superseded regardless of any copied status field).",
|
|
54
|
+
columns: [
|
|
55
|
+
{ name: "canonical_id", type: "text", description: "doc:<family>-<edition> slug" },
|
|
56
|
+
{ name: "docidentifier", type: "text", description: "the full identifier with edition" },
|
|
57
|
+
{ name: "family", type: "text", description: "the family key, e.g. R-60" },
|
|
58
|
+
{ name: "part", type: "text", description: "1 | 2 | 3 | A | annexes | sup | empty" },
|
|
59
|
+
{ name: "edition", type: "number", description: "the edition year", numeric_cast: true },
|
|
60
|
+
{ name: "status", type: "text", description: "in-force | superseded | withdrawn (as recorded)" },
|
|
61
|
+
{ name: "derived_status", type: "text", description: "the successor-edge derivation result" },
|
|
62
|
+
{ name: "active", type: "number", description: "1 = the active edition of its family+part" },
|
|
63
|
+
{ name: "superseded_by", type: "text", description: "the successor's canonical_id, else empty" },
|
|
64
|
+
{ name: "title", type: "text", description: "the publication title" },
|
|
65
|
+
],
|
|
66
|
+
default_select: ["canonical_id", "docidentifier", "family", "part", "edition", "derived_status", "active", "superseded_by", "title"],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "graph_nodes",
|
|
70
|
+
description: "The public citation-graph nodes: documents, families, and vocabulary concepts.",
|
|
71
|
+
columns: [
|
|
72
|
+
{ name: "id", type: "text", description: "doc:<identifier-slug> | family:<number> | concept:<id>" },
|
|
73
|
+
{ name: "kind", type: "text", description: "doc | family | concept" },
|
|
74
|
+
{ name: "label", type: "text", description: "the display label" },
|
|
75
|
+
],
|
|
76
|
+
default_select: ["id", "kind", "label"],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "graph_edges",
|
|
80
|
+
description: "The public citation-graph edges between nodes.",
|
|
81
|
+
columns: [
|
|
82
|
+
{ name: "src", type: "text", description: "the source node id" },
|
|
83
|
+
{ name: "dst", type: "text", description: "the destination node id" },
|
|
84
|
+
{ name: "kind", type: "text", description: "part_of | variant_of | successor | amends | defines" },
|
|
85
|
+
],
|
|
86
|
+
default_select: ["src", "kind", "dst"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "model_nodes",
|
|
90
|
+
description:
|
|
91
|
+
"The Recommendation model plane (per-standard requirement/conformance graphs): the machine-readable nodes behind the verdict engine.",
|
|
92
|
+
columns: [
|
|
93
|
+
{ name: "standard", type: "text", description: "the standard key, e.g. r60" },
|
|
94
|
+
{ name: "node_id", type: "text", description: "/req/metrological/mpe" },
|
|
95
|
+
{ name: "kind", type: "text", description: "requirement | conformance_test | term | constraint | characteristic | state_machine | dimension" },
|
|
96
|
+
{ name: "name", type: "text", description: "the node's display name" },
|
|
97
|
+
{ name: "clause_doc", type: "text", description: "the provenance document urn" },
|
|
98
|
+
{ name: "clause_ref", type: "text", description: "the clause inside it, e.g. 5.3.2" },
|
|
99
|
+
{ name: "content", type: "text", description: "the node's JSON projection (the checks, thresholds, parameters)", wide: true },
|
|
100
|
+
{ name: "content_hash", type: "text", description: "sha256 of content" },
|
|
101
|
+
],
|
|
102
|
+
default_select: ["standard", "node_id", "kind", "name", "clause_doc", "clause_ref"],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "unit_payloads",
|
|
106
|
+
description: "Typed retrievable units (tables, formulas, figures, terms) with their producer payloads.",
|
|
107
|
+
columns: [
|
|
108
|
+
{ name: "unit_id", type: "text", description: "u:table-1" },
|
|
109
|
+
{ name: "doc_id", type: "text", description: "the owning document id" },
|
|
110
|
+
{ name: "docidentifier", type: "text", description: "the publication identifier with part" },
|
|
111
|
+
{ name: "edition", type: "text", description: "the edition year" },
|
|
112
|
+
{ name: "clause_anchor", type: "text", description: "the clause the unit lives in" },
|
|
113
|
+
{ name: "type", type: "text", description: "table | formula | figure | term | requirement" },
|
|
114
|
+
{ name: "payload", type: "text", description: "the producer payload JSON (the table's rows, the formula's math)", wide: true },
|
|
115
|
+
],
|
|
116
|
+
default_select: ["unit_id", "doc_id", "docidentifier", "edition", "clause_anchor", "type"],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "chunks",
|
|
120
|
+
description:
|
|
121
|
+
"The indexed corpus chunks (one row per clause-level passage) — use for exact metadata questions; semantic search is NOT this tool's job.",
|
|
122
|
+
columns: [
|
|
123
|
+
{ name: "id", type: "text", description: "the chunk id" },
|
|
124
|
+
{ name: "doc_id", type: "text", description: "the source document slug" },
|
|
125
|
+
{ name: "docidentifier", type: "text", description: "the publication identifier with part" },
|
|
126
|
+
{ name: "doctype", type: "text", description: "R | D | B | G | E" },
|
|
127
|
+
{ name: "doc_number", type: "text", description: "60" },
|
|
128
|
+
{ name: "edition", type: "text", description: "the edition year" },
|
|
129
|
+
{ name: "language", type: "text", description: "en | fr | …" },
|
|
130
|
+
{ name: "clause_anchor", type: "text", description: "the clause anchor" },
|
|
131
|
+
{ name: "clause_title", type: "text", description: "the clause title" },
|
|
132
|
+
{ name: "status", type: "text", description: "in-force | superseded" },
|
|
133
|
+
{ name: "superseded_by", type: "text", description: "the superseding docidentifier, else empty" },
|
|
134
|
+
{ name: "corpus", type: "text", description: "clean | dirty" },
|
|
135
|
+
{ name: "tier", type: "text", description: "the quality tier" },
|
|
136
|
+
{ name: "text", type: "text", description: "the passage body (wide — select only when needed)", wide: true },
|
|
137
|
+
{ name: "fts_text", type: "text", description: "context preamble + body (wide)", wide: true },
|
|
138
|
+
],
|
|
139
|
+
default_select: ["id", "doc_id", "docidentifier", "doctype", "doc_number", "edition", "language", "clause_anchor", "clause_title", "status", "corpus"],
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
export function publishedTable(name: string): CatalogTable | undefined {
|
|
144
|
+
return DB_CATALOG.find((t) => t.name === name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── the predicate compiler (pure; unit-tested) ────────────────────────
|
|
148
|
+
|
|
149
|
+
export const FIND_OPS = ["eq", "ne", "lt", "le", "gt", "ge", "in", "like", "between"] as const;
|
|
150
|
+
export type FindOp = (typeof FIND_OPS)[number];
|
|
151
|
+
|
|
152
|
+
export interface FindPredicate {
|
|
153
|
+
col: string;
|
|
154
|
+
op: FindOp;
|
|
155
|
+
value: unknown;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FindArgs {
|
|
159
|
+
table: string;
|
|
160
|
+
where?: FindPredicate[];
|
|
161
|
+
select?: string[];
|
|
162
|
+
order_by?: { col: string; dir?: "asc" | "desc" };
|
|
163
|
+
limit?: number;
|
|
164
|
+
group_by?: string[];
|
|
165
|
+
agg?: { fn: "count" | "sum" | "avg" | "min" | "max"; col?: string };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type Compiled =
|
|
169
|
+
| { ok: true; sql: string; params: unknown[]; echo: string }
|
|
170
|
+
| { ok: false; error: string };
|
|
171
|
+
|
|
172
|
+
const MAX_LIMIT = 25;
|
|
173
|
+
const DEFAULT_LIMIT = 10;
|
|
174
|
+
|
|
175
|
+
const NUMERIC_OPS: FindOp[] = ["lt", "le", "gt", "ge", "between"];
|
|
176
|
+
|
|
177
|
+
/** Compile validated identifiers + bound parameters. Returns a
|
|
178
|
+
* schema-linked error (naming the valid columns) on any mismatch —
|
|
179
|
+
* the model corrects from the error, the loop continues. */
|
|
180
|
+
export function compileFind(args: FindArgs): Compiled {
|
|
181
|
+
const table = publishedTable(String(args?.table ?? ""));
|
|
182
|
+
if (!table) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `unknown table "${String(args?.table ?? "")}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const colNames = table.columns.map((c) => c.name);
|
|
189
|
+
const colByName = new Map(table.columns.map((c) => [c.name, c]));
|
|
190
|
+
const params: unknown[] = [];
|
|
191
|
+
const phrases: string[] = [];
|
|
192
|
+
const p = (n: number) => `?${n}`;
|
|
193
|
+
|
|
194
|
+
const colRef = (col: string): string => {
|
|
195
|
+
const c = colByName.get(col)!;
|
|
196
|
+
return c.numeric_cast ? `CAST(${col} AS INTEGER)` : col;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
for (const w of Array.isArray(args?.where) ? args.where : []) {
|
|
200
|
+
const col = String(w?.col ?? "");
|
|
201
|
+
const colDef = colByName.get(col);
|
|
202
|
+
if (!colDef) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: `table "${table.name}" has no column "${col}". Valid columns: ${colNames.join(", ")}.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const op = String(w?.op ?? "") as FindOp;
|
|
209
|
+
if (!FIND_OPS.includes(op)) {
|
|
210
|
+
return { ok: false, error: `unknown op "${op}". Valid ops: ${FIND_OPS.join(", ")}.` };
|
|
211
|
+
}
|
|
212
|
+
const v = w?.value;
|
|
213
|
+
if (NUMERIC_OPS.includes(op) && !colDef.numeric_cast && colDef.type !== "number") {
|
|
214
|
+
return { ok: false, error: `op "${op}" needs a numeric column; "${col}" is ${colDef.type}.` };
|
|
215
|
+
}
|
|
216
|
+
const ref = colRef(col);
|
|
217
|
+
if (op === "in") {
|
|
218
|
+
const list = Array.isArray(v) ? v.map((x) => String(x)) : [String(v)];
|
|
219
|
+
if (!list.length) return { ok: false, error: `op "in" needs a non-empty value list.` };
|
|
220
|
+
const holders = list.map((x) => p(params.push(x))).join(", ");
|
|
221
|
+
phrases.push(`${ref} IN (${holders})`);
|
|
222
|
+
} else if (op === "between") {
|
|
223
|
+
const pair = Array.isArray(v) ? v : [undefined, undefined];
|
|
224
|
+
if (pair.length !== 2) return { ok: false, error: `op "between" needs a two-element value [lo, hi].` };
|
|
225
|
+
phrases.push(`${ref} BETWEEN ${p(params.push(pair[0]))} AND ${p(params.push(pair[1]))}`);
|
|
226
|
+
} else if (op === "like") {
|
|
227
|
+
phrases.push(`${ref} LIKE ${p(params.push(String(v)))}`);
|
|
228
|
+
} else {
|
|
229
|
+
const sqlOp = { eq: "=", ne: "!=", lt: "<", le: "<=", gt: ">", ge: ">=" }[op as "eq" | "ne" | "lt" | "le" | "gt" | "ge"];
|
|
230
|
+
phrases.push(`${ref} ${sqlOp} ${p(params.push(v))}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const select = (Array.isArray(args?.select) && args.select.length ? args.select : table.default_select).map(String);
|
|
235
|
+
for (const s of select) {
|
|
236
|
+
if (!colNames.includes(s)) {
|
|
237
|
+
return { ok: false, error: `table "${table.name}" has no column "${s}". Valid columns: ${colNames.join(", ")}.` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let head: string;
|
|
242
|
+
if (args?.agg?.fn) {
|
|
243
|
+
const fn = String(args.agg.fn) as "count" | "sum" | "avg" | "min" | "max";
|
|
244
|
+
if (!["count", "sum", "avg", "min", "max"].includes(fn)) {
|
|
245
|
+
return { ok: false, error: `unknown agg fn "${fn}". Valid: count, sum, avg, min, max.` };
|
|
246
|
+
}
|
|
247
|
+
let aggExpr = "COUNT(*)";
|
|
248
|
+
if (fn !== "count") {
|
|
249
|
+
const acol = String(args.agg.col ?? "");
|
|
250
|
+
const adef = colByName.get(acol);
|
|
251
|
+
if (!adef) return { ok: false, error: `agg column "${acol}" is not a column of "${table.name}". Valid columns: ${colNames.join(", ")}.` };
|
|
252
|
+
aggExpr = `${fn.toUpperCase()}(${colRef(acol)})`;
|
|
253
|
+
}
|
|
254
|
+
head = `SELECT ${args?.group_by?.length ? `${(args.group_by as string[]).map(String).join(", ")}, ` : ""}${aggExpr} AS agg FROM ${table.name}`;
|
|
255
|
+
for (const g of args?.group_by ?? []) {
|
|
256
|
+
if (!colNames.includes(String(g))) {
|
|
257
|
+
return { ok: false, error: `table "${table.name}" has no column "${String(g)}". Valid columns: ${colNames.join(", ")}.` };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
head = `SELECT ${select.join(", ")} FROM ${table.name}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const where = phrases.length ? ` WHERE ${phrases.join(" AND ")}` : "";
|
|
265
|
+
let tail = "";
|
|
266
|
+
if (args?.group_by?.length) tail += ` GROUP BY ${(args.group_by as string[]).map(String).join(", ")}`;
|
|
267
|
+
if (args?.order_by?.col) {
|
|
268
|
+
const ocol = String(args.order_by.col);
|
|
269
|
+
if (!colNames.includes(ocol)) {
|
|
270
|
+
return { ok: false, error: `table "${table.name}" has no column "${ocol}". Valid columns: ${colNames.join(", ")}.` };
|
|
271
|
+
}
|
|
272
|
+
const dir = String(args.order_by.dir ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
|
|
273
|
+
tail += ` ORDER BY ${colRef(ocol)} ${dir}`;
|
|
274
|
+
}
|
|
275
|
+
const limit = Math.max(1, Math.min(MAX_LIMIT, Number(args?.limit ?? DEFAULT_LIMIT) || DEFAULT_LIMIT));
|
|
276
|
+
tail += ` LIMIT ${limit}`;
|
|
277
|
+
|
|
278
|
+
const echo = [
|
|
279
|
+
`db.find ${table.name}`,
|
|
280
|
+
args?.where?.length ? `where ${args.where.map((w) => `${w.col} ${w.op} ${JSON.stringify(w.value ?? null)}`).join(" AND ")}` : "all rows",
|
|
281
|
+
args?.agg?.fn ? `${args.agg.fn}${args.agg.col ? ` ${args.agg.col}` : ""}` : "",
|
|
282
|
+
]
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
.join(" · ");
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
sql: `${head}${where}${tail}`,
|
|
289
|
+
params,
|
|
290
|
+
echo,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The db.find handler: compile, run on D1, format deterministically.
|
|
295
|
+
* Every output carries the predicate echo — the answer attributes the
|
|
296
|
+
* rows to the query that produced them. */
|
|
297
|
+
export async function runFind(env: any, args: FindArgs): Promise<{ name: string; query: string; output: string } | null> {
|
|
298
|
+
const compiled = compileFind(args);
|
|
299
|
+
if (!compiled.ok) {
|
|
300
|
+
return {
|
|
301
|
+
name: "db.find",
|
|
302
|
+
query: String(args?.table ?? ""),
|
|
303
|
+
output: `The query was rejected: ${compiled.error} Correct the predicates and call again.`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const { portStore } = await import("./env.ts");
|
|
308
|
+
const res = await portStore(env).prepare(compiled.sql).bind(...compiled.params).all();
|
|
309
|
+
const rows = (res?.results ?? []) as Record<string, unknown>[];
|
|
310
|
+
const body = rows.length
|
|
311
|
+
? rows.map((r) => JSON.stringify(r)).join("\n")
|
|
312
|
+
: "No rows matched. Either the predicates are too narrow or the data is absent — state which, and consider widening.";
|
|
313
|
+
const aggNote = args?.agg?.fn ? " (aggregate)" : "";
|
|
314
|
+
return { name: "db.find", query: compiled.echo, output: `${compiled.echo}${aggNote} → ${rows.length} row(s):\n${body}` };
|
|
315
|
+
} catch (e: any) {
|
|
316
|
+
return { name: "db.find", query: compiled.echo, output: `The query failed to run: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The db.schema handler: the published map, one table or the catalog. */
|
|
321
|
+
export function runSchema(table?: string): { name: string; query: string; output: string } {
|
|
322
|
+
const tables = table ? [publishedTable(String(table))].filter(Boolean) : DB_CATALOG;
|
|
323
|
+
if (!tables.length) {
|
|
324
|
+
return {
|
|
325
|
+
name: "db.schema",
|
|
326
|
+
query: String(table),
|
|
327
|
+
output: `Unknown table "${table}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const output = (tables as CatalogTable[])
|
|
331
|
+
.map((t) => {
|
|
332
|
+
const cols = t.columns
|
|
333
|
+
.map((c) => ` ${c.name} (${c.type}${c.numeric_cast ? ", numeric-cast" : ""}${c.wide ? ", wide" : ""}) — ${c.description}`)
|
|
334
|
+
.join("\n");
|
|
335
|
+
return `${t.name} — ${t.description}\n${cols}\n default select: ${t.default_select.join(", ")}`;
|
|
336
|
+
})
|
|
337
|
+
.join("\n\n");
|
|
338
|
+
return {
|
|
339
|
+
name: "db.schema",
|
|
340
|
+
query: table ? String(table) : "catalog",
|
|
341
|
+
output: `Published tables (compose TOOL db.find with where: [{col, op, value}]; ops: ${FIND_OPS.join(" ")}; values bind safely; like accepts % and _ wildcards):\n\n${output}`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -17,19 +17,21 @@ export interface ToolResult {
|
|
|
17
17
|
output: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The strict protocol: a line `TOOL
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
/** The strict protocol: a line `TOOL <name> {…json…}`. Line-anchored,
|
|
21
|
+
* case-sensitive (prose never trips it); the args are the WHOLE rest
|
|
22
|
+
* of the line parsed as JSON — nested objects (db.find predicates)
|
|
23
|
+
* are legal, and a malformed or unterminated object is not a call. */
|
|
23
24
|
export function parseToolCall(text: string): ToolCall | null {
|
|
24
|
-
const m = /^TOOL\s+([a-z_.]+)\
|
|
25
|
+
const m = /^TOOL\s+([a-z_.]+)[ \t]*(\{.*)?$/m.exec(String(text ?? ""));
|
|
25
26
|
if (!m) return null;
|
|
26
|
-
|
|
27
|
-
if (m[
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
27
|
+
const rest = (m[2] ?? "").trim();
|
|
28
|
+
if (!rest) return { name: m[1].toLowerCase(), args: {} };
|
|
29
|
+
if (!rest.startsWith("{")) return null;
|
|
30
|
+
try {
|
|
31
|
+
return { name: m[1].toLowerCase(), args: JSON.parse(rest) };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
31
34
|
}
|
|
32
|
-
return { name: m[1].toLowerCase(), args };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// ── the tool registry (OCP, the owner's 2026-10-04 direction) ────────
|
|
@@ -65,19 +67,63 @@ const certificatesSearch: ToolSpec = {
|
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
import { unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert } from "./tools-content.ts";
|
|
70
|
+
import { runFind, runSchema } from "./dbtools.ts";
|
|
71
|
+
|
|
72
|
+
// the data-plane grammar (dbtools.ts): the model composes typed
|
|
73
|
+
// predicates over the PUBLISHED schema — no operation menus, no raw SQL
|
|
74
|
+
const dbSchema: ToolSpec = {
|
|
75
|
+
name: "db.schema",
|
|
76
|
+
description:
|
|
77
|
+
"The published data map: every queryable table (the certificate register, the document registry, the citation graph, the model plane, typed units, the corpus chunks) with its columns, types, and meanings. Call this first whenever a question turns on structured data; then compose db.find.",
|
|
78
|
+
params: [{ key: "table", required: false, description: "one table's detail; omit for the whole catalog" }],
|
|
79
|
+
audiences: ["agent", "mcp"],
|
|
80
|
+
handler: async (_env, args) => {
|
|
81
|
+
const t = args?.table ? String(args.table) : undefined;
|
|
82
|
+
return runSchema(t);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const dbFind: ToolSpec = {
|
|
87
|
+
name: "db.find",
|
|
88
|
+
description:
|
|
89
|
+
"Query a published table with typed predicates you compose: where: [{col, op, value}] with ops eq|ne|lt|le|gt|ge|in|like|between, optional select, order_by {col, dir}, limit (≤25), group_by + agg {fn: count|sum|avg|min|max, col}. Values bind safely; identifiers validate against the schema (errors name the valid columns). Returns the matching rows or aggregate with the query echoed.",
|
|
90
|
+
params: [
|
|
91
|
+
{ key: "table", required: true, description: "a published table (db.schema lists them)" },
|
|
92
|
+
{ key: "where", required: false, description: "[{col, op, value}] predicates, ANDed" },
|
|
93
|
+
{ key: "select", required: false, description: "columns to return; default per table" },
|
|
94
|
+
{ key: "order_by", required: false, description: "{col, dir: asc|desc}" },
|
|
95
|
+
{ key: "limit", required: false, description: "row cap, ≤25" },
|
|
96
|
+
{ key: "agg", required: false, description: "{fn, col?} aggregate instead of rows; with group_by: [cols]" },
|
|
97
|
+
],
|
|
98
|
+
audiences: ["agent", "mcp"],
|
|
99
|
+
handler: async (env, args) => {
|
|
100
|
+
const findArgs = {
|
|
101
|
+
table: String(args?.table ?? ""),
|
|
102
|
+
where: Array.isArray(args?.where) ? args.where : [],
|
|
103
|
+
select: Array.isArray(args?.select) ? args.select : undefined,
|
|
104
|
+
order_by: args?.order_by && typeof args.order_by === "object" ? (args.order_by as { col: string; dir?: "asc" | "desc" }) : undefined,
|
|
105
|
+
limit: args?.limit != null ? Number(args.limit) : undefined,
|
|
106
|
+
group_by: Array.isArray(args?.group_by) ? args.group_by : undefined,
|
|
107
|
+
agg: args?.agg && typeof args.agg === "object" ? (args.agg as { fn: "count" | "sum" | "avg" | "min" | "max"; col?: string }) : undefined,
|
|
108
|
+
};
|
|
109
|
+
if (!findArgs.table) return null;
|
|
110
|
+
return runFind(env, findArgs);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const TOOLS_REGISTRY: ToolSpec[] = [dbSchema, dbFind, certificatesSearch, unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert];
|
|
68
115
|
|
|
69
|
-
export const TOOLS_REGISTRY: ToolSpec[] = [certificatesSearch, unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert];
|
|
70
116
|
|
|
71
117
|
/** The agent loop's protocol declaration, GENERATED from the registry —
|
|
72
118
|
* the model's tool vocabulary is always the registry's, never a
|
|
73
119
|
* hand-maintained literal. */
|
|
74
120
|
export const TOOL_DECLARATION = [
|
|
75
|
-
"You may
|
|
121
|
+
"You may query data before answering, by writing ONE line per call:",
|
|
76
122
|
...TOOLS_REGISTRY.filter((t) => t.audiences.includes("agent")).map((t) => {
|
|
77
|
-
const shape = `{${t.params.map((p) => `"${p.key}":
|
|
123
|
+
const shape = `{${t.params.map((p) => `"${p.key}": <${p.description}>`).join(", ")}}`;
|
|
78
124
|
return `TOOL ${t.name} ${shape}`;
|
|
79
125
|
}),
|
|
80
|
-
"The worker runs
|
|
126
|
+
"The worker runs each call and returns the result attributed; you may issue up to three calls across the turns, one per turn, each on its own single line (compact JSON, no line breaks). Structured questions — counts, latest, filters, per-holder or per-year breakdowns over the register, document editions, model-plane requirements — start from TOOL db.schema (it lists the queryable tables and their columns), then compose TOOL db.find predicates. Each result arrives attributed: phrase it as what the tool returned, with your query visible in the answer. When you have enough, answer WITHOUT a TOOL line; if no tool helps, answer directly.",
|
|
81
127
|
].join("\n");
|
|
82
128
|
|
|
83
129
|
/** Dispatch by LOOKUP — a tool name is never hard-coded at a call site.
|