@zackbart/connecta 0.40.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 +148 -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 +32 -5
- package/dist/activity.js +32 -6
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +33 -44
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +15 -4
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +77 -39
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +352 -43
- package/dist/catalog-freshness.js +25 -0
- package/dist/catalog-service.js +408 -78
- package/dist/catalog.js +216 -62
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +53 -21
- package/dist/config.js +56 -17
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +113 -22
- package/dist/connectors/remote-mcp/oauth.js +2 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +24 -6
- package/dist/connectors/remote-mcp.js +14 -5
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +4 -2
- package/dist/describe-config.js +1 -0
- package/dist/discovery-health.js +26 -11
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +60 -4
- package/dist/errors.js +88 -3
- package/dist/execute.js +491 -202
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +3 -3
- package/dist/executors/quickjs-child.js +14 -5
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +37 -20
- package/dist/executors/quickjs.js +24 -7
- package/dist/guarded-transport.js +2 -0
- 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 +14 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +11 -3
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- 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 +15 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +167 -64
- package/dist/membership.d.ts +1 -1
- 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 +286 -73
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +177 -55
- package/dist/node.d.ts +7 -0
- package/dist/node.js +85 -4
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +104 -0
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +9 -1
- package/dist/operator-record.js +16 -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 +1 -14
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +3 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +2 -1
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +8 -3
- 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 +2 -1
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +3 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +12 -6
- 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/api.js +28 -25
- package/dist/providers/quickbooks/index.js +3 -6
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +2 -1
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +2 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +3 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +142 -30
- package/dist/resource-read.js +21 -2
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +53 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +83 -35
- package/dist/routes/shared.d.ts +6 -0
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +66 -14
- package/dist/runtime/request-scope.js +38 -2
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +213 -57
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +16 -7
- package/dist/storage/sql-native.js +60 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +36 -0
- package/dist/url-safety.js +38 -20
- package/dist/usage-guide.js +14 -13
- package/dist/utility-recovery.js +33 -0
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +60 -15
- package/documentation/architecture.md +152 -43
- package/documentation/auth.md +160 -53
- package/documentation/code-mode.md +396 -119
- package/documentation/deploying.md +35 -10
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +761 -157
- package/documentation/operating.md +91 -4
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +34 -20
- 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 +10 -8
- 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
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
import { utilityRecovery } from "./utility-recovery.js";
|
|
2
|
+
import { traceFor } from "./timing-trace.js";
|
|
3
|
+
import { utf8Length } from "./bytes.js";
|
|
4
|
+
import { catalogOwnerPartition } from "./catalog-policy.js";
|
|
1
5
|
import { registerAuthMethod } from "./auth/native.js";
|
|
2
6
|
import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
|
|
7
|
+
import { readProgramRefuses } from "./tool-safety.js";
|
|
3
8
|
import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
|
|
4
|
-
import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
|
|
9
|
+
import { carryCatalogFreshness, catalogGenerationFor, catalogIsFresh, catalogStaleness, markCatalogStale, } from "./catalog-freshness.js";
|
|
5
10
|
import { retainedCatalogIdentity } from "./catalog-cache.js";
|
|
6
11
|
import { cloneTool } from "./tool-safety.js";
|
|
7
12
|
import { hasControlCharacters } from "./tool-name.js";
|
|
8
|
-
import { Effect, Result } from "effect";
|
|
9
|
-
import { 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";
|
|
10
15
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
11
16
|
import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
|
|
12
17
|
import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
|
|
13
|
-
import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
18
|
+
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
14
19
|
import { registryProgram } from "./runtime/registry.js";
|
|
15
20
|
import { SharedRead } from "./runtime/shared-read.js";
|
|
21
|
+
import { closeScope } from "./runtime/connector-scope.js";
|
|
16
22
|
import { duringViewEffect } from "./runtime/call.js";
|
|
17
23
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, UNSCOPED_DISCOVERY_BUDGET_MS, UNSCOPED_PROBE_TIMEOUT_MS, } from "./timeout.js";
|
|
18
24
|
import { claimDiscoveryProbe, readDiscoveryHealth, settleDiscoveryHealth, } from "./discovery-health.js";
|
|
@@ -20,6 +26,29 @@ import { keepErrorName } from "./redaction-identity.js";
|
|
|
20
26
|
import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
|
|
21
27
|
/** The slice of an unscoped search's budget kept for recording what its probes saw. */
|
|
22
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);
|
|
50
|
+
const lexicalStores = new WeakMap();
|
|
51
|
+
let nextLexicalStore = 0;
|
|
23
52
|
/** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
|
|
24
53
|
const CLAIM_MARGIN_MS = 2_000;
|
|
25
54
|
/** A connector unscoped search skipped because its discovery failed recently. */
|
|
@@ -32,10 +61,12 @@ class DiscoverySkipped extends Error {
|
|
|
32
61
|
this.retryAfterMs = retryAfterMs;
|
|
33
62
|
}
|
|
34
63
|
}
|
|
35
|
-
|
|
64
|
+
const DEFAULT_SEARCH_LIMIT = 8;
|
|
36
65
|
export const MAX_SEARCH_LIMIT = 100;
|
|
37
66
|
export const MAX_DESCRIBE_ADDRESSES = 100;
|
|
38
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;
|
|
39
70
|
const MAX_QUERY_TERMS = 8;
|
|
40
71
|
const MAX_QUERY_TERM_LENGTH = 64;
|
|
41
72
|
/**
|
|
@@ -262,6 +293,23 @@ function schemaKeyMetadata(input, output) {
|
|
|
262
293
|
...(outputKeys && outputKeys.properties.length > 0 ? { outputKeys: outputKeys.properties } : {}),
|
|
263
294
|
};
|
|
264
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.`;
|
|
265
313
|
const stringListSchema = { type: "array", items: { type: "string" } };
|
|
266
314
|
const STRING = { type: "string" };
|
|
267
315
|
// The reasons enum is stated once under `$defs` and referenced from each field.
|
|
@@ -275,6 +323,8 @@ const catalogFailureProperties = {
|
|
|
275
323
|
message: { type: "string" },
|
|
276
324
|
retryable: { type: "boolean" },
|
|
277
325
|
retryAfterMs: { type: "integer", minimum: 0 },
|
|
326
|
+
stale: { const: true },
|
|
327
|
+
ageMs: { type: "integer" },
|
|
278
328
|
connector: { type: "string" },
|
|
279
329
|
recovery: { enum: ["oauth", "operator_config", "unavailable"] },
|
|
280
330
|
status: { const: "auth_required" },
|
|
@@ -299,29 +349,27 @@ const catalogFailureProperties = {
|
|
|
299
349
|
},
|
|
300
350
|
},
|
|
301
351
|
retry: { type: "string" },
|
|
352
|
+
// Handoff fields above carry the detailed authorization contract. Keep
|
|
353
|
+
// additive action advice compact in the always-loaded search schema.
|
|
302
354
|
nextAction: {
|
|
303
355
|
type: "object",
|
|
304
|
-
required: ["
|
|
305
|
-
additionalProperties: false,
|
|
356
|
+
anyOf: [{ required: ["url"] }, { required: ["tool"] }, { required: ["action"] }],
|
|
306
357
|
properties: {
|
|
358
|
+
url: STRING,
|
|
307
359
|
tool: { const: "authorize_connector" },
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
additionalProperties: false,
|
|
312
|
-
properties: { connector: { type: "string" } },
|
|
313
|
-
},
|
|
314
|
-
operatorHandoff: { type: "string" },
|
|
360
|
+
action: { enum: ["contact_operator", "inspect_capabilities"] },
|
|
361
|
+
purpose: STRING,
|
|
362
|
+
arguments: { type: "object", properties: { connector: STRING } },
|
|
315
363
|
},
|
|
316
364
|
},
|
|
317
365
|
};
|
|
318
366
|
/** The one typed failure (and auth handoff) both failure fields reference. */
|
|
319
367
|
const FAILURE_REF = { $ref: "#/$defs/failure" };
|
|
320
368
|
/**
|
|
321
|
-
* The
|
|
322
|
-
*
|
|
323
|
-
* Every failure field references one local `$defs` entry, so the typed
|
|
324
|
-
* 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.
|
|
325
373
|
*/
|
|
326
374
|
export const CATALOG_SEARCH_RESULT_SCHEMA = {
|
|
327
375
|
type: "object",
|
|
@@ -360,6 +408,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
|
|
|
360
408
|
name: { type: "string" },
|
|
361
409
|
address: { type: "string" },
|
|
362
410
|
description: { type: "string" },
|
|
411
|
+
descriptionTruncated: { const: true },
|
|
363
412
|
inputSchema: {},
|
|
364
413
|
outputSchema: {},
|
|
365
414
|
signature: { type: "string" },
|
|
@@ -447,6 +496,7 @@ export class CatalogService {
|
|
|
447
496
|
searchRoute;
|
|
448
497
|
requestSignal;
|
|
449
498
|
readOnly;
|
|
499
|
+
markUncallableWrites;
|
|
450
500
|
// The request-scoped catalog cache: one shared read per connector asked
|
|
451
501
|
// about, started by the first asker and joined by every later one. A
|
|
452
502
|
// success stays while its in-process invalidation fence matches, so a search
|
|
@@ -471,6 +521,13 @@ export class CatalogService {
|
|
|
471
521
|
// current by its own probes, so repeated searches read no storage.
|
|
472
522
|
health = new Map();
|
|
473
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;
|
|
474
531
|
constructor(registry, baseUrl, options = {}) {
|
|
475
532
|
this.registry = registry;
|
|
476
533
|
this.baseUrl = baseUrl;
|
|
@@ -479,11 +536,13 @@ export class CatalogService {
|
|
|
479
536
|
this.requestScope = options.requestScope ?? {};
|
|
480
537
|
this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
481
538
|
this.discoveryBudgetMs = normalizeTimeoutMs(options.discoveryBudgetMs) ?? UNSCOPED_DISCOVERY_BUDGET_MS;
|
|
539
|
+
this.unscopedProbeTimeoutMs = normalizeTimeoutMs(options.unscopedProbeTimeoutMs) ?? UNSCOPED_PROBE_TIMEOUT_MS;
|
|
482
540
|
this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
|
|
483
541
|
this.searchRoute = options.searchRoute ?? "search_tools";
|
|
484
542
|
this.requestSignal = options.requestSignal;
|
|
485
543
|
this.defer = options.defer;
|
|
486
544
|
this.readOnly = options.readOnly === true;
|
|
545
|
+
this.markUncallableWrites = options.markUncallableWrites === true;
|
|
487
546
|
}
|
|
488
547
|
/**
|
|
489
548
|
* Send a caller back to discovery through the surface it can actually reach.
|
|
@@ -533,6 +592,7 @@ export class CatalogService {
|
|
|
533
592
|
timeoutMs: this.probeTimeoutMs,
|
|
534
593
|
...(this.defer ? { defer: this.defer } : {}),
|
|
535
594
|
}).pipe(Effect.map((tools) => {
|
|
595
|
+
traceFor(this.requestScope)?.record("catalog.accepted", 0, utf8Length(JSON.stringify(tools)), 0, 0, tools.length);
|
|
536
596
|
// An authored catalog lists its declared descriptions for this request's surface.
|
|
537
597
|
const connector = this.registry.getConnector(id);
|
|
538
598
|
return carryCatalogFreshness(tools, tools.map((tool) => {
|
|
@@ -565,10 +625,113 @@ export class CatalogService {
|
|
|
565
625
|
let view = views.get(key);
|
|
566
626
|
if (!view) {
|
|
567
627
|
view = toolsForSafety(readOnly ? tools.filter((tool) => tool.classification === "read") : tools, safety ?? "all");
|
|
628
|
+
carryLexicalCatalog(tools, view);
|
|
629
|
+
const stale = catalogStaleness(tools);
|
|
630
|
+
if (stale)
|
|
631
|
+
markCatalogStale(view, stale);
|
|
568
632
|
views.set(key, view);
|
|
569
633
|
}
|
|
570
634
|
return view;
|
|
571
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
|
+
}
|
|
572
735
|
// Every named catalog under its own probe deadline, `concurrency` at a time,
|
|
573
736
|
// each settling in its input slot: an unavailable catalog is a Failure
|
|
574
737
|
// there, not a failed discovery. `route` names the surface in a timeout.
|
|
@@ -588,11 +751,22 @@ export class CatalogService {
|
|
|
588
751
|
// it, then runs to its end. A lapsed record is claimed as its probe is
|
|
589
752
|
// dispatched, leased to the end of this budget plus a margin.
|
|
590
753
|
// Caller cancellation and a probe the budget cut short leave health as it was.
|
|
591
|
-
|
|
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) {
|
|
592
761
|
return Effect.gen({ self: this }, function* () {
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
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);
|
|
596
770
|
// Health storage starts only while budget remains and the caller is
|
|
597
771
|
// here; running out is not a failure, health is then unknown. Once
|
|
598
772
|
// started, an operation is awaited to its end, never raced or detached:
|
|
@@ -624,60 +798,142 @@ export class CatalogService {
|
|
|
624
798
|
});
|
|
625
799
|
};
|
|
626
800
|
const probeOne = (id) => Effect.gen({ self: this }, function* () {
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
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)
|
|
630
804
|
return {
|
|
631
805
|
id,
|
|
632
|
-
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`))),
|
|
633
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;
|
|
634
846
|
const timeoutFor = (remaining) => {
|
|
635
847
|
const budgetCut = remaining < deadline;
|
|
636
848
|
return {
|
|
637
849
|
budgetCut,
|
|
638
850
|
timeoutMs: budgetCut ? remaining : deadline,
|
|
639
851
|
timeoutError: budgetCut
|
|
640
|
-
? new ConnectorCallError("timeout", `${
|
|
641
|
-
: 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`),
|
|
642
854
|
};
|
|
643
855
|
};
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// lease runs to an absolute time, the end of this request's
|
|
649
|
-
// discovery budget plus a margin, so however long the claim write
|
|
650
|
-
// takes, the probe the budget still allows ends inside it.
|
|
651
|
-
if (skip && verdict?.slot) {
|
|
652
|
-
const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
|
|
653
|
-
const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
|
|
654
|
-
if (!claimed) {
|
|
655
|
-
this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
|
|
656
|
-
return {
|
|
657
|
-
id,
|
|
658
|
-
result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
this.health.set(id, { slot: verdict.slot });
|
|
662
|
-
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());
|
|
663
860
|
if (plan.timeoutMs <= 0)
|
|
664
|
-
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 };
|
|
665
898
|
}
|
|
666
|
-
const started = Date.now();
|
|
667
|
-
const result = yield* Effect.result(this.probe(id, false, plan.timeoutMs, plan.timeoutError));
|
|
668
|
-
// Elapsed time is this probe's own, measured as it settles.
|
|
669
|
-
const elapsedMs = Date.now() - started;
|
|
670
899
|
const outcome = plan.budgetCut && Result.isFailure(result) && result.failure === plan.timeoutError
|
|
671
900
|
? { id, result }
|
|
672
901
|
: { id, result, settle: { timeoutError: plan.timeoutError, elapsedMs } };
|
|
673
902
|
if (scoped)
|
|
674
903
|
return outcome;
|
|
675
|
-
|
|
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
|
+
}
|
|
676
932
|
return { id, result };
|
|
677
933
|
});
|
|
678
934
|
if (!scoped) {
|
|
679
935
|
yield* readHealth;
|
|
680
|
-
const probed = yield* Effect.forEach(ids, probeOne, { concurrency:
|
|
936
|
+
const probed = yield* Effect.forEach(ids, probeOne, { concurrency: "unbounded" });
|
|
681
937
|
return probed.map((outcome) => outcome.result);
|
|
682
938
|
}
|
|
683
939
|
const [, probed] = yield* Effect.all([readHealth, Effect.forEach(ids, probeOne, { concurrency: this.concurrency })], { concurrency: 2 });
|
|
@@ -694,13 +950,20 @@ export class CatalogService {
|
|
|
694
950
|
* settles on its own, so one unavailable connector is an `error` entry
|
|
695
951
|
* beside the others rather than a failed listing.
|
|
696
952
|
*/
|
|
697
|
-
listCatalogs(includeWrites = false, connectors = this.registry.listConnectors()) {
|
|
698
|
-
return runEdge(this.listCatalogsEffect(includeWrites, connectors));
|
|
953
|
+
listCatalogs(includeWrites = false, connectors = this.registry.listConnectors(), allowStale = false) {
|
|
954
|
+
return runEdge(this.listCatalogsEffect(includeWrites, connectors, allowStale));
|
|
699
955
|
}
|
|
700
|
-
|
|
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) {
|
|
701
961
|
return duringViewEffect({ requestScope: this.requestScope, signal: this.requestSignal }, () => Effect.suspend(() => {
|
|
702
962
|
const visible = connectors ?? this.registry.listConnectors();
|
|
703
|
-
|
|
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) => {
|
|
704
967
|
const connector = visible[index];
|
|
705
968
|
return Result.isSuccess(result)
|
|
706
969
|
? { connector, tools: carryCatalogFreshness(result.success, result.success.map(cloneTool)) }
|
|
@@ -735,7 +998,10 @@ export class CatalogService {
|
|
|
735
998
|
cause,
|
|
736
999
|
};
|
|
737
1000
|
}
|
|
738
|
-
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);
|
|
739
1005
|
return {
|
|
740
1006
|
ok: false,
|
|
741
1007
|
error: {
|
|
@@ -743,6 +1009,7 @@ export class CatalogService {
|
|
|
743
1009
|
? `Unknown tool on connector "${connector.id}"`
|
|
744
1010
|
: `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
|
|
745
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 } : {}),
|
|
746
1013
|
},
|
|
747
1014
|
catalogMs: Date.now() - started,
|
|
748
1015
|
connector,
|
|
@@ -768,7 +1035,7 @@ export class CatalogService {
|
|
|
768
1035
|
onSuccess: (tools) => {
|
|
769
1036
|
const definition = tools.find((tool) => tool.name === toolName);
|
|
770
1037
|
if (!definition) {
|
|
771
|
-
return this.unknownToolFailure(toolName, connector, started);
|
|
1038
|
+
return this.unknownToolFailure(toolName, connector, started, tools);
|
|
772
1039
|
}
|
|
773
1040
|
return {
|
|
774
1041
|
ok: true,
|
|
@@ -793,6 +1060,9 @@ export class CatalogService {
|
|
|
793
1060
|
message: `Connector "${connector.id}" was skipped because its catalog lookup failed recently (${failure.code}). Search with connector "${connector.id}" to try it now.`,
|
|
794
1061
|
retryable: true,
|
|
795
1062
|
retryAfterMs: failure.retryAfterMs,
|
|
1063
|
+
...(["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(failure.code)
|
|
1064
|
+
? { nextAction: utilityRecovery(failure.code, "Catalog lookup is unavailable.").nextAction }
|
|
1065
|
+
: {}),
|
|
796
1066
|
};
|
|
797
1067
|
}
|
|
798
1068
|
const classified = classifyCallError(failure, "catalog_lookup_failed");
|
|
@@ -830,6 +1100,9 @@ export class CatalogService {
|
|
|
830
1100
|
error.retry =
|
|
831
1101
|
"Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
|
|
832
1102
|
}
|
|
1103
|
+
if (["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(error.code)) {
|
|
1104
|
+
error.nextAction = utilityRecovery(error.code, error.message).nextAction;
|
|
1105
|
+
}
|
|
833
1106
|
return yield* withAuthorizationHandoffEffect(this.requestScope, error);
|
|
834
1107
|
});
|
|
835
1108
|
}
|
|
@@ -876,8 +1149,23 @@ export class CatalogService {
|
|
|
876
1149
|
: undefined;
|
|
877
1150
|
const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
|
|
878
1151
|
// Timeout messages name the route the caller used.
|
|
879
|
-
const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
|
|
880
|
-
const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) =>
|
|
1152
|
+
const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined ? "scoped" : "unscoped");
|
|
1153
|
+
const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => {
|
|
1154
|
+
const id = connectors[index].id;
|
|
1155
|
+
const ctx = this.registry.contextFor(id, this.baseUrl, this.requestScope);
|
|
1156
|
+
const { storage, partition } = catalogOwnerPartition(ctx);
|
|
1157
|
+
let store = lexicalStores.get(storage);
|
|
1158
|
+
if (store === undefined)
|
|
1159
|
+
lexicalStores.set(storage, (store = ++nextLexicalStore));
|
|
1160
|
+
prepareLexicalCatalog(tools, JSON.stringify([
|
|
1161
|
+
store,
|
|
1162
|
+
id,
|
|
1163
|
+
partition,
|
|
1164
|
+
retainedCatalogIdentity(ctx),
|
|
1165
|
+
catalogGenerationFor(this.requestScope, id),
|
|
1166
|
+
]));
|
|
1167
|
+
return this.searchView(id, tools, true, safety);
|
|
1168
|
+
}));
|
|
881
1169
|
const matches = [];
|
|
882
1170
|
let matchMode = "all";
|
|
883
1171
|
// Provider-declared synonyms widen term matching within their own catalog only.
|
|
@@ -951,21 +1239,36 @@ export class CatalogService {
|
|
|
951
1239
|
order: original.success.indexOf(tool),
|
|
952
1240
|
exactName: false,
|
|
953
1241
|
matchedTermCount: toolTermCount,
|
|
1242
|
+
headTerms: [],
|
|
1243
|
+
nameTerms: [],
|
|
1244
|
+
namesDistinctiveTerm: false,
|
|
954
1245
|
});
|
|
955
1246
|
}
|
|
956
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;
|
|
957
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);
|
|
958
1256
|
collected.push({
|
|
959
1257
|
connector,
|
|
960
1258
|
tool: ranked.tool,
|
|
961
|
-
score:
|
|
1259
|
+
score: vocabulary && !ranked.exactName ? vocabularyRank : ranked.score,
|
|
962
1260
|
order: orderBase + ranked.order,
|
|
963
|
-
|
|
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()),
|
|
964
1263
|
exactLookup: rawPhrase === lexicalQueryTerms(ranked.tool.name).join(" ") ||
|
|
965
1264
|
rawPhrase === lexicalQueryTerms(`${connector.id}.${ranked.tool.name}`).join(" "),
|
|
966
1265
|
connectorRank,
|
|
967
1266
|
matchedTermCount: vocabularyTools.has(ranked.tool) ? toolTermCount : ranked.matchedTermCount,
|
|
968
|
-
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,
|
|
969
1272
|
});
|
|
970
1273
|
}
|
|
971
1274
|
}
|
|
@@ -978,12 +1281,13 @@ export class CatalogService {
|
|
|
978
1281
|
// unrelated zero-score match, with no coverage to explain the result.
|
|
979
1282
|
const rankedMatches = unsearchableQuery ? [] : collectMatches(isBrowse ? "all" : "partial");
|
|
980
1283
|
const completeMatchCount = rankedMatches.filter((match) => match.complete).length;
|
|
981
|
-
matches.push(...rankedMatches.filter((match) => match.
|
|
1284
|
+
matches.push(...rankedMatches.filter((match) => match.covered ||
|
|
1285
|
+
match.namesDistinctiveTerm ||
|
|
982
1286
|
completeMatchCount === 0 ||
|
|
983
1287
|
match.exactName ||
|
|
984
1288
|
match.connectorRank > 0 ||
|
|
985
1289
|
match.matchedTermCount >= 2));
|
|
986
|
-
if (!isBrowse && !unsearchableQuery &&
|
|
1290
|
+
if (!isBrowse && !unsearchableQuery && !rankedMatches.some((match) => match.covered)) {
|
|
987
1291
|
matchMode = "partial";
|
|
988
1292
|
}
|
|
989
1293
|
// A read-intent verb leads the query once connector names are set aside.
|
|
@@ -994,13 +1298,15 @@ export class CatalogService {
|
|
|
994
1298
|
// longer name such as get_issue_status cannot beat an exact get_issue
|
|
995
1299
|
// merely because its description repeats rare query terms. Within a
|
|
996
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;
|
|
997
1305
|
matches.sort((a, b) => {
|
|
998
|
-
const aFirstTier = a.complete || a.exactName;
|
|
999
|
-
const bFirstTier = b.complete || b.exactName;
|
|
1000
1306
|
return (Number(b.exactLookup) - Number(a.exactLookup) ||
|
|
1001
1307
|
Number(b.exactName) - Number(a.exactName) ||
|
|
1002
1308
|
b.connectorRank - a.connectorRank ||
|
|
1003
|
-
|
|
1309
|
+
tier(b) - tier(a) ||
|
|
1004
1310
|
readFirst(b) - readFirst(a) ||
|
|
1005
1311
|
b.score - a.score ||
|
|
1006
1312
|
a.order - b.order);
|
|
@@ -1029,7 +1335,10 @@ export class CatalogService {
|
|
|
1029
1335
|
? renderSearchSchema(output.schema, schemaFormat)
|
|
1030
1336
|
: undefined;
|
|
1031
1337
|
const schemaKeys = withSchemas && args.includeSchemaKeys ? schemaKeyMetadata(input, output.schema) : undefined;
|
|
1032
|
-
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;
|
|
1033
1342
|
const requiredReasons = guideRequiredReasons(match.connector, match.tool, renderedInput?.truncated === true || renderedOutput?.truncated === true);
|
|
1034
1343
|
const guideSummary = connectorGuideSummary(match.connector);
|
|
1035
1344
|
return {
|
|
@@ -1044,6 +1353,7 @@ export class CatalogService {
|
|
|
1044
1353
|
name: match.tool.name,
|
|
1045
1354
|
address: `${match.connector.id}.${match.tool.name}`,
|
|
1046
1355
|
...(description !== undefined ? { description } : {}),
|
|
1356
|
+
...(summary?.truncated ? { descriptionTruncated: true } : {}),
|
|
1047
1357
|
...(schemaFormat
|
|
1048
1358
|
? {
|
|
1049
1359
|
inputSchema: renderedInput?.schema,
|
|
@@ -1068,6 +1378,7 @@ export class CatalogService {
|
|
|
1068
1378
|
...(schemaKeys?.outputKeys && !renderedOutput?.truncated ? { outputKeys: schemaKeys.outputKeys } : {}),
|
|
1069
1379
|
...(match.tool.annotations ? { annotations: match.tool.annotations } : {}),
|
|
1070
1380
|
classification: match.tool.classification,
|
|
1381
|
+
...(this.markUncallableWrites && readProgramRefuses(match.tool) ? { callable: false } : {}),
|
|
1071
1382
|
...(requiredReasons
|
|
1072
1383
|
? {
|
|
1073
1384
|
guideRequired: true,
|
|
@@ -1134,14 +1445,19 @@ export class CatalogService {
|
|
|
1134
1445
|
}
|
|
1135
1446
|
}
|
|
1136
1447
|
const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
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();
|
|
1140
1455
|
const needsAuth = (error) => error.code === "auth_required" ||
|
|
1141
1456
|
error.code === "downstream_oauth_required" ||
|
|
1142
1457
|
error.code === "provider_permission_denied";
|
|
1143
1458
|
catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
|
|
1144
|
-
|
|
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;
|
|
1145
1461
|
const scopedCatalogError = scopedFailure
|
|
1146
1462
|
? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
|
|
1147
1463
|
? scopedFailure
|
|
@@ -1211,6 +1527,7 @@ export class CatalogService {
|
|
|
1211
1527
|
(unavailableCatalogs
|
|
1212
1528
|
? ` ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable; inspect catalogErrors.`
|
|
1213
1529
|
: "") +
|
|
1530
|
+
(staleCatalogs ? ` ${staleListedText(staleCatalogs)}` : "") +
|
|
1214
1531
|
filterRecovery;
|
|
1215
1532
|
// A scope that resolved to nothing is the same silence one step earlier in
|
|
1216
1533
|
// the lookup: no connector resolved, so no catalog was even attempted, so
|
|
@@ -1245,7 +1562,7 @@ export class CatalogService {
|
|
|
1245
1562
|
}
|
|
1246
1563
|
if (queryTerms.length === 0) {
|
|
1247
1564
|
if (unavailableCatalogs === 0)
|
|
1248
|
-
return undefined;
|
|
1565
|
+
return staleCatalogs ? staleListedText(staleCatalogs) : undefined;
|
|
1249
1566
|
return scopedConnector
|
|
1250
1567
|
? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
|
|
1251
1568
|
: `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Inspect catalogErrors for the typed reasons and recovery actions.`;
|
|
@@ -1264,8 +1581,10 @@ export class CatalogService {
|
|
|
1264
1581
|
if (identityGuidance || apiSearchGuidance)
|
|
1265
1582
|
return ([identityGuidance, apiSearchGuidance].filter(Boolean).join(" ") +
|
|
1266
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}`;
|
|
1267
1586
|
return unavailableCatalogs === 0
|
|
1268
|
-
? `No
|
|
1587
|
+
? `No ${safetyLabel}tool matched these terms. Try other terms, scope by connector, or browse with an empty query.${filterRecovery}`
|
|
1269
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}`;
|
|
1270
1589
|
}
|
|
1271
1590
|
if (matchMode !== "partial")
|
|
@@ -1372,6 +1691,10 @@ export class CatalogService {
|
|
|
1372
1691
|
: undefined;
|
|
1373
1692
|
return [id, error];
|
|
1374
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;
|
|
1375
1698
|
return resolved.map(({ address, resolved: addressResolution }) => {
|
|
1376
1699
|
if (!addressResolution) {
|
|
1377
1700
|
const message = `Unknown address "${boundedEchoText(address)}"`;
|
|
@@ -1424,7 +1747,12 @@ export class CatalogService {
|
|
|
1424
1747
|
const tool = cloneTool(selected);
|
|
1425
1748
|
const input = tool.inputSchema ?? { type: "object" };
|
|
1426
1749
|
const output = this.outputSchema(addressResolution.connector.id, tool);
|
|
1427
|
-
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;
|
|
1428
1756
|
const signature = format === "typescript"
|
|
1429
1757
|
? typescriptSignature(input, output.schema, {
|
|
1430
1758
|
observed: output.source === "observed",
|
|
@@ -1442,6 +1770,7 @@ export class CatalogService {
|
|
|
1442
1770
|
schemaFormat: format === "json" ? "json" : "text",
|
|
1443
1771
|
name: tool.name,
|
|
1444
1772
|
...(description !== undefined ? { description } : {}),
|
|
1773
|
+
...(summary?.truncated ? { descriptionTruncated: true } : {}),
|
|
1445
1774
|
...(connectorGuide(addressResolution.connector)
|
|
1446
1775
|
? {
|
|
1447
1776
|
guide: connectorSkillName(addressResolution.connector.id),
|
|
@@ -1465,6 +1794,7 @@ export class CatalogService {
|
|
|
1465
1794
|
...(output.source ? { outputSchemaSource: output.source } : {}),
|
|
1466
1795
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
1467
1796
|
classification: tool.classification,
|
|
1797
|
+
...(this.markUncallableWrites && readProgramRefuses(tool) ? { callable: false } : {}),
|
|
1468
1798
|
};
|
|
1469
1799
|
});
|
|
1470
1800
|
});
|