@zackbart/connecta 0.41.0 → 0.42.0
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/CHANGELOG.md +61 -0
- package/bin/connecta.mjs +26 -17
- package/bin/meta-tool-names.d.mts +3 -1
- package/bin/meta-tool-names.mjs +4 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +2 -1
- package/dist/activity.js +5 -5
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-routes.js +31 -5
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth.js +3 -0
- package/dist/auth/static-oauth.js +26 -18
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +292 -24
- package/dist/catalog-freshness.js +12 -0
- package/dist/catalog-service.js +373 -70
- package/dist/catalog.js +147 -58
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +43 -17
- package/dist/config.js +26 -10
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +62 -5
- package/dist/connectors/remote-mcp.js +10 -1
- package/dist/describe-config.d.ts +3 -2
- package/dist/discovery-health.js +26 -11
- package/dist/errors.d.ts +52 -3
- package/dist/errors.js +86 -3
- package/dist/execute.js +476 -200
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +2 -2
- package/dist/executors/quickjs-child.js +13 -4
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +36 -19
- package/dist/executors/quickjs.js +23 -6
- package/dist/guest-functions.js +5 -4
- package/dist/guest-runtime.js +37 -1
- package/dist/guest-types.d.ts +18 -0
- package/dist/http/client.js +7 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +9 -3
- package/dist/http/paging.js +1 -1
- package/dist/http/provider.js +2 -6
- package/dist/http/vendor.js +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -0
- package/dist/invocation.js +98 -34
- package/dist/meta-output.js +24 -18
- package/dist/meta-tool-names.d.ts +1 -1
- package/dist/meta-tool-names.js +1 -1
- package/dist/meta-tools.js +259 -60
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +166 -51
- package/dist/node.js +3 -1
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +2 -1
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +1 -1
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/program-result.js +2 -0
- package/dist/program-source.js +1 -1
- package/dist/program-writes.js +112 -19
- package/dist/provider.d.ts +0 -13
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +2 -0
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +7 -2
- package/dist/providers/github/lib/auth.d.ts +3 -1
- package/dist/providers/github/lib/auth.js +43 -12
- package/dist/providers/github/lib/discovery.js +11 -1
- package/dist/providers/github/lib/operations.js +8 -0
- package/dist/providers/github/safety.js +39 -0
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +12 -5
- package/dist/providers/gmail/index.js +1 -0
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +2 -0
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +11 -5
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/index.js +2 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +2 -2
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +1 -0
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +2 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +127 -27
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +13 -4
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +55 -26
- package/dist/routes/shared.d.ts +6 -0
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/request-scope.js +36 -1
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skills.js +100 -33
- package/dist/storage/keys.js +9 -6
- package/dist/storage/sql-native.js +1 -1
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +31 -0
- package/dist/usage-guide.js +14 -13
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +28 -8
- package/documentation/architecture.md +104 -42
- package/documentation/auth.md +46 -19
- package/documentation/code-mode.md +378 -116
- package/documentation/deploying.md +10 -9
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +655 -153
- package/documentation/operating.md +5 -3
- package/documentation/providers.md +18 -6
- package/examples/worker/README.md +3 -3
- package/guides/how-it-works.md +2 -1
- package/guides/safety-and-trust.md +1 -3
- package/package.json +1 -1
- package/templates/node/AGENTS.md +3 -3
- package/templates/node/README.md +1 -1
- package/templates/node/package.json +1 -1
package/dist/catalog-service.js
CHANGED
|
@@ -4,19 +4,21 @@ import { utf8Length } from "./bytes.js";
|
|
|
4
4
|
import { catalogOwnerPartition } from "./catalog-policy.js";
|
|
5
5
|
import { registerAuthMethod } from "./auth/native.js";
|
|
6
6
|
import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
|
|
7
|
+
import { readProgramRefuses } from "./tool-safety.js";
|
|
7
8
|
import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
|
|
8
|
-
import { carryCatalogFreshness, catalogIsFresh,
|
|
9
|
+
import { carryCatalogFreshness, catalogGenerationFor, catalogIsFresh, catalogStaleness, markCatalogStale, } from "./catalog-freshness.js";
|
|
9
10
|
import { retainedCatalogIdentity } from "./catalog-cache.js";
|
|
10
11
|
import { cloneTool } from "./tool-safety.js";
|
|
11
12
|
import { hasControlCharacters } from "./tool-name.js";
|
|
12
|
-
import { Effect, Result } from "effect";
|
|
13
|
-
import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys,
|
|
13
|
+
import { Effect, Result, Semaphore } from "effect";
|
|
14
|
+
import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, discoverySummary, vocabularyScore, typescriptSignature, } from "./catalog.js";
|
|
14
15
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
15
16
|
import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
|
|
16
17
|
import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
|
|
17
|
-
import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
18
|
+
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
18
19
|
import { registryProgram } from "./runtime/registry.js";
|
|
19
20
|
import { SharedRead } from "./runtime/shared-read.js";
|
|
21
|
+
import { closeScope } from "./runtime/connector-scope.js";
|
|
20
22
|
import { duringViewEffect } from "./runtime/call.js";
|
|
21
23
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, UNSCOPED_DISCOVERY_BUDGET_MS, UNSCOPED_PROBE_TIMEOUT_MS, } from "./timeout.js";
|
|
22
24
|
import { claimDiscoveryProbe, readDiscoveryHealth, settleDiscoveryHealth, } from "./discovery-health.js";
|
|
@@ -24,6 +26,27 @@ import { keepErrorName } from "./redaction-identity.js";
|
|
|
24
26
|
import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
|
|
25
27
|
/** The slice of an unscoped search's budget kept for recording what its probes saw. */
|
|
26
28
|
const SETTLE_RESERVE_MS = 500;
|
|
29
|
+
/** Cached reads running at once in one discovery. Storage only, so they never wait for a probe slot. */
|
|
30
|
+
const CATALOG_PEEK_CONCURRENCY = 16;
|
|
31
|
+
/** The longest a stale catalog's read holds up a finished probe, and the least any peek gets. */
|
|
32
|
+
const STALE_READ_TIMEOUT_MS = 1_000;
|
|
33
|
+
/**
|
|
34
|
+
* One deferred refresh's whole tail, from being scheduled to the end of its
|
|
35
|
+
* cleanup: queueing, claim, listing, settlement and close. Inside the 30 seconds
|
|
36
|
+
* Workers keeps `waitUntil` alive.
|
|
37
|
+
*/
|
|
38
|
+
const CATALOG_REFRESH_TIMEOUT_MS = 20_000;
|
|
39
|
+
/** The end of that tail no listing may use: the record's settlement, then connector cleanup. */
|
|
40
|
+
const REFRESH_SETTLE_MS = 1_000;
|
|
41
|
+
const REFRESH_CLOSE_MS = 2_000;
|
|
42
|
+
/**
|
|
43
|
+
* Failures that say the connector did not answer, so its last complete catalog
|
|
44
|
+
* can stand in for the listing. Auth and permission failures, refusals and
|
|
45
|
+
* malformed catalogs are answers, and stay failures.
|
|
46
|
+
*/
|
|
47
|
+
const STALE_LISTING_CODES = new Set(["timeout", "unavailable", "rate_limited"]);
|
|
48
|
+
/** A retryable downstream failure (HTTP 502, 503 or 504); a refusal under this code is not retryable. */
|
|
49
|
+
const staleListingAllowed = (code, retryable) => STALE_LISTING_CODES.has(code) || (code === "connector_call_failed" && retryable);
|
|
27
50
|
const lexicalStores = new WeakMap();
|
|
28
51
|
let nextLexicalStore = 0;
|
|
29
52
|
/** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
|
|
@@ -38,10 +61,12 @@ class DiscoverySkipped extends Error {
|
|
|
38
61
|
this.retryAfterMs = retryAfterMs;
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
|
-
|
|
64
|
+
const DEFAULT_SEARCH_LIMIT = 8;
|
|
42
65
|
export const MAX_SEARCH_LIMIT = 100;
|
|
43
66
|
export const MAX_DESCRIBE_ADDRESSES = 100;
|
|
44
67
|
export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
|
|
68
|
+
/** UTF-8 bytes of full tool purposes one describe call returns; fallback summaries have their own bound. */
|
|
69
|
+
export const MAX_DESCRIBE_DESCRIPTION_BYTES = 32 * 1024;
|
|
45
70
|
const MAX_QUERY_TERMS = 8;
|
|
46
71
|
const MAX_QUERY_TERM_LENGTH = 64;
|
|
47
72
|
/**
|
|
@@ -268,6 +293,23 @@ function schemaKeyMetadata(input, output) {
|
|
|
268
293
|
...(outputKeys && outputKeys.properties.length > 0 ? { outputKeys: outputKeys.properties } : {}),
|
|
269
294
|
};
|
|
270
295
|
}
|
|
296
|
+
/** Fixed text and typed facts only: nothing here comes from the connector. */
|
|
297
|
+
function staleCatalogFailure(connector, stale) {
|
|
298
|
+
// A downstream that asked for time gets it: asking now would only be refused again.
|
|
299
|
+
const advice = stale.code === "rate_limited" || stale.downstreamWait
|
|
300
|
+
? `Wait${stale.retryAfterMs === undefined ? "" : ` ${stale.retryAfterMs} ms`} before searching with connector "${connector}".`
|
|
301
|
+
: `Search with connector "${connector}" to try it now.`;
|
|
302
|
+
return {
|
|
303
|
+
connector,
|
|
304
|
+
code: stale.code,
|
|
305
|
+
message: `Connector "${connector}" did not answer discovery (${stale.code}), so its tools are listed from its last complete catalog. Calls still check the live catalog. ${advice}`,
|
|
306
|
+
retryable: true,
|
|
307
|
+
...(stale.retryAfterMs === undefined ? {} : { retryAfterMs: stale.retryAfterMs }),
|
|
308
|
+
stale: true,
|
|
309
|
+
ageMs: stale.ageMs,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const staleListedText = (count) => `${count} connector catalog${count === 1 ? " is" : "s are"} listed from an older complete catalog; inspect catalogErrors.`;
|
|
271
313
|
const stringListSchema = { type: "array", items: { type: "string" } };
|
|
272
314
|
const STRING = { type: "string" };
|
|
273
315
|
// The reasons enum is stated once under `$defs` and referenced from each field.
|
|
@@ -281,6 +323,8 @@ const catalogFailureProperties = {
|
|
|
281
323
|
message: { type: "string" },
|
|
282
324
|
retryable: { type: "boolean" },
|
|
283
325
|
retryAfterMs: { type: "integer", minimum: 0 },
|
|
326
|
+
stale: { const: true },
|
|
327
|
+
ageMs: { type: "integer" },
|
|
284
328
|
connector: { type: "string" },
|
|
285
329
|
recovery: { enum: ["oauth", "operator_config", "unavailable"] },
|
|
286
330
|
status: { const: "auth_required" },
|
|
@@ -309,12 +353,12 @@ const catalogFailureProperties = {
|
|
|
309
353
|
// additive action advice compact in the always-loaded search schema.
|
|
310
354
|
nextAction: {
|
|
311
355
|
type: "object",
|
|
312
|
-
anyOf: [{ required: ["tool"] }, { required: ["action"] }],
|
|
356
|
+
anyOf: [{ required: ["url"] }, { required: ["tool"] }, { required: ["action"] }],
|
|
313
357
|
properties: {
|
|
358
|
+
url: STRING,
|
|
314
359
|
tool: { const: "authorize_connector" },
|
|
315
360
|
action: { enum: ["contact_operator", "inspect_capabilities"] },
|
|
316
361
|
purpose: STRING,
|
|
317
|
-
operatorHandoff: STRING,
|
|
318
362
|
arguments: { type: "object", properties: { connector: STRING } },
|
|
319
363
|
},
|
|
320
364
|
},
|
|
@@ -322,10 +366,10 @@ const catalogFailureProperties = {
|
|
|
322
366
|
/** The one typed failure (and auth handoff) both failure fields reference. */
|
|
323
367
|
const FAILURE_REF = { $ref: "#/$defs/failure" };
|
|
324
368
|
/**
|
|
325
|
-
* The
|
|
326
|
-
*
|
|
327
|
-
* Every failure field references one local `$defs` entry, so the typed
|
|
328
|
-
* handoff is
|
|
369
|
+
* The full page contract for either search adapter, which the server validates
|
|
370
|
+
* every page against. `tools/list` advertises the open summary in `meta-output.ts`
|
|
371
|
+
* instead. Every failure field references one local `$defs` entry, so the typed
|
|
372
|
+
* auth handoff is stated once.
|
|
329
373
|
*/
|
|
330
374
|
export const CATALOG_SEARCH_RESULT_SCHEMA = {
|
|
331
375
|
type: "object",
|
|
@@ -364,6 +408,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
|
|
|
364
408
|
name: { type: "string" },
|
|
365
409
|
address: { type: "string" },
|
|
366
410
|
description: { type: "string" },
|
|
411
|
+
descriptionTruncated: { const: true },
|
|
367
412
|
inputSchema: {},
|
|
368
413
|
outputSchema: {},
|
|
369
414
|
signature: { type: "string" },
|
|
@@ -451,6 +496,7 @@ export class CatalogService {
|
|
|
451
496
|
searchRoute;
|
|
452
497
|
requestSignal;
|
|
453
498
|
readOnly;
|
|
499
|
+
markUncallableWrites;
|
|
454
500
|
// The request-scoped catalog cache: one shared read per connector asked
|
|
455
501
|
// about, started by the first asker and joined by every later one. A
|
|
456
502
|
// success stays while its in-process invalidation fence matches, so a search
|
|
@@ -475,6 +521,13 @@ export class CatalogService {
|
|
|
475
521
|
// current by its own probes, so repeated searches read no storage.
|
|
476
522
|
health = new Map();
|
|
477
523
|
routes;
|
|
524
|
+
// This request's peeks: a stale listing is kept here, never in `catalogs`,
|
|
525
|
+
// because resolve reads `catalogs` and a call must never resolve through it.
|
|
526
|
+
peeks = new Map();
|
|
527
|
+
// At most one deferred refresh per connector per request.
|
|
528
|
+
refreshed = new Set();
|
|
529
|
+
refreshSlots;
|
|
530
|
+
unscopedProbeTimeoutMs;
|
|
478
531
|
constructor(registry, baseUrl, options = {}) {
|
|
479
532
|
this.registry = registry;
|
|
480
533
|
this.baseUrl = baseUrl;
|
|
@@ -483,11 +536,13 @@ export class CatalogService {
|
|
|
483
536
|
this.requestScope = options.requestScope ?? {};
|
|
484
537
|
this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
485
538
|
this.discoveryBudgetMs = normalizeTimeoutMs(options.discoveryBudgetMs) ?? UNSCOPED_DISCOVERY_BUDGET_MS;
|
|
539
|
+
this.unscopedProbeTimeoutMs = normalizeTimeoutMs(options.unscopedProbeTimeoutMs) ?? UNSCOPED_PROBE_TIMEOUT_MS;
|
|
486
540
|
this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
|
|
487
541
|
this.searchRoute = options.searchRoute ?? "search_tools";
|
|
488
542
|
this.requestSignal = options.requestSignal;
|
|
489
543
|
this.defer = options.defer;
|
|
490
544
|
this.readOnly = options.readOnly === true;
|
|
545
|
+
this.markUncallableWrites = options.markUncallableWrites === true;
|
|
491
546
|
}
|
|
492
547
|
/**
|
|
493
548
|
* Send a caller back to discovery through the surface it can actually reach.
|
|
@@ -571,10 +626,112 @@ export class CatalogService {
|
|
|
571
626
|
if (!view) {
|
|
572
627
|
view = toolsForSafety(readOnly ? tools.filter((tool) => tool.classification === "read") : tools, safety ?? "all");
|
|
573
628
|
carryLexicalCatalog(tools, view);
|
|
629
|
+
const stale = catalogStaleness(tools);
|
|
630
|
+
if (stale)
|
|
631
|
+
markCatalogStale(view, stale);
|
|
574
632
|
views.set(key, view);
|
|
575
633
|
}
|
|
576
634
|
return view;
|
|
577
635
|
}
|
|
636
|
+
// One connector's cached catalog, read at most once per request and identity.
|
|
637
|
+
// Storage only (INV-10), under its own deadline and the request's signal. A
|
|
638
|
+
// read that fails or runs out of time is a miss: discovery never fails on it.
|
|
639
|
+
peek(id, timeoutMs) {
|
|
640
|
+
return withDeadlineEffect((signal) => this.joinPeek(id, signal), {
|
|
641
|
+
...(this.requestSignal ? { signal: this.requestSignal } : {}),
|
|
642
|
+
timeoutMs,
|
|
643
|
+
timeoutError: new ConnectorCallError("timeout", `catalog peek of "${id}" timed out`),
|
|
644
|
+
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
|
|
645
|
+
}
|
|
646
|
+
joinPeek(id, signal) {
|
|
647
|
+
return Effect.suspend(() => {
|
|
648
|
+
const identity = retainedCatalogIdentity(this.registry.contextFor(id, this.baseUrl, this.requestScope));
|
|
649
|
+
let retained = this.peeks.get(id);
|
|
650
|
+
if (!retained || retained.identity !== identity) {
|
|
651
|
+
const started = new SharedRead((signal) => registryProgram(this.registry, "peekCatalog", id, this.baseUrl, this.requestScope, { signal }).pipe(Effect.map((peek) => {
|
|
652
|
+
if (!peek)
|
|
653
|
+
return undefined;
|
|
654
|
+
const connector = this.registry.getConnector(id);
|
|
655
|
+
for (const tool of peek.tools)
|
|
656
|
+
routeToolInPlace(connector, tool, this.routes);
|
|
657
|
+
return peek;
|
|
658
|
+
})), (succeeded) => {
|
|
659
|
+
if (!succeeded && this.peeks.get(id)?.read === started)
|
|
660
|
+
this.peeks.delete(id);
|
|
661
|
+
}, this.requestScope);
|
|
662
|
+
retained = { identity, read: started };
|
|
663
|
+
this.peeks.set(id, retained);
|
|
664
|
+
}
|
|
665
|
+
return retained.read.join(signal);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* A peek as this discovery's answer for a connector that did not. Whatever a
|
|
670
|
+
* listing would hide stays hidden: the view is filtered like a live one, and
|
|
671
|
+
* the mark is what makes search and native listing report it.
|
|
672
|
+
*/
|
|
673
|
+
staleListing(id, peek, includeWrites, failure) {
|
|
674
|
+
const source = markCatalogStale(peek.tools.map((tool) => cloneTool(tool)), { ...failure, ageMs: Math.max(0, Date.now() - peek.fetchedAt) });
|
|
675
|
+
return this.searchView(id, source, includeWrites, "all");
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Refresh a catalog whose probe ran out of time, in a request scope of its
|
|
679
|
+
* own under `defer`, the runtime's tail. Nothing here shares this request's
|
|
680
|
+
* signal, transports or reads, so nothing request-bound outlives the request
|
|
681
|
+
* (INV-7); the refresh owns its transport and closes it within its own
|
|
682
|
+
* deadline, which Workers `waitUntil` covers. It lists like any discovery:
|
|
683
|
+
* a partial walk publishes nothing (INV-8) and no authorization starts
|
|
684
|
+
* (INV-10). The health record's lease keeps other requests from starting
|
|
685
|
+
* another while this one runs, and its result settles that record.
|
|
686
|
+
*
|
|
687
|
+
* Every step answers to one clock, started as it is scheduled, so queueing,
|
|
688
|
+
* a slow claim and a slow settlement spend the same tail. Waiting on storage
|
|
689
|
+
* stops at its limit; a write already sent is left to land or to lapse.
|
|
690
|
+
*/
|
|
691
|
+
refreshLater(id, slot, spentMs) {
|
|
692
|
+
const tailMs = Math.min(this.probeTimeoutMs, CATALOG_REFRESH_TIMEOUT_MS);
|
|
693
|
+
const closeMs = Math.min(REFRESH_CLOSE_MS, Math.floor(tailMs / 4));
|
|
694
|
+
const settleMs = Math.min(REFRESH_SETTLE_MS, Math.floor(tailMs / 4));
|
|
695
|
+
if (!this.defer || this.refreshed.has(id) || tailMs - closeMs - settleMs <= spentMs)
|
|
696
|
+
return;
|
|
697
|
+
this.refreshed.add(id);
|
|
698
|
+
const scope = {};
|
|
699
|
+
const connector = this.registry.getConnector(id);
|
|
700
|
+
const endsAt = Date.now() + tailMs;
|
|
701
|
+
const listEnd = endsAt - closeMs - settleMs;
|
|
702
|
+
const timeoutError = new ConnectorCallError("timeout", `catalog refresh of "${id}" timed out`);
|
|
703
|
+
const until = (limit, storage, otherwise) => Effect.suspend(() => limit <= Date.now()
|
|
704
|
+
? Effect.succeed(otherwise)
|
|
705
|
+
: withDeadlineEffect(() => storage, { timeoutMs: limit - Date.now(), timeoutError }).pipe(Effect.catchCause(() => Effect.succeed(otherwise))));
|
|
706
|
+
const refresh = Effect.gen({ self: this }, function* () {
|
|
707
|
+
const claimed = yield* until(listEnd, claimDiscoveryProbe(slot, endsAt + CLAIM_MARGIN_MS), false);
|
|
708
|
+
// Only what the claim left: a claim that spent it all dispatches nothing,
|
|
709
|
+
// and its lease lapses by itself.
|
|
710
|
+
const timeoutMs = listEnd - Date.now();
|
|
711
|
+
if (!claimed || !connector || timeoutMs <= 0)
|
|
712
|
+
return;
|
|
713
|
+
const started = Date.now();
|
|
714
|
+
const result = yield* Effect.result(withDeadlineEffect((signal) => registryProgram(this.registry, "getTools", id, this.baseUrl, scope, {
|
|
715
|
+
signal,
|
|
716
|
+
timeoutMs,
|
|
717
|
+
...(this.defer ? { defer: this.defer } : {}),
|
|
718
|
+
}), { timeoutMs, timeoutError }));
|
|
719
|
+
yield* until(endsAt - closeMs, settleDiscoveryHealth(slot, Result.isSuccess(result)
|
|
720
|
+
? { ok: true }
|
|
721
|
+
: {
|
|
722
|
+
ok: false,
|
|
723
|
+
failure: result.failure,
|
|
724
|
+
timedOut: result.failure === timeoutError,
|
|
725
|
+
elapsedMs: Date.now() - started,
|
|
726
|
+
}), undefined);
|
|
727
|
+
});
|
|
728
|
+
this.refreshSlots ??= Semaphore.makeUnsafe(this.concurrency);
|
|
729
|
+
detach(this.refreshSlots
|
|
730
|
+
.withPermits(1)(refresh)
|
|
731
|
+
.pipe(Effect.ensuring(Effect.suspend(() => connector
|
|
732
|
+
? closeScope(connector, this.registry.contextFor(id, this.baseUrl, scope), this.defer)
|
|
733
|
+
: Effect.void))), { waitUntil: this.defer });
|
|
734
|
+
}
|
|
578
735
|
// Every named catalog under its own probe deadline, `concurrency` at a time,
|
|
579
736
|
// each settling in its input slot: an unavailable catalog is a Failure
|
|
580
737
|
// there, not a failed discovery. `route` names the surface in a timeout.
|
|
@@ -594,11 +751,22 @@ export class CatalogService {
|
|
|
594
751
|
// it, then runs to its end. A lapsed record is claimed as its probe is
|
|
595
752
|
// dispatched, leased to the end of this budget plus a margin.
|
|
596
753
|
// Caller cancellation and a probe the budget cut short leave health as it was.
|
|
597
|
-
|
|
754
|
+
//
|
|
755
|
+
// Unscoped search and a native listing also serve a connector's last complete
|
|
756
|
+
// catalog, marked stale, when it did not answer in time. A cached or authored
|
|
757
|
+
// catalog is read without a probe slot, so only a connector that must actually
|
|
758
|
+
// be asked waits for one. A native listing has no budget and gives a connector
|
|
759
|
+
// with nothing to fall back on its full deadline, since it would otherwise vanish.
|
|
760
|
+
searchCatalogs(ids, mode, includeWrites = false) {
|
|
598
761
|
return Effect.gen({ self: this }, function* () {
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
const
|
|
762
|
+
const scoped = mode === "scoped";
|
|
763
|
+
const shortDeadline = Math.min(this.probeTimeoutMs, this.unscopedProbeTimeoutMs);
|
|
764
|
+
const budgeted = mode === "unscoped";
|
|
765
|
+
const route = mode === "native" ? "tools/list" : this.searchRoute;
|
|
766
|
+
const budgetEnd = budgeted ? Date.now() + this.discoveryBudgetMs : Infinity;
|
|
767
|
+
const settleReserve = budgeted ? Math.min(SETTLE_RESERVE_MS, Math.floor(this.discoveryBudgetMs / 10)) : 0;
|
|
768
|
+
const slots = yield* Semaphore.make(this.concurrency);
|
|
769
|
+
const peekSlots = yield* Semaphore.make(CATALOG_PEEK_CONCURRENCY);
|
|
602
770
|
// Health storage starts only while budget remains and the caller is
|
|
603
771
|
// here; running out is not a failure, health is then unknown. Once
|
|
604
772
|
// started, an operation is awaited to its end, never raced or detached:
|
|
@@ -630,60 +798,142 @@ export class CatalogService {
|
|
|
630
798
|
});
|
|
631
799
|
};
|
|
632
800
|
const probeOne = (id) => Effect.gen({ self: this }, function* () {
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
if (
|
|
801
|
+
const connector = this.registry.getConnector(id);
|
|
802
|
+
// An authored catalog answers from configuration: no slot, no budget, no health.
|
|
803
|
+
if (!scoped && connector?.staticTools)
|
|
636
804
|
return {
|
|
637
805
|
id,
|
|
638
|
-
result:
|
|
806
|
+
result: yield* Effect.result(this.probe(id, includeWrites, this.probeTimeoutMs, new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`))),
|
|
639
807
|
};
|
|
808
|
+
// Storage only, so a connector with a current catalog never queues
|
|
809
|
+
// behind one that must be asked. A custom connector is always listed
|
|
810
|
+
// anew and starts no cache I/O here; it peeks only if that fails.
|
|
811
|
+
let peek = !scoped && connector?.peekCatalog && Date.now() < budgetEnd
|
|
812
|
+
? yield* peekSlots.withPermits(1)(this.peek(id, Math.max(shortDeadline, STALE_READ_TIMEOUT_MS)))
|
|
813
|
+
: undefined;
|
|
814
|
+
const verdict = scoped ? undefined : this.health.get(id);
|
|
815
|
+
const skip = verdict?.skip;
|
|
816
|
+
// A peek is only as good as the moment it was read. After any wait for
|
|
817
|
+
// a slot or a probe it is read again, through every acceptance fence,
|
|
818
|
+
// so an invalidation, a rotated credential or a refusal in the
|
|
819
|
+
// meantime retires it, in this isolate or another.
|
|
820
|
+
const staleAnswer = (failure, waited = true) => Effect.gen({ self: this }, function* () {
|
|
821
|
+
// Caller cancellation is not a connector that failed to answer.
|
|
822
|
+
if (scoped || this.requestSignal?.aborted)
|
|
823
|
+
return undefined;
|
|
824
|
+
if (waited)
|
|
825
|
+
this.peeks.delete(id);
|
|
826
|
+
if (waited || !peek)
|
|
827
|
+
peek = yield* this.peek(id, STALE_READ_TIMEOUT_MS);
|
|
828
|
+
return peek ? this.staleListing(id, peek, includeWrites, failure) : undefined;
|
|
829
|
+
});
|
|
830
|
+
if (skip && skip.retryAt > Date.now() && !peek?.fresh) {
|
|
831
|
+
const retryAfterMs = Math.ceil(skip.retryAt - Date.now());
|
|
832
|
+
// Discovery health records only timeouts and retryable failures.
|
|
833
|
+
if (staleListingAllowed(skip.code, true)) {
|
|
834
|
+
const stale = yield* staleAnswer({ code: skip.code, retryAfterMs }, false);
|
|
835
|
+
if (stale)
|
|
836
|
+
return { id, result: Result.succeed(stale) };
|
|
837
|
+
}
|
|
838
|
+
// A native listing has no search to come back through: it still asks.
|
|
839
|
+
if (mode !== "native")
|
|
840
|
+
return { id, result: Result.fail(new DiscoverySkipped(skip.code, retryAfterMs)) };
|
|
841
|
+
}
|
|
842
|
+
// With something to fall back on a short deadline is enough. Without
|
|
843
|
+
// it, a native listing waits its full deadline, as the connector
|
|
844
|
+
// would otherwise be absent; unscoped search keeps its short one.
|
|
845
|
+
const deadline = scoped || (mode === "native" && !peek) ? this.probeTimeoutMs : shortDeadline;
|
|
640
846
|
const timeoutFor = (remaining) => {
|
|
641
847
|
const budgetCut = remaining < deadline;
|
|
642
848
|
return {
|
|
643
849
|
budgetCut,
|
|
644
850
|
timeoutMs: budgetCut ? remaining : deadline,
|
|
645
851
|
timeoutError: budgetCut
|
|
646
|
-
? new ConnectorCallError("timeout", `${
|
|
647
|
-
: new ConnectorCallError("timeout", `${
|
|
852
|
+
? new ConnectorCallError("timeout", `${route} stopped probing "${id}" at the ${this.discoveryBudgetMs}ms discovery budget`)
|
|
853
|
+
: new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${deadline}ms`),
|
|
648
854
|
};
|
|
649
855
|
};
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
// lease runs to an absolute time, the end of this request's
|
|
655
|
-
// discovery budget plus a margin, so however long the claim write
|
|
656
|
-
// takes, the probe the budget still allows ends inside it.
|
|
657
|
-
if (skip && verdict?.slot) {
|
|
658
|
-
const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
|
|
659
|
-
const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
|
|
660
|
-
if (!claimed) {
|
|
661
|
-
this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
|
|
662
|
-
return {
|
|
663
|
-
id,
|
|
664
|
-
result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
this.health.set(id, { slot: verdict.slot });
|
|
668
|
-
plan = timeoutFor(Math.ceil(budgetEnd - settleReserve - Date.now()));
|
|
856
|
+
const asked = Effect.gen({ self: this }, function* () {
|
|
857
|
+
// A current catalog is a storage read in hand, not a probe the budget has to bound.
|
|
858
|
+
const remaining = () => (peek?.fresh ? Infinity : Math.ceil(budgetEnd - settleReserve - Date.now()));
|
|
859
|
+
let plan = timeoutFor(remaining());
|
|
669
860
|
if (plan.timeoutMs <= 0)
|
|
670
|
-
return {
|
|
861
|
+
return { result: Result.fail(plan.timeoutError), plan };
|
|
862
|
+
// A lapsed record: claim this probe now, as it is dispatched. The
|
|
863
|
+
// lease runs to an absolute time, the end of this request's
|
|
864
|
+
// discovery budget plus a margin, so however long the claim write
|
|
865
|
+
// takes, the probe the budget still allows ends inside it.
|
|
866
|
+
if (budgeted && skip && verdict?.slot && !peek?.fresh) {
|
|
867
|
+
const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
|
|
868
|
+
const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
|
|
869
|
+
if (!claimed) {
|
|
870
|
+
this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
|
|
871
|
+
return {
|
|
872
|
+
result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
|
|
873
|
+
plan,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
this.health.set(id, { slot: verdict.slot });
|
|
877
|
+
plan = timeoutFor(remaining());
|
|
878
|
+
if (plan.timeoutMs <= 0)
|
|
879
|
+
return { result: Result.fail(plan.timeoutError), plan };
|
|
880
|
+
}
|
|
881
|
+
const started = Date.now();
|
|
882
|
+
const result = yield* Effect.result(this.probe(id, includeWrites, plan.timeoutMs, plan.timeoutError));
|
|
883
|
+
// Elapsed time is this probe's own, measured as it settles.
|
|
884
|
+
return { result, plan, elapsedMs: Date.now() - started };
|
|
885
|
+
});
|
|
886
|
+
// A current catalog's read takes no slot: it hands the peek to the listing.
|
|
887
|
+
const { result, plan, elapsedMs } = (peek?.fresh ? yield* asked : yield* slots.withPermits(1)(asked));
|
|
888
|
+
if (elapsedMs === undefined) {
|
|
889
|
+
if (Result.isFailure(result)) {
|
|
890
|
+
const failure = result.failure;
|
|
891
|
+
const stale = yield* staleAnswer(failure instanceof DiscoverySkipped
|
|
892
|
+
? { code: failure.code, retryAfterMs: failure.retryAfterMs }
|
|
893
|
+
: { code: "timeout" });
|
|
894
|
+
if (stale)
|
|
895
|
+
return { id, result: Result.succeed(stale) };
|
|
896
|
+
}
|
|
897
|
+
return { id, result };
|
|
671
898
|
}
|
|
672
|
-
const started = Date.now();
|
|
673
|
-
const result = yield* Effect.result(this.probe(id, false, plan.timeoutMs, plan.timeoutError));
|
|
674
|
-
// Elapsed time is this probe's own, measured as it settles.
|
|
675
|
-
const elapsedMs = Date.now() - started;
|
|
676
899
|
const outcome = plan.budgetCut && Result.isFailure(result) && result.failure === plan.timeoutError
|
|
677
900
|
? { id, result }
|
|
678
901
|
: { id, result, settle: { timeoutError: plan.timeoutError, elapsedMs } };
|
|
679
902
|
if (scoped)
|
|
680
903
|
return outcome;
|
|
681
|
-
|
|
904
|
+
// A catalog served from cache says nothing about a recorded failure.
|
|
905
|
+
if (!(skip && peek?.fresh))
|
|
906
|
+
yield* settleOne(outcome);
|
|
907
|
+
if (Result.isSuccess(result))
|
|
908
|
+
return { id, result };
|
|
909
|
+
const failure = result.failure;
|
|
910
|
+
// A probe that ran out of time: its abort left nothing cached, so ask
|
|
911
|
+
// again past this request, under the longer deadline the runtime's tail allows.
|
|
912
|
+
if (failure === plan.timeoutError && !plan.budgetCut) {
|
|
913
|
+
// Only the request whose settlement recorded this: anyone else lost
|
|
914
|
+
// to a record that already has an owner, or wrote nothing at all.
|
|
915
|
+
const recorded = this.health.get(id);
|
|
916
|
+
if (recorded?.slot && recorded.recorded)
|
|
917
|
+
this.refreshLater(id, recorded.slot, plan.timeoutMs);
|
|
918
|
+
}
|
|
919
|
+
const classified = failure === plan.timeoutError
|
|
920
|
+
? { code: "timeout", retryable: true }
|
|
921
|
+
: classifyCallError(failure, "catalog_lookup_failed");
|
|
922
|
+
if (staleListingAllowed(classified.code, classified.retryable)) {
|
|
923
|
+
const stale = yield* staleAnswer({
|
|
924
|
+
code: classified.code,
|
|
925
|
+
...(classified.retryAfterMs === undefined
|
|
926
|
+
? {}
|
|
927
|
+
: { retryAfterMs: classified.retryAfterMs, downstreamWait: true }),
|
|
928
|
+
});
|
|
929
|
+
if (stale)
|
|
930
|
+
return { id, result: Result.succeed(stale) };
|
|
931
|
+
}
|
|
682
932
|
return { id, result };
|
|
683
933
|
});
|
|
684
934
|
if (!scoped) {
|
|
685
935
|
yield* readHealth;
|
|
686
|
-
const probed = yield* Effect.forEach(ids, probeOne, { concurrency:
|
|
936
|
+
const probed = yield* Effect.forEach(ids, probeOne, { concurrency: "unbounded" });
|
|
687
937
|
return probed.map((outcome) => outcome.result);
|
|
688
938
|
}
|
|
689
939
|
const [, probed] = yield* Effect.all([readHealth, Effect.forEach(ids, probeOne, { concurrency: this.concurrency })], { concurrency: 2 });
|
|
@@ -700,13 +950,20 @@ export class CatalogService {
|
|
|
700
950
|
* settles on its own, so one unavailable connector is an `error` entry
|
|
701
951
|
* beside the others rather than a failed listing.
|
|
702
952
|
*/
|
|
703
|
-
listCatalogs(includeWrites = false, connectors = this.registry.listConnectors()) {
|
|
704
|
-
return runEdge(this.listCatalogsEffect(includeWrites, connectors));
|
|
953
|
+
listCatalogs(includeWrites = false, connectors = this.registry.listConnectors(), allowStale = false) {
|
|
954
|
+
return runEdge(this.listCatalogsEffect(includeWrites, connectors, allowStale));
|
|
705
955
|
}
|
|
706
|
-
|
|
956
|
+
/**
|
|
957
|
+
* `allowStale` is for a listing only. Resolving a native call must not pass
|
|
958
|
+
* it: uniqueness and authority come from a catalog that answered.
|
|
959
|
+
*/
|
|
960
|
+
listCatalogsEffect(includeWrites = false, connectors, allowStale = false) {
|
|
707
961
|
return duringViewEffect({ requestScope: this.requestScope, signal: this.requestSignal }, () => Effect.suspend(() => {
|
|
708
962
|
const visible = connectors ?? this.registry.listConnectors();
|
|
709
|
-
|
|
963
|
+
const ids = visible.map((connector) => connector.id);
|
|
964
|
+
return Effect.map(allowStale
|
|
965
|
+
? this.searchCatalogs(ids, "native", includeWrites)
|
|
966
|
+
: this.discoveryCatalogs(ids, "tools/list", includeWrites), (results) => results.map((result, index) => {
|
|
710
967
|
const connector = visible[index];
|
|
711
968
|
return Result.isSuccess(result)
|
|
712
969
|
? { connector, tools: carryCatalogFreshness(result.success, result.success.map(cloneTool)) }
|
|
@@ -741,7 +998,10 @@ export class CatalogService {
|
|
|
741
998
|
cause,
|
|
742
999
|
};
|
|
743
1000
|
}
|
|
744
|
-
unknownToolFailure(toolName, connector, started) {
|
|
1001
|
+
unknownToolFailure(toolName, connector, started, tools) {
|
|
1002
|
+
// The same view discovery serves: a read-only scope never names a write here.
|
|
1003
|
+
const visible = this.readOnly || connector.readOnly === true ? tools.filter((tool) => tool.classification === "read") : tools;
|
|
1004
|
+
const suggestions = hasControlCharacters(toolName) ? [] : describeSuggestions(connector.id, toolName, visible);
|
|
745
1005
|
return {
|
|
746
1006
|
ok: false,
|
|
747
1007
|
error: {
|
|
@@ -749,6 +1009,7 @@ export class CatalogService {
|
|
|
749
1009
|
? `Unknown tool on connector "${connector.id}"`
|
|
750
1010
|
: `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
|
|
751
1011
|
nextAction: this.searchRecovery({ query: hasControlCharacters(toolName) ? "" : recoveryQuery(toolName), connector: connector.id }, "Find the connector's current canonical tool address."),
|
|
1012
|
+
...(suggestions.length > 0 ? { suggestions } : {}),
|
|
752
1013
|
},
|
|
753
1014
|
catalogMs: Date.now() - started,
|
|
754
1015
|
connector,
|
|
@@ -774,7 +1035,7 @@ export class CatalogService {
|
|
|
774
1035
|
onSuccess: (tools) => {
|
|
775
1036
|
const definition = tools.find((tool) => tool.name === toolName);
|
|
776
1037
|
if (!definition) {
|
|
777
|
-
return this.unknownToolFailure(toolName, connector, started);
|
|
1038
|
+
return this.unknownToolFailure(toolName, connector, started, tools);
|
|
778
1039
|
}
|
|
779
1040
|
return {
|
|
780
1041
|
ok: true,
|
|
@@ -888,7 +1149,7 @@ export class CatalogService {
|
|
|
888
1149
|
: undefined;
|
|
889
1150
|
const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
|
|
890
1151
|
// Timeout messages name the route the caller used.
|
|
891
|
-
const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
|
|
1152
|
+
const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined ? "scoped" : "unscoped");
|
|
892
1153
|
const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => {
|
|
893
1154
|
const id = connectors[index].id;
|
|
894
1155
|
const ctx = this.registry.contextFor(id, this.baseUrl, this.requestScope);
|
|
@@ -978,21 +1239,36 @@ export class CatalogService {
|
|
|
978
1239
|
order: original.success.indexOf(tool),
|
|
979
1240
|
exactName: false,
|
|
980
1241
|
matchedTermCount: toolTermCount,
|
|
1242
|
+
headTerms: [],
|
|
1243
|
+
nameTerms: [],
|
|
1244
|
+
namesDistinctiveTerm: false,
|
|
981
1245
|
});
|
|
982
1246
|
}
|
|
983
1247
|
}
|
|
1248
|
+
// A tool on the named connector need not repeat that connector's name.
|
|
1249
|
+
const statedTerms = args.connector || exactConnector ? toolTerms.filter((term) => !connectorTerms.has(term)) : toolTerms;
|
|
1250
|
+
const requiredTerms = statedTerms.length > 0 ? statedTerms : toolTerms;
|
|
984
1251
|
for (const ranked of rankedTools) {
|
|
1252
|
+
const complete = isBrowse || requiredTerms.every((term) => ranked.headTerms.includes(term));
|
|
1253
|
+
const vocabulary = vocabularyTools.has(ranked.tool);
|
|
1254
|
+
// Discovery pointers follow every tool that answers lexically.
|
|
1255
|
+
const vocabularyRank = complete ? 0 : vocabularyScore(requiredTerms, statistics);
|
|
985
1256
|
collected.push({
|
|
986
1257
|
connector,
|
|
987
1258
|
tool: ranked.tool,
|
|
988
|
-
score:
|
|
1259
|
+
score: vocabulary && !ranked.exactName ? vocabularyRank : ranked.score,
|
|
989
1260
|
order: orderBase + ranked.order,
|
|
990
|
-
|
|
1261
|
+
// A tool named just `search` is not what a longer query containing that verb names.
|
|
1262
|
+
exactName: ranked.exactName && !ACTION_WORD.test(ranked.tool.name.toLowerCase()),
|
|
991
1263
|
exactLookup: rawPhrase === lexicalQueryTerms(ranked.tool.name).join(" ") ||
|
|
992
1264
|
rawPhrase === lexicalQueryTerms(`${connector.id}.${ranked.tool.name}`).join(" "),
|
|
993
1265
|
connectorRank,
|
|
994
1266
|
matchedTermCount: vocabularyTools.has(ranked.tool) ? toolTermCount : ranked.matchedTermCount,
|
|
995
|
-
complete
|
|
1267
|
+
complete,
|
|
1268
|
+
covered: isBrowse || vocabulary || ranked.matchedTermCount === toolTermCount,
|
|
1269
|
+
vocabulary,
|
|
1270
|
+
namesSubject: ranked.nameTerms.some((term) => !ACTION_WORD.test(term) && !connectorTerms.has(term)),
|
|
1271
|
+
namesDistinctiveTerm: ranked.namesDistinctiveTerm,
|
|
996
1272
|
});
|
|
997
1273
|
}
|
|
998
1274
|
}
|
|
@@ -1005,12 +1281,13 @@ export class CatalogService {
|
|
|
1005
1281
|
// unrelated zero-score match, with no coverage to explain the result.
|
|
1006
1282
|
const rankedMatches = unsearchableQuery ? [] : collectMatches(isBrowse ? "all" : "partial");
|
|
1007
1283
|
const completeMatchCount = rankedMatches.filter((match) => match.complete).length;
|
|
1008
|
-
matches.push(...rankedMatches.filter((match) => match.
|
|
1284
|
+
matches.push(...rankedMatches.filter((match) => match.covered ||
|
|
1285
|
+
match.namesDistinctiveTerm ||
|
|
1009
1286
|
completeMatchCount === 0 ||
|
|
1010
1287
|
match.exactName ||
|
|
1011
1288
|
match.connectorRank > 0 ||
|
|
1012
1289
|
match.matchedTermCount >= 2));
|
|
1013
|
-
if (!isBrowse && !unsearchableQuery &&
|
|
1290
|
+
if (!isBrowse && !unsearchableQuery && !rankedMatches.some((match) => match.covered)) {
|
|
1014
1291
|
matchMode = "partial";
|
|
1015
1292
|
}
|
|
1016
1293
|
// A read-intent verb leads the query once connector names are set aside.
|
|
@@ -1021,13 +1298,15 @@ export class CatalogService {
|
|
|
1021
1298
|
// longer name such as get_issue_status cannot beat an exact get_issue
|
|
1022
1299
|
// merely because its description repeats rare query terms. Within a
|
|
1023
1300
|
// tier, a read-intent query puts reads before writes.
|
|
1301
|
+
// A hatch with a matching operation stays above partial matches, as does a
|
|
1302
|
+
// tool that names the query's subject and states the rest in its tail. A
|
|
1303
|
+
// tail alone earns no tier: long prose can mention anything.
|
|
1304
|
+
const tier = (match) => match.complete || match.exactName ? 2 : match.vocabulary || (match.covered && match.namesSubject) ? 1 : 0;
|
|
1024
1305
|
matches.sort((a, b) => {
|
|
1025
|
-
const aFirstTier = a.complete || a.exactName;
|
|
1026
|
-
const bFirstTier = b.complete || b.exactName;
|
|
1027
1306
|
return (Number(b.exactLookup) - Number(a.exactLookup) ||
|
|
1028
1307
|
Number(b.exactName) - Number(a.exactName) ||
|
|
1029
1308
|
b.connectorRank - a.connectorRank ||
|
|
1030
|
-
|
|
1309
|
+
tier(b) - tier(a) ||
|
|
1031
1310
|
readFirst(b) - readFirst(a) ||
|
|
1032
1311
|
b.score - a.score ||
|
|
1033
1312
|
a.order - b.order);
|
|
@@ -1056,7 +1335,10 @@ export class CatalogService {
|
|
|
1056
1335
|
? renderSearchSchema(output.schema, schemaFormat)
|
|
1057
1336
|
: undefined;
|
|
1058
1337
|
const schemaKeys = withSchemas && args.includeSchemaKeys ? schemaKeyMetadata(input, output.schema) : undefined;
|
|
1059
|
-
const
|
|
1338
|
+
const summary = match.tool.description && args.fullDescriptions !== true
|
|
1339
|
+
? discoverySummary(match.tool.description)
|
|
1340
|
+
: undefined;
|
|
1341
|
+
const description = summary ? summary.text : match.tool.description || undefined;
|
|
1060
1342
|
const requiredReasons = guideRequiredReasons(match.connector, match.tool, renderedInput?.truncated === true || renderedOutput?.truncated === true);
|
|
1061
1343
|
const guideSummary = connectorGuideSummary(match.connector);
|
|
1062
1344
|
return {
|
|
@@ -1071,6 +1353,7 @@ export class CatalogService {
|
|
|
1071
1353
|
name: match.tool.name,
|
|
1072
1354
|
address: `${match.connector.id}.${match.tool.name}`,
|
|
1073
1355
|
...(description !== undefined ? { description } : {}),
|
|
1356
|
+
...(summary?.truncated ? { descriptionTruncated: true } : {}),
|
|
1074
1357
|
...(schemaFormat
|
|
1075
1358
|
? {
|
|
1076
1359
|
inputSchema: renderedInput?.schema,
|
|
@@ -1095,6 +1378,7 @@ export class CatalogService {
|
|
|
1095
1378
|
...(schemaKeys?.outputKeys && !renderedOutput?.truncated ? { outputKeys: schemaKeys.outputKeys } : {}),
|
|
1096
1379
|
...(match.tool.annotations ? { annotations: match.tool.annotations } : {}),
|
|
1097
1380
|
classification: match.tool.classification,
|
|
1381
|
+
...(this.markUncallableWrites && readProgramRefuses(match.tool) ? { callable: false } : {}),
|
|
1098
1382
|
...(requiredReasons
|
|
1099
1383
|
? {
|
|
1100
1384
|
guideRequired: true,
|
|
@@ -1161,14 +1445,19 @@ export class CatalogService {
|
|
|
1161
1445
|
}
|
|
1162
1446
|
}
|
|
1163
1447
|
const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1448
|
+
const staleCatalogs = catalogs.filter((catalog) => Result.isSuccess(catalog) && catalogStaleness(catalog.success)).length;
|
|
1449
|
+
const catalogErrors = (yield* Effect.forEach(catalogs, (catalog, index) => {
|
|
1450
|
+
if (Result.isFailure(catalog))
|
|
1451
|
+
return this.catalogFailure(connectors[index], catalog.failure).pipe(Effect.map((error) => [error]));
|
|
1452
|
+
const stale = catalogStaleness(catalog.success);
|
|
1453
|
+
return Effect.succeed(stale ? [staleCatalogFailure(connectors[index].id, stale)] : []);
|
|
1454
|
+
}, { concurrency: "unbounded" })).flat();
|
|
1167
1455
|
const needsAuth = (error) => error.code === "auth_required" ||
|
|
1168
1456
|
error.code === "downstream_oauth_required" ||
|
|
1169
1457
|
error.code === "provider_permission_denied";
|
|
1170
1458
|
catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
|
|
1171
|
-
|
|
1459
|
+
// A stale row beside listed tools is not this scoped search's failure.
|
|
1460
|
+
const scopedFailure = args.connector ? catalogErrors.find((error) => !error.stale) : undefined;
|
|
1172
1461
|
const scopedCatalogError = scopedFailure
|
|
1173
1462
|
? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
|
|
1174
1463
|
? scopedFailure
|
|
@@ -1238,6 +1527,7 @@ export class CatalogService {
|
|
|
1238
1527
|
(unavailableCatalogs
|
|
1239
1528
|
? ` ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable; inspect catalogErrors.`
|
|
1240
1529
|
: "") +
|
|
1530
|
+
(staleCatalogs ? ` ${staleListedText(staleCatalogs)}` : "") +
|
|
1241
1531
|
filterRecovery;
|
|
1242
1532
|
// A scope that resolved to nothing is the same silence one step earlier in
|
|
1243
1533
|
// the lookup: no connector resolved, so no catalog was even attempted, so
|
|
@@ -1272,7 +1562,7 @@ export class CatalogService {
|
|
|
1272
1562
|
}
|
|
1273
1563
|
if (queryTerms.length === 0) {
|
|
1274
1564
|
if (unavailableCatalogs === 0)
|
|
1275
|
-
return undefined;
|
|
1565
|
+
return staleCatalogs ? staleListedText(staleCatalogs) : undefined;
|
|
1276
1566
|
return scopedConnector
|
|
1277
1567
|
? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
|
|
1278
1568
|
: `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Inspect catalogErrors for the typed reasons and recovery actions.`;
|
|
@@ -1291,8 +1581,10 @@ export class CatalogService {
|
|
|
1291
1581
|
if (identityGuidance || apiSearchGuidance)
|
|
1292
1582
|
return ([identityGuidance, apiSearchGuidance].filter(Boolean).join(" ") +
|
|
1293
1583
|
(apiSearchGuidance ? "" : filterRecovery));
|
|
1584
|
+
if (unavailableCatalogs === 0 && staleCatalogs)
|
|
1585
|
+
return `No matching ${safetyLabel}capability was found. ${staleListedText(staleCatalogs)} Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
|
|
1294
1586
|
return unavailableCatalogs === 0
|
|
1295
|
-
? `No
|
|
1587
|
+
? `No ${safetyLabel}tool matched these terms. Try other terms, scope by connector, or browse with an empty query.${filterRecovery}`
|
|
1296
1588
|
: `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
|
|
1297
1589
|
}
|
|
1298
1590
|
if (matchMode !== "partial")
|
|
@@ -1399,6 +1691,10 @@ export class CatalogService {
|
|
|
1399
1691
|
: undefined;
|
|
1400
1692
|
return [id, error];
|
|
1401
1693
|
}), { concurrency: "unbounded" }));
|
|
1694
|
+
// Full prose shares one budget in address order, so a large batch keeps its
|
|
1695
|
+
// schemas and a repeated describe renders the same text. A fallback summary
|
|
1696
|
+
// spends none of it: every entry keeps its purpose, within 480 bytes each.
|
|
1697
|
+
let descriptionBytes = MAX_DESCRIBE_DESCRIPTION_BYTES;
|
|
1402
1698
|
return resolved.map(({ address, resolved: addressResolution }) => {
|
|
1403
1699
|
if (!addressResolution) {
|
|
1404
1700
|
const message = `Unknown address "${boundedEchoText(address)}"`;
|
|
@@ -1451,7 +1747,12 @@ export class CatalogService {
|
|
|
1451
1747
|
const tool = cloneTool(selected);
|
|
1452
1748
|
const input = tool.inputSchema ?? { type: "object" };
|
|
1453
1749
|
const output = this.outputSchema(addressResolution.connector.id, tool);
|
|
1454
|
-
const
|
|
1750
|
+
const bytes = tool.description ? encoder.encode(tool.description).length : 0;
|
|
1751
|
+
const withinBudget = args.fullDescriptions === true || bytes <= descriptionBytes;
|
|
1752
|
+
if (withinBudget)
|
|
1753
|
+
descriptionBytes -= bytes;
|
|
1754
|
+
const summary = withinBudget || !tool.description ? undefined : discoverySummary(tool.description);
|
|
1755
|
+
const description = summary ? summary.text : tool.description || undefined;
|
|
1455
1756
|
const signature = format === "typescript"
|
|
1456
1757
|
? typescriptSignature(input, output.schema, {
|
|
1457
1758
|
observed: output.source === "observed",
|
|
@@ -1469,6 +1770,7 @@ export class CatalogService {
|
|
|
1469
1770
|
schemaFormat: format === "json" ? "json" : "text",
|
|
1470
1771
|
name: tool.name,
|
|
1471
1772
|
...(description !== undefined ? { description } : {}),
|
|
1773
|
+
...(summary?.truncated ? { descriptionTruncated: true } : {}),
|
|
1472
1774
|
...(connectorGuide(addressResolution.connector)
|
|
1473
1775
|
? {
|
|
1474
1776
|
guide: connectorSkillName(addressResolution.connector.id),
|
|
@@ -1492,6 +1794,7 @@ export class CatalogService {
|
|
|
1492
1794
|
...(output.source ? { outputSchemaSource: output.source } : {}),
|
|
1493
1795
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
1494
1796
|
classification: tool.classification,
|
|
1797
|
+
...(this.markUncallableWrites && readProgramRefuses(tool) ? { callable: false } : {}),
|
|
1495
1798
|
};
|
|
1496
1799
|
});
|
|
1497
1800
|
});
|