@mcoda/codali 0.1.120 → 0.1.122

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.
@@ -15,6 +15,7 @@ import type { DocdexClient } from "../../docdex/DocdexClient.js";
15
15
  export declare const toRepoRelativePath: (value: string | undefined, workspaceRoot?: string) => string | undefined;
16
16
  /** Test seam: forget any open circuit. */
17
17
  export declare const resetWebResearchBackoff: () => void;
18
+ export declare const normalizeWebQuery: (value: unknown) => string | undefined;
18
19
  /**
19
20
  * Judges whether search results actually answer the question.
20
21
  *
@@ -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;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"}
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;AAwCF,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,QAAO,IAG1C,CAAC;AA0DF,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SAO3D,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"}
@@ -123,8 +123,23 @@ export const toRepoRelativePath = (value, workspaceRoot) => {
123
123
  * the fallback is deliberately short, because staying closed too long is a
124
124
  * self-inflicted outage and reopening early costs one wasted call.
125
125
  */
126
- const WEB_QUOTA_STATUSES = new Set([401, 402, 403, 429]);
126
+ /**
127
+ * Two kinds of refusal, and they deserve different patience.
128
+ *
129
+ * A spent quota or a rejected credential will still be spent and still be
130
+ * rejected in a minute, so the sensible default is long. A rate limit or a
131
+ * service restarting is over in seconds, and blocking for a quarter of an hour
132
+ * because a container was recreated would turn a blip into an outage of our
133
+ * own making.
134
+ *
135
+ * `Retry-After` overrides both, because the provider knows and we are guessing.
136
+ * The upstream gateway now answers a cold start with 503 and a `Retry-After`
137
+ * rather than a bodyless 502, which is precisely the case this handles.
138
+ */
139
+ const WEB_EXHAUSTED_STATUSES = new Set([401, 402, 403]);
140
+ const WEB_TRANSIENT_STATUSES = new Set([429, 503]);
127
141
  const DEFAULT_WEB_BACKOFF_MS = 15 * 60000;
142
+ const TRANSIENT_WEB_BACKOFF_MS = 30000;
128
143
  const MAX_WEB_BACKOFF_MS = 60 * 60000;
129
144
  let webUnavailableUntilMs = 0;
130
145
  let webUnavailableReason = "";
@@ -150,17 +165,49 @@ const retryAfterMsFrom = (error) => {
150
165
  }
151
166
  return undefined;
152
167
  };
153
- const isWebQuotaRefusal = (error) => {
168
+ const webRefusalStatus = (error) => {
154
169
  const status = isRecord(error) && typeof error.status === "number" ? error.status : undefined;
155
- return status !== undefined && WEB_QUOTA_STATUSES.has(status);
170
+ if (status === undefined)
171
+ return undefined;
172
+ return WEB_EXHAUSTED_STATUSES.has(status) || WEB_TRANSIENT_STATUSES.has(status)
173
+ ? status
174
+ : undefined;
156
175
  };
176
+ const isWebQuotaRefusal = (error) => webRefusalStatus(error) !== undefined;
157
177
  const noteWebRefusal = (error) => {
158
- const backoff = Math.min(retryAfterMsFrom(error) ?? DEFAULT_WEB_BACKOFF_MS, MAX_WEB_BACKOFF_MS);
178
+ const status = webRefusalStatus(error);
179
+ const fallback = status !== undefined && WEB_TRANSIENT_STATUSES.has(status)
180
+ ? TRANSIENT_WEB_BACKOFF_MS
181
+ : DEFAULT_WEB_BACKOFF_MS;
182
+ const backoff = Math.min(retryAfterMsFrom(error) ?? fallback, MAX_WEB_BACKOFF_MS);
159
183
  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`;
184
+ webUnavailableReason =
185
+ `provider returned ${status ?? "unknown"}; not retrying for ` +
186
+ (backoff < 60000 ? `${Math.round(backoff / 1000)}s` : `${Math.round(backoff / 60000)}m`);
162
187
  };
163
188
  const webResearchBlocked = () => Date.now() < webUnavailableUntilMs ? webUnavailableReason : undefined;
189
+ /**
190
+ * Normalises a query before it costs a metered request.
191
+ *
192
+ * The schema asks for a string and a model can satisfy that with whitespace, or
193
+ * with a whole paragraph of pasted context. Both are rejected upstream, and a
194
+ * rejected request still spends the allowance: measured at the gateway, a third
195
+ * of an hour's traffic was 400s rather than answers.
196
+ *
197
+ * A search query is a phrase. Beyond a couple of hundred characters it is not a
198
+ * query any more, and the useful part is at the front.
199
+ */
200
+ const MAX_WEB_QUERY_CHARS = 400;
201
+ export const normalizeWebQuery = (value) => {
202
+ if (typeof value !== "string")
203
+ return undefined;
204
+ const collapsed = value.replace(/\s+/g, " ").trim();
205
+ if (!collapsed)
206
+ return undefined;
207
+ return collapsed.length > MAX_WEB_QUERY_CHARS
208
+ ? collapsed.slice(0, MAX_WEB_QUERY_CHARS).trimEnd()
209
+ : collapsed;
210
+ };
164
211
  /** Pages to keep from a web research call, and how much of each. */
165
212
  const MAX_WEB_PAGES = 6;
166
213
  const MAX_WEB_PAGE_CHARS = 6000;
@@ -329,9 +376,10 @@ const docdexToolDefinitions = (client, options) => [
329
376
  // A judge that fails must not block a search that already succeeded.
330
377
  }
331
378
  }
332
- if (!localAnswers && !webResearchBlocked()) {
379
+ const webQuery = normalizeWebQuery(query);
380
+ if (!localAnswers && webQuery && !webResearchBlocked()) {
333
381
  try {
334
- const web = await client.search(query, { limit, forceWeb: true });
382
+ const web = await client.search(webQuery, { limit, forceWeb: true });
335
383
  if (hasWebFindings(web)) {
336
384
  return toOutput(web);
337
385
  }
@@ -604,6 +652,11 @@ const docdexToolDefinitions = (client, options) => [
604
652
  // a central bank's interest rate came back sourced to this repository's
605
653
  // own documentation. Anything genuinely local is what `docdex_search` is
606
654
  // for; a caller can still ask for the mixed behaviour explicitly.
655
+ const normalizedQuery = normalizeWebQuery(query);
656
+ if (!normalizedQuery) {
657
+ // Spending a metered request to be told this is malformed helps nobody.
658
+ return toOutput({ query, pages: [], unavailable: "empty query" });
659
+ }
607
660
  const blocked = webResearchBlocked();
608
661
  if (blocked) {
609
662
  // Answering "the web is unavailable" costs nothing; asking again costs
@@ -612,7 +665,7 @@ const docdexToolDefinitions = (client, options) => [
612
665
  }
613
666
  let result;
614
667
  try {
615
- result = await client.webResearch(query, {
668
+ result = await client.webResearch(normalizedQuery, {
616
669
  forceWeb: forceWeb ?? true,
617
670
  skipLocalSearch,
618
671
  webLimit,
@@ -632,7 +685,7 @@ const docdexToolDefinitions = (client, options) => [
632
685
  // was standing in for.
633
686
  if (!noCache && !hasWebContent(result)) {
634
687
  try {
635
- const fresh = await client.webResearch(query, {
688
+ const fresh = await client.webResearch(normalizedQuery, {
636
689
  forceWeb: forceWeb ?? true,
637
690
  skipLocalSearch,
638
691
  webLimit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcoda/codali",
3
- "version": "0.1.120",
3
+ "version": "0.1.122",
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.120",
39
- "@mcoda/shared": "0.1.120"
38
+ "@mcoda/db": "0.1.122",
39
+ "@mcoda/shared": "0.1.122"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.json",