@mcoda/codali 0.1.117 → 0.1.119

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.
@@ -188,8 +188,32 @@ export declare const CODALI_GATEWAY_PLANNER_SCHEMA: {
188
188
  };
189
189
  };
190
190
  };
191
+ /**
192
+ * Matches a tool name the planner wrote against the tools that actually exist.
193
+ *
194
+ * Registered names are namespaced — `http:logmira_tenant_records:daily_logs`,
195
+ * `mcp:github:list_issues` — and a model asked to repeat one routinely writes
196
+ * the last segment instead, or drops the transport prefix. Compared exactly,
197
+ * that name matches nothing, and a task whose tools do not resolve is rejected
198
+ * whole: the run then calls no tools at all, returns no sources, and reports
199
+ * that the context pack was empty.
200
+ *
201
+ * It reached production. A declared HTTP connector was registered, visible and
202
+ * undropped, while the planner in the same run happily called `docdex_search`
203
+ * — a name short enough to reproduce exactly. The connector was never called
204
+ * once.
205
+ *
206
+ * Only unambiguous matches are accepted. Two tools ending in the same segment
207
+ * resolve to neither, because guessing between them would call the wrong
208
+ * system, and that is worse than reporting the tool as unavailable.
209
+ */
210
+ export declare const resolveToolNameAgainst: (candidate: string, available: readonly string[]) => string | undefined;
191
211
  export declare const buildCodaliGatewayClassifierMessages: (input: GatewayPlannerInput) => ProviderMessage[];
192
212
  export declare const buildCodaliGatewayPlannerMessages: (input: GatewayPlannerInput, classifier: CodaliGatewayClassifierOutput) => ProviderMessage[];
213
+ export declare const sanitizePlannerOutput: (planner: CodaliGatewayPlannerOutput, input: GatewayPlannerInput) => {
214
+ planner: CodaliGatewayPlannerOutput;
215
+ warnings: string[];
216
+ };
193
217
  export declare class CodaliGatewayPlanner {
194
218
  private readonly provider;
195
219
  private readonly options;
@@ -1 +1 @@
1
- {"version":3,"file":"GatewayPlanner.d.ts","sourceRoot":"","sources":["../../src/gateway/GatewayPlanner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,eAAe,EAEhB,MAAM,+BAA+B,CAAC;AAIvC,OAAO,KAAK,EACV,6BAA6B,EAC7B,0BAA0B,EAC1B,oBAAoB,EAEpB,4BAA4B,EAE7B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,4BAA4B,CAAC;AAKpC,MAAM,WAAW,kCAAkC;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IAC7C,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,kCAAkC,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,2BAA2B;IAC1C,iBAAiB,EAAE,wBAAwB,CAAC;IAC5C,UAAU,EAAE,6BAA6B,CAAC;IAC1C,OAAO,EAAE,0BAA0B,CAAC;IACpC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,wBAAwB,EAAE,MAAM,CAAC;IACjC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,2BAA2B;IAC1C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,4BAA4B,EAAE,CAAC;gBAErC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,4BAA4B,EAAE;CAMnF;AAED,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyBnC,CAAC;AAEX,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+ChC,CAAC;AA0YX,eAAO,MAAM,oCAAoC,GAC/C,OAAO,mBAAmB,KACzB,eAAe,EAuCjB,CAAC;AAEF,eAAO,MAAM,iCAAiC,GAC5C,OAAO,mBAAmB,EAC1B,YAAY,6BAA6B,KACxC,eAAe,EA8CjB,CAAC;AA0VF,qBAAa,oBAAoB;IAI7B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJ1B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;gBAGxB,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,2BAAgC;IAKtD,QAAQ,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC;QAClD,UAAU,EAAE,6BAA6B,CAAC;QAC1C,cAAc,EAAE,MAAM,CAAC;QACvB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IAiBI,IAAI,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,2BAA2B,CAAC;YAuE9D,iBAAiB;CAgDhC;AAED,eAAO,MAAM,0BAA0B,GACrC,UAAU,QAAQ,EAClB,UAAU,2BAA2B,KACpC,oBAAmE,CAAC"}
1
+ {"version":3,"file":"GatewayPlanner.d.ts","sourceRoot":"","sources":["../../src/gateway/GatewayPlanner.ts"],"names":[],"mappings":"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"}
@@ -377,6 +377,46 @@ const validateClassifierOutput = (value, input, policyCompilation) => {
377
377
  },
378
378
  };
379
379
  };
