@konneal/engine 0.2.83 → 0.2.85

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-TWK46PU2.js";
3
+ } from "./chunk-R3KA6S3E.js";
4
4
  import "./chunk-7HWJOFTQ.js";
5
5
  import "./chunk-SPKPH54J.js";
6
6
  import "./chunk-A3QHHUN5.js";
@@ -1875,7 +1875,7 @@ function exactCacheKey(indexVersion, gen, ns, queryHash) {
1875
1875
 
1876
1876
  // workers/worker_public/src/tools.ts
1877
1877
  function parseToolCall(text) {
1878
- const m = /^TOOL\s+([a-z_]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? ""));
1878
+ const m = /^TOOL\s+([a-z_.]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? ""));
1879
1879
  if (!m) return null;
1880
1880
  let args = {};
1881
1881
  if (m[2]) {
@@ -1884,22 +1884,38 @@ function parseToolCall(text) {
1884
1884
  } catch {
1885
1885
  return null;
1886
1886
  }
1887
- } else if (/^TOOL\s+[a-z_]+\s*\{/m.test(String(text ?? ""))) {
1887
+ } else if (/^TOOL\s+[a-z_.]+\s*\{/m.test(String(text ?? ""))) {
1888
1888
  return null;
1889
1889
  }
1890
1890
  return { name: m[1].toLowerCase(), args };
1891
1891
  }
1892
- var TOOL_DECLARATION = `You may use one tool before answering, by writing a single line:
1893
- TOOL register_search {"query": "manufacturer and model to look up"}
1894
- The worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known \u2014 including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.`;
1892
+ var certificatesSearch = {
1893
+ name: "certificates.search",
1894
+ description: "Search the certificate register (a snapshot) by holder name, model designation, or a printed certificate number. Returns the matching rows \u2014 number, holder, model, issue year, status, and document links where on file \u2014 or the exact no-match statement for the string asked.",
1895
+ params: [{ key: "query", required: true, description: "the holder, model, or printed certificate number to look up" }],
1896
+ audiences: ["agent", "mcp"],
1897
+ handler: async (db, args) => {
1898
+ const query = String(args?.query ?? "").trim().slice(0, 160);
1899
+ if (!query) return null;
1900
+ const { searchRegister: searchRegister2, registerNote: registerNote2, certificateLinks: certificateLinks2 } = await import("./certificates-CO7KX2S2.js");
1901
+ const reg = await searchRegister2(db, query, true);
1902
+ const output = reg?.rows?.length ? [registerNote2(reg.rows), certificateLinks2(reg.rows)].filter(Boolean).join("\n") : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
1903
+ return { name: "certificates.search", query, output };
1904
+ }
1905
+ };
1906
+ var TOOLS_REGISTRY = [certificatesSearch];
1907
+ var TOOL_DECLARATION = [
1908
+ "You may use one tool before answering, by writing a single line:",
1909
+ ...TOOLS_REGISTRY.filter((t) => t.audiences.includes("agent")).map((t) => {
1910
+ const shape = `{${t.params.map((p) => `"${p.key}": "<${p.description}>"`).join(", ")}}`;
1911
+ return `TOOL ${t.name} ${shape}`;
1912
+ }),
1913
+ "The worker runs it and returns the result attributed \u2014 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."
1914
+ ].join("\n");
1895
1915
  async function runTool(db, call) {
1896
- if (call.name !== "register_search") return null;
1897
- const query = String(call.args?.query ?? "").trim().slice(0, 160);
1898
- if (!query) return null;
1899
- const { searchRegister: searchRegister2, registerNote: registerNote2 } = await import("./certificates-CO7KX2S2.js");
1900
- const reg = await searchRegister2(db, query, true);
1901
- const output = reg?.rows?.length ? registerNote2(reg.rows) : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
1902
- return { name: call.name, query, output };
1916
+ const spec = TOOLS_REGISTRY.find((t) => t.name === call.name && t.audiences.includes("agent"));
1917
+ if (!spec) return null;
1918
+ return spec.handler(db, call.args ?? {});
1903
1919
  }
1904
1920
  function toolNote(r) {
1905
1921
  return `The ${r.name} tool returned, for the query "${r.query}":
@@ -2310,6 +2326,11 @@ async function handleAsk(env, ctx, req, tier, key) {
2310
2326
  if (await env.CACHE.get("sys:generation") === "off") {
2311
2327
  return err(503, "generation_disabled", "Generation is temporarily paused; search remains available.");
2312
2328
  }
2329
+ const agentSteps = [];
2330
+ const mark = (step) => {
2331
+ agentSteps.push(step);
2332
+ console.log(`agent-step: ${step}`);
2333
+ };
2313
2334
  const service = env.INTERNAL_SERVICE;
2314
2335
  const fedAuth = {
2315
2336
  cookie: req.headers.get("cookie") ?? "",
@@ -2488,6 +2509,7 @@ async function handleAsk(env, ctx, req, tier, key) {
2488
2509
  let register = await searchRegister(env.DB, q.query);
2489
2510
  const bridgeImage = userImage ?? storedImage;
2490
2511
  if (bridgeImage && /certificate|certified|certification/i.test(q.query)) {
2512
+ mark("Reading the nameplate photograph");
2491
2513
  const np = await extractNameplate(env.AI, MODELS.member, bridgeImage);
2492
2514
  if (np?.manufacturer) {
2493
2515
  const bridged = await searchRegister(env.DB, nameplateRegisterQuery(np, q.query), true);
@@ -2878,6 +2900,7 @@ Answer account questions from these records ONLY: name the record when you use i
2878
2900
  if (/certificate|certified|certification/i.test(q.query) && env.DB) {
2879
2901
  try {
2880
2902
  const imageTurn = typeof withImage === "string";
2903
+ if (imageTurn) mark("Reading the photograph to identify the instrument");
2881
2904
  const probeMessages = [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}
2882
2905
 
2883
2906
  ${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 \u2014 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)." : ""}
@@ -2892,6 +2915,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
2892
2915
  const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
2893
2916
  const call = parseToolCall(probeText);
2894
2917
  if (call) {
2918
+ mark(`Searching the certificate register for \u201C${String(call.args?.query ?? "")}\u201D`);
2895
2919
  const result = await runTool(env.DB, call);
2896
2920
  if (result) {
2897
2921
  messages.push({ role: "system", content: toolNote(result) });
@@ -2926,6 +2950,12 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
2926
2950
  const send = (obj) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}
2927
2951
 
2928
2952
  `));
2953
+ for (const step of agentSteps) {
2954
+ try {
2955
+ send({ type: "agent", step });
2956
+ } catch {
2957
+ }
2958
+ }
2929
2959
  for (const t of pendingTools) {
2930
2960
  try {
2931
2961
  send({ type: "tool", name: t.name, query: t.query });
@@ -3085,6 +3115,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
3085
3115
  completionBlocks.push(...await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks], used));
3086
3116
  if (withImage && register?.rows?.length && answer.includes(refusalAnswer())) {
3087
3117
  console.log("blind-answer: the register note carried rows but the answer refused \u2014 regenerating once");
3118
+ mark("Re-answering from the register results");
3088
3119
  const regen = await generateOnce(env, model, [
3089
3120
  ...messages.slice(0, -1),
3090
3121
  { role: "user", content: "The photograph was attached and the register note lists actual matching certificate rows. Present those rows \u2014 number, holder, model, status, and every document link \u2014 as the answer. Do not claim that nothing was provided." },
@@ -3148,5 +3179,6 @@ export {
3148
3179
  bindModelNode,
3149
3180
  modelGroundingBlock,
3150
3181
  scoreJudge,
3182
+ TOOLS_REGISTRY,
3151
3183
  handleAsk
3152
3184
  };
package/dist/tools.d.ts CHANGED
@@ -7,11 +7,28 @@ export interface ToolResult {
7
7
  query: string;
8
8
  output: string;
9
9
  }
10
- /** The strict protocol: a line `TOOL register_search {"query": "…"}`.
10
+ /** The strict protocol: a line `TOOL certificates.search {"query": "…"}`.
11
11
  * Plain regex parse — no JSON mode needed, tolerant of surrounding
12
12
  * prose (the model may talk around the call). */
13
13
  export declare function parseToolCall(text: string): ToolCall | null;
14
- export declare const TOOL_DECLARATION = "You may use one tool before answering, by writing a single line:\nTOOL register_search {\"query\": \"manufacturer and model to look up\"}\nThe worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known \u2014 including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.";
14
+ export interface ToolSpec {
15
+ /** canonical on every surface */
16
+ name: string;
17
+ description: string;
18
+ params: {
19
+ key: string;
20
+ required: boolean;
21
+ description: string;
22
+ }[];
23
+ audiences: ("agent" | "mcp")[];
24
+ handler: (db: any, args: Record<string, unknown>) => Promise<ToolResult | null>;
25
+ }
26
+ export declare const TOOLS_REGISTRY: ToolSpec[];
27
+ /** The agent loop's protocol declaration, GENERATED from the registry —
28
+ * the model's tool vocabulary is always the registry's, never a
29
+ * hand-maintained literal. */
30
+ export declare const TOOL_DECLARATION: string;
31
+ /** Dispatch by LOOKUP — a tool name is never hard-coded at a call site. */
15
32
  export declare function runTool(db: any, call: ToolCall): Promise<ToolResult | null>;
16
33
  /** The attributed injection: the answer model sees what the tool
17
34
  * returned, for the string it asked — never a standing claim. */
@@ -1,4 +1,5 @@
1
1
  import {
2
+ TOOLS_REGISTRY,
2
3
  bindModelNode,
3
4
  checkQuoteAnchors,
4
5
  handleAsk,
@@ -6,7 +7,7 @@ import {
6
7
  modelGroundingBlock,
7
8
  scoreFaithfulness,
8
9
  scoreJudge
9
- } from "../../chunk-TWK46PU2.js";
10
+ } from "../../chunk-R3KA6S3E.js";
10
11
  import {
11
12
  handleSearch
12
13
  } from "../../chunk-KO6DRI7C.js";
@@ -922,7 +923,16 @@ var TOOLS = [
922
923
  },
923
924
  required: ["query"]
924
925
  }
925
- }
926
+ },
927
+ ...TOOLS_REGISTRY.filter((t) => t.audiences.includes("mcp")).map((t) => ({
928
+ name: t.name,
929
+ description: t.description,
930
+ inputSchema: {
931
+ type: "object",
932
+ properties: Object.fromEntries(t.params.map((p) => [p.key, { type: "string", description: p.description }])),
933
+ required: t.params.filter((p) => p.required).map((p) => p.key)
934
+ }
935
+ }))
926
936
  ];
927
937
  function dispatch(method, params, callTool) {
928
938
  switch (method) {
@@ -959,7 +969,7 @@ async function handleMcp(env, ctx, req, tier, key) {
959
969
  // stream:false forces the JSON lane (anon defaults to SSE)
960
970
  body: JSON.stringify({ ...args, stream: false })
961
971
  });
962
- const res = name === "ask" ? await (await import("../../ask-IAJDX2YY.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-LVUQLA3C.js")).handleSearch(env, ctx, inner, tier, key);
972
+ const res = name === "ask" ? await (await import("../../ask-52JUHGUG.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-LVUQLA3C.js")).handleSearch(env, ctx, inner, tier, key);
963
973
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
964
974
  });
965
975
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.83",
30
+ "version": "0.2.85",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -364,6 +364,16 @@ async function handleAsk(
364
364
  return err(503, "generation_disabled", "Generation is temporarily paused; search remains available.");
365
365
  }
366
366
 
367
+ // the agent's rounds, visible to the user as they happen: each step the
368
+ // assistant takes before and during the answer streams as an event (the
369
+ // owner's 2026-10-04 direction — the user must see the system-to-model
370
+ // questioning sessions, not stare at a spinner)
371
+ const agentSteps: string[] = [];
372
+ const mark = (step: string) => {
373
+ agentSteps.push(step);
374
+ console.log(`agent-step: ${step}`);
375
+ };
376
+
367
377
  // Members get federated retrieval (OIML + ISO/IEC) merged into the same
368
378
  // pipeline via the service binding; rag-public never touches the
369
379
  // internal index itself, and generation/rerank stay in ONE pipeline.
@@ -666,6 +676,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
666
676
  // holder, and the photograph's own reading is authoritative over them.
667
677
  const bridgeImage = userImage ?? storedImage;
668
678
  if (bridgeImage && /certificate|certified|certification/i.test(q.query)) {
679
+ mark("Reading the nameplate photograph");
669
680
  const np = await extractNameplate((env as any).AI, MODELS.member, bridgeImage);
670
681
  if (np?.manufacturer) {
671
682
  const bridged = await searchRegister(env.DB, nameplateRegisterQuery(np, q.query), true);
@@ -1216,7 +1227,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1216
1227
  await attachFigureImages(env, messages, usedHits, q.query);
1217
1228
  // the agent loop (the owner's 2026-09-28 direction): ONE bounded tool
1218
1229
  // round, armed only where a tool can help. The model may call
1219
- // register_search through the strict line protocol; the worker
1230
+ // the registry tools through the strict line protocol; the worker
1220
1231
  // executes it deterministically and the ATTRIBUTED result joins the
1221
1232
  // messages — what the tool returned, for the string it asked. The
1222
1233
  // invocations record into pendingTools; the stream emits them as its
@@ -1230,6 +1241,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1230
1241
  if (/certificate|certified|certification/i.test(q.query) && env.DB) {
1231
1242
  try {
1232
1243
  const imageTurn = typeof withImage === "string";
1244
+ if (imageTurn) mark("Reading the photograph to identify the instrument");
1233
1245
  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") }];
1234
1246
  if (imageTurn) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1235
1247
  const probe = await (env as any).AI.run(model, {
@@ -1240,6 +1252,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1240
1252
  const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1241
1253
  const call = parseToolCall(probeText);
1242
1254
  if (call) {
1255
+ mark(`Searching the certificate register for “${String(call.args?.query ?? "")}”`);
1243
1256
  const result = await runTool(env.DB, call);
1244
1257
  if (result) {
1245
1258
  messages.push({ role: "system", content: toolNote(result) });
@@ -1292,8 +1305,12 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1292
1305
  const sse = new ReadableStream({
1293
1306
  async start(controller) {
1294
1307
  const send = (obj: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
1295
- // the reading arrives first: the interpretation that steered
1296
- // retrieval, before a single token of the answer
1308
+ // the agent trace arrives first: every questioning round the
1309
+ // assistant ran — the photograph read, the register search, any
1310
+ // re-answer — so the user watched what happened, not a spinner
1311
+ for (const step of agentSteps) {
1312
+ try { send({ type: "agent", step }); } catch { /* never block the answer */ }
1313
+ }
1297
1314
  for (const t of pendingTools) {
1298
1315
  try { send({ type: "tool", name: t.name, query: t.query }); } catch { /* never block the answer */ }
1299
1316
  }
@@ -1522,6 +1539,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1522
1539
  // that exact signature; the retry carries a sterner ground instruction.
1523
1540
  if (withImage && register?.rows?.length && answer.includes(refusalAnswer())) {
1524
1541
  console.log("blind-answer: the register note carried rows but the answer refused — regenerating once");
1542
+ mark("Re-answering from the register results");
1525
1543
  const regen = await generateOnce(env, model, [
1526
1544
  ...messages.slice(0, -1),
1527
1545
  { role: "user", content: "The photograph was attached and the register note lists actual matching certificate rows. Present those rows — number, holder, model, status, and every document link — as the answer. Do not claim that nothing was provided." },
@@ -1,6 +1,7 @@
1
1
  // The MCP protocol surface (JSON-RPC 2.0, streamable HTTP 2025-06-18),
2
2
  // dependency-free so plain node can load it for unit tests — the
3
3
  // route adapter (mcp.ts) owns the handler wiring.
4
+ import { TOOLS_REGISTRY } from "./tools.ts";
4
5
  export const PROTOCOL_VERSION = "2025-06-18";
5
6
 
6
7
  export interface McpTool {
@@ -9,6 +10,8 @@ export interface McpTool {
9
10
  inputSchema: { type: "object"; properties: Record<string, unknown>; required: string[] };
10
11
  }
11
12
 
13
+ // the registry's MCP-audience tools are GENERATED here (OCP: adding a
14
+ // tool registers it; this list is never hand-extended for them)
12
15
  export const TOOLS: McpTool[] = [
13
16
  {
14
17
  name: "ask",
@@ -34,6 +37,17 @@ export const TOOLS: McpTool[] = [
34
37
  required: ["query"],
35
38
  },
36
39
  },
40
+ ...TOOLS_REGISTRY
41
+ .filter((t) => t.audiences.includes("mcp"))
42
+ .map((t): McpTool => ({
43
+ name: t.name,
44
+ description: t.description,
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: Object.fromEntries(t.params.map((p) => [p.key, { type: "string", description: p.description }])),
48
+ required: t.params.filter((p) => p.required).map((p) => p.key),
49
+ },
50
+ })),
37
51
  ];
38
52
 
39
53
  export type McpResult =
@@ -17,35 +17,72 @@ export interface ToolResult {
17
17
  output: string;
18
18
  }
19
19
 
20
- /** The strict protocol: a line `TOOL register_search {"query": "…"}`.
20
+ /** The strict protocol: a line `TOOL certificates.search {"query": "…"}`.
21
21
  * Plain regex parse — no JSON mode needed, tolerant of surrounding
22
22
  * prose (the model may talk around the call). */
23
23
  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
24
+ const m = /^TOOL\s+([a-z_.]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? "")); // line-anchored, case-sensitive: prose never trips it
25
25
  if (!m) return null;
26
26
  let args: Record<string, unknown> = {};
27
27
  if (m[2]) {
28
28
  try { args = JSON.parse(m[2]); } catch { return null; }
29
- } else if (/^TOOL\s+[a-z_]+\s*\{/m.test(String(text ?? ""))) {
29
+ } else if (/^TOOL\s+[a-z_.]+\s*\{/m.test(String(text ?? ""))) {
30
30
  return null; // a malformed call (opened, never closed) is not a call
31
31
  }
32
32
  return { name: m[1].toLowerCase(), args };
33
33
  }
34
34
 
35
- export const TOOL_DECLARATION = `You may use one tool before answering, by writing a single line:
36
- TOOL register_search {"query": "manufacturer and model to look up"}
37
- The worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known — including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.`;
35
+ // ── the tool registry (OCP, the owner's 2026-10-04 direction) ────────
36
+ // ONE declared table is the single source of the tool surface: the agent
37
+ // loop's declaration is GENERATED from it, runTool dispatches by lookup,
38
+ // and the MCP server's tools/list is generated from the same entries.
39
+ // Adding a tool = adding one registration; no call site is edited.
40
+ export interface ToolSpec {
41
+ /** canonical on every surface */
42
+ name: string;
43
+ description: string;
44
+ params: { key: string; required: boolean; description: string }[];
45
+ audiences: ("agent" | "mcp")[];
46
+ handler: (db: any, args: Record<string, unknown>) => Promise<ToolResult | null>;
47
+ }
48
+
49
+ const certificatesSearch: ToolSpec = {
50
+ name: "certificates.search",
51
+ description:
52
+ "Search the certificate register (a snapshot) by holder name, model designation, or a printed certificate number. Returns the matching rows — number, holder, model, issue year, status, and document links where on file — or the exact no-match statement for the string asked.",
53
+ params: [{ key: "query", required: true, description: "the holder, model, or printed certificate number to look up" }],
54
+ audiences: ["agent", "mcp"],
55
+ handler: async (db, args) => {
56
+ const query = String(args?.query ?? "").trim().slice(0, 160);
57
+ if (!query) return null;
58
+ const { searchRegister, registerNote, certificateLinks } = await import("./certificates");
59
+ const reg = await searchRegister(db, query, true); // the tool decided — no shaping gate
60
+ const output = reg?.rows?.length
61
+ ? [registerNote(reg.rows), certificateLinks(reg.rows)].filter(Boolean).join("\n")
62
+ : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
63
+ return { name: "certificates.search", query, output };
64
+ },
65
+ };
66
+
67
+ export const TOOLS_REGISTRY: ToolSpec[] = [certificatesSearch];
68
+
69
+ /** The agent loop's protocol declaration, GENERATED from the registry —
70
+ * the model's tool vocabulary is always the registry's, never a
71
+ * hand-maintained literal. */
72
+ export const TOOL_DECLARATION = [
73
+ "You may use one tool before answering, by writing a single line:",
74
+ ...TOOLS_REGISTRY.filter((t) => t.audiences.includes("agent")).map((t) => {
75
+ const shape = `{${t.params.map((p) => `"${p.key}": "<${p.description}>"`).join(", ")}}`;
76
+ return `TOOL ${t.name} ${shape}`;
77
+ }),
78
+ "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.",
79
+ ].join("\n");
38
80
 
81
+ /** Dispatch by LOOKUP — a tool name is never hard-coded at a call site. */
39
82
  export async function runTool(db: any, call: ToolCall): Promise<ToolResult | null> {
40
- if (call.name !== "register_search") return null;
41
- const query = String(call.args?.query ?? "").trim().slice(0, 160);
42
- if (!query) return null;
43
- const { searchRegister, registerNote } = await import("./certificates");
44
- const reg = await searchRegister(db, query, true); // the tool decided — no shaping gate
45
- const output = reg?.rows?.length
46
- ? registerNote(reg.rows)
47
- : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
48
- return { name: call.name, query, output };
83
+ const spec = TOOLS_REGISTRY.find((t) => t.name === call.name && t.audiences.includes("agent"));
84
+ if (!spec) return null;
85
+ return spec.handler(db, call.args ?? {});
49
86
  }
50
87
 
51
88
  /** The attributed injection: the answer model sees what the tool