@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.
@@ -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 && /certificate|certified|certification/i.test(q.query) && env.DB) {
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
- const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}\n\n${imageTurn ? "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)." : ""}\n\n${q.query}`.replace("\n\n\n\n", "\n\n") }];
1434
- if (imageTurn) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1435
- const probe = await (env as any).AI.run(model, {
1436
- messages: probeMessages,
1437
- max_tokens: 600,
1438
- reasoning_effort: imageTurn ? "high" : "low",
1439
- });
1440
- const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1441
- probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1442
- const call = parseToolCall(probeText);
1443
- if (call) {
1444
- mark(`Searching the certificate register for “${String(call.args?.query ?? "")}”`);
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
- messages.push({ role: "system", content: toolNote(result) });
1448
- pendingTools.push(result);
1449
- console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
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 certificates.search {"query": "…"}`.
21
- * Plain regex parse — no JSON mode needed, tolerant of surrounding
22
- * prose (the model may talk around the call). */
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_.]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? "")); // line-anchored, case-sensitive: prose never trips it
25
+ const m = /^TOOL\s+([a-z_.]+)[ \t]*(\{.*)?$/m.exec(String(text ?? ""));
25
26
  if (!m) return null;
26
- let args: Record<string, unknown> = {};
27
- if (m[2]) {
28
- try { args = JSON.parse(m[2]); } catch { return null; }
29
- } else if (/^TOOL\s+[a-z_.]+\s*\{/m.test(String(text ?? ""))) {
30
- return null; // a malformed call (opened, never closed) is not a call
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 use one tool before answering, by writing a single line:",
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}": "<${p.description}>"`).join(", ")}}`;
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 it and returns the result attributed — phrase the tool's result as what it returned, with the searched string visible in your answer. Use a tool when the question turns on what it answers (including from a photograph). If you do not need it, answer directly without the line.",
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.