380
+ /**
381
+ * Matches a tool name the planner wrote against the tools that actually exist.
382
+ *
383
+ * Registered names are namespaced — `http:logmira_tenant_records:daily_logs`,
384
+ * `mcp:github:list_issues` — and a model asked to repeat one routinely writes
385
+ * the last segment instead, or drops the transport prefix. Compared exactly,
386
+ * that name matches nothing, and a task whose tools do not resolve is rejected
387
+ * whole: the run then calls no tools at all, returns no sources, and reports
388
+ * that the context pack was empty.
389
+ *
390
+ * It reached production. A declared HTTP connector was registered, visible and
391
+ * undropped, while the planner in the same run happily called `docdex_search`
392
+ * — a name short enough to reproduce exactly. The connector was never called
393
+ * once.
394
+ *
395
+ * Only unambiguous matches are accepted. Two tools ending in the same segment
396
+ * resolve to neither, because guessing between them would call the wrong
397
+ * system, and that is worse than reporting the tool as unavailable.
398
+ */
399
+ export const resolveToolNameAgainst = (candidate, available) => {
400
+ const wanted = candidate.trim();
401
+ if (!wanted)
402
+ return undefined;
403
+ if (available.includes(wanted))
404
+ return wanted;
405
+ const lower = wanted.toLowerCase();
406
+ const caseless = available.filter((tool) => tool.toLowerCase() === lower);
407
+ if (caseless.length === 1)
408
+ return caseless[0];
409
+ const lastSegment = (value) => {
410
+ const parts = value.split(":");
411
+ return (parts[parts.length - 1] ?? value).toLowerCase();
412
+ };
413
+ const bySegment = available.filter((tool) => lastSegment(tool) === lastSegment(wanted));
414
+ if (bySegment.length === 1)
415
+ return bySegment[0];
416
+ // `logmira_tenant_records:daily_logs` for `http:logmira_tenant_records:daily_logs`.
417
+ const bySuffix = available.filter((tool) => tool.toLowerCase().endsWith(`:${lower}`));
418
+ return bySuffix.length === 1 ? bySuffix[0] : undefined;
419
+ };
380
420
  const allowedToolNames = (compilation) => [...compilation.effectiveAllowedTools].sort();
381
421
  /**
382
422
  * Capability names the classifier selected for stage-2 tool expansion. Absent
@@ -729,7 +769,7 @@ const normalizePlannerOutput = (value, input, classifier, policyCompilation) =>
729
769
  };
730
770
  return normalized;
731
771
  };
732
- const sanitizePlannerOutput = (planner, input) => {
772
+ export const sanitizePlannerOutput = (planner, input) => {
733
773
  const policy = input.policyCompilation ?? compileCodaliGatewayPolicy({ request: input.request });
734
774
  const allowed = new Set(allowedToolNames(policy));
735
775
  const warnings = [];
@@ -739,8 +779,22 @@ const sanitizePlannerOutput = (planner, input) => {
739
779
  warnings.push(`planner_task_removed_image_worker_disabled:${task.id}`);
740
780
  continue;
741
781
  }
742
- const filteredTools = task.toolsAllowed.filter((tool) => allowed.has(tool));
743
- const removed = task.toolsAllowed.filter((tool) => !allowed.has(tool));
782
+ const allowedList = [...allowed];
783
+ const resolved = task.toolsAllowed.map((tool) => ({
784
+ requested: tool,
785
+ canonical: resolveToolNameAgainst(tool, allowedList),
786
+ }));
787
+ const filteredTools = [
788
+ ...new Set(resolved
789
+ .map((entry) => entry.canonical)
790
+ .filter((tool) => Boolean(tool))),
791
+ ];
792
+ const repaired = resolved.filter((entry) => entry.canonical && entry.canonical !== entry.requested);
793
+ if (repaired.length > 0) {
794
+ warnings.push(`planner_task_tools_resolved:${task.id}:` +
795
+ repaired.map((entry) => `${entry.requested}->${entry.canonical}`).join(","));
796
+ }
797
+ const removed = resolved.filter((entry) => !entry.canonical).map((entry) => entry.requested);
744
798
  if (removed.length > 0) {
745
799
  warnings.push(`planner_task_tools_removed:${task.id}:${removed.join(",")}`);
746
800
  }
@@ -13,6 +13,8 @@ import type { DocdexClient } from "../../docdex/DocdexClient.js";
13
13
  * no path is given.
14
14
  */
15
15
  export declare const toRepoRelativePath: (value: string | undefined, workspaceRoot?: string) => string | undefined;
16
+ /** Test seam: forget any open circuit. */
17
+ export declare const resetWebResearchBackoff: () => void;
16
18
  /**
17
19
  * Judges whether search results actually answer the question.
18
20
  *
@@ -1 +1 @@
1
- {"version":3,"file":"DocdexTools.d.ts","sourceRoot":"","sources":["../../../src/tools/docdex/DocdexTools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAuFjE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,MAAM,GAAG,SAAS,EACzB,gBAAgB,MAAM,KACrB,MAAM,GAAG,SAWX,CAAC;AAoHF;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEvB,MAAM,WAAW,iBAAiB;IAChC,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,YAAY,EACpB,UAAS,iBAAsB,KAC9B,cAAc,EAAgE,CAAC"}
1
+ {"version":3,"file":"DocdexTools.d.ts","sourceRoot":"","sources":["../../../src/tools/docdex/DocdexTools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAuFjE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,MAAM,GAAG,SAAS,EACzB,gBAAgB,MAAM,KACrB,MAAM,GAAG,SAWX,CAAC;AAyBF,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,QAAO,IAG1C,CAAC;AAqJF;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEvB,MAAM,WAAW,iBAAiB;IAChC,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,YAAY,EACpB,UAAS,iBAAsB,KAC9B,cAAc,EAAgE,CAAC"}
@@ -107,6 +107,60 @@ export const toRepoRelativePath = (value, workspaceRoot) => {
107
107
  candidate = candidate.replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/{2,}/g, "/");
108
108
  return candidate || undefined;
109
109
  };
110
+ /**
111
+ * Stops paying for a quota that is already spent.
112
+ *
113
+ * Web discovery is metered upstream. When the provider answers 402 — or 401,
114
+ * 403, 429 — the answer will not change for the next caller, but nothing
115
+ * remembered that: every question kept asking, and each question asks more than
116
+ * once, because a search that finds nothing locally falls through to the web
117
+ * and a cache hit with no content is retried uncached. Measured upstream at
118
+ * roughly 366 attempts in two hours, about 4,400 a day, which empties a monthly
119
+ * allowance of 5,000 in a little over a day.
120
+ *
121
+ * So the first refusal opens the circuit for everyone until it is due to close.
122
+ * `Retry-After` decides when, since the provider knows and we are guessing;
123
+ * the fallback is deliberately short, because staying closed too long is a
124
+ * self-inflicted outage and reopening early costs one wasted call.
125
+ */
126
+ const WEB_QUOTA_STATUSES = new Set([401, 402, 403, 429]);
127
+ const DEFAULT_WEB_BACKOFF_MS = 15 * 60000;
128
+ const MAX_WEB_BACKOFF_MS = 60 * 60000;
129
+ let webUnavailableUntilMs = 0;
130
+ let webUnavailableReason = "";
131
+ /** Test seam: forget any open circuit. */
132
+ export const resetWebResearchBackoff = () => {
133
+ webUnavailableUntilMs = 0;
134
+ webUnavailableReason = "";
135
+ };
136
+ const retryAfterMsFrom = (error) => {
137
+ const details = isRecord(error) && isRecord(error.details) ? error.details : undefined;
138
+ const raw = details?.retry_after_ms ??
139
+ details?.retryAfterMs ??
140
+ details?.["retry-after"] ??
141
+ details?.retryAfter;
142
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
143
+ // Seconds or milliseconds, depending on who wrote it.
144
+ return raw < 10000 ? raw * 1000 : raw;
145
+ }
146
+ if (typeof raw === "string") {
147
+ const seconds = Number(raw.trim());
148
+ if (Number.isFinite(seconds) && seconds > 0)
149
+ return seconds * 1000;
150
+ }
151
+ return undefined;
152
+ };
153
+ const isWebQuotaRefusal = (error) => {
154
+ const status = isRecord(error) && typeof error.status === "number" ? error.status : undefined;
155
+ return status !== undefined && WEB_QUOTA_STATUSES.has(status);
156
+ };
157
+ const noteWebRefusal = (error) => {
158
+ const backoff = Math.min(retryAfterMsFrom(error) ?? DEFAULT_WEB_BACKOFF_MS, MAX_WEB_BACKOFF_MS);
159
+ webUnavailableUntilMs = Date.now() + backoff;
160
+ const status = isRecord(error) && typeof error.status === "number" ? error.status : "unknown";
161
+ webUnavailableReason = `provider returned ${status}; not retrying for ${Math.round(backoff / 60000)}m`;
162
+ };
163
+ const webResearchBlocked = () => Date.now() < webUnavailableUntilMs ? webUnavailableReason : undefined;
110
164
  /** Pages to keep from a web research call, and how much of each. */
111
165
  const MAX_WEB_PAGES = 6;
112
166
  const MAX_WEB_PAGE_CHARS = 6000;
@@ -275,16 +329,20 @@ const docdexToolDefinitions = (client, options) => [
275
329
  // A judge that fails must not block a search that already succeeded.
276
330
  }
277
331
  }
278
- if (!localAnswers) {
332
+ if (!localAnswers && !webResearchBlocked()) {
279
333
  try {
280
334
  const web = await client.search(query, { limit, forceWeb: true });
281
335
  if (hasWebFindings(web)) {
282
336
  return toOutput(web);
283
337
  }
284
338
  }
285
- catch {
339
+ catch (error) {
286
340
  // Web discovery being unavailable must not fail a local search that
287
- // simply found nothing. Return the local (empty) result instead.
341
+ // simply found nothing. Return the local (empty) result instead — and
342
+ // remember a refusal, because every search would otherwise pay to
343
+ // rediscover it.
344
+ if (isWebQuotaRefusal(error))
345
+ noteWebRefusal(error);
288
346
  }
289
347
  }
290
348
  return toOutput(await withQueryCentredSnippets(client, query, local));
@@ -546,12 +604,28 @@ const docdexToolDefinitions = (client, options) => [
546
604
  // a central bank's interest rate came back sourced to this repository's
547
605
  // own documentation. Anything genuinely local is what `docdex_search` is
548
606
  // for; a caller can still ask for the mixed behaviour explicitly.
549
- let result = await client.webResearch(query, {
550
- forceWeb: forceWeb ?? true,
551
- skipLocalSearch,
552
- webLimit,
553
- noCache,
554
- });
607
+ const blocked = webResearchBlocked();
608
+ if (blocked) {
609
+ // Answering "the web is unavailable" costs nothing; asking again costs
610
+ // a request from an allowance that is already spent.
611
+ return toOutput({ query, pages: [], unavailable: blocked });
612
+ }
613
+ let result;
614
+ try {
615
+ result = await client.webResearch(query, {
616
+ forceWeb: forceWeb ?? true,
617
+ skipLocalSearch,
618
+ webLimit,
619
+ noCache,
620
+ });
621
+ }
622
+ catch (error) {
623
+ if (isWebQuotaRefusal(error)) {
624
+ noteWebRefusal(error);
625
+ return toOutput({ query, pages: [], unavailable: webResearchBlocked() });
626
+ }
627
+ throw error;
628
+ }
555
629
  // A cache hit returns discovery metadata with no page content at all —
556
630
  // no web_context, no hits — so the run sees that a search happened and
557
631
  // nothing it can quote. Retrying uncached fetches the pages the cache
@@ -568,8 +642,12 @@ const docdexToolDefinitions = (client, options) => [
568
642
  result = fresh;
569
643
  }
570
644
  }
571
- catch {
572
- // Keep the cached response rather than failing a search that worked.
645
+ catch (error) {
646
+ // Keep the cached response rather than failing a search that worked —
647
+ // but a refusal is worth remembering, so the next question does not
648
+ // spend another request discovering the same thing.
649
+ if (isWebQuotaRefusal(error))
650
+ noteWebRefusal(error);
573
651
  }
574
652
  }
575
653
  return toOutput(toWebResearchOutput(result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcoda/codali",
3
- "version": "0.1.117",
3
+ "version": "0.1.119",
4
4
  "description": "Standalone tool-runner adapter for mcoda.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "1.30.0",
38
- "@mcoda/db": "0.1.117",
39
- "@mcoda/shared": "0.1.117"
38
+ "@mcoda/db": "0.1.119",
39
+ "@mcoda/shared": "0.1.119"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.json",