diff --git a/packages/gatekeeper-context/__tests__/agent-skill.test.ts b/packages/gatekeeper-context/__tests__/agent-skill.test.ts index 330033308..4124e7512 100644 --- a/packages/gatekeeper-context/__tests__/agent-skill.test.ts +++ b/packages/gatekeeper-context/__tests__/agent-skill.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { + AGENT_SKILL_CATALOG_MAX_ENTRIES, isSkillManifestPath, buildAgentSkillCatalogEntries, buildAgentSkillCommands, - buildAgentSkillMessage, parseSkillManifest, + buildAgentSkillMessage, buildContextCatalog, parseSkillManifest, type CollectionSkills, } from "../src/agent-skill"; import { isTextContentType } from "../src/context-types"; @@ -267,3 +268,44 @@ describe("buildAgentSkillMessage", () => { ); }); }); + +describe("buildContextCatalog", () => { + let collection = (id: string, title: string) => ({ + id, title, description: `${title} description`, + source: "public" as const, lastUpdated: new Date(), + }); + + it("keeps every collection when skills exceed their cap", () => { + let collections = Array.from({length: 40}, (_, index) => + collection(`c${index}`, `Zulu collection ${String(index).padStart(2, "0")}`)); + let loaded: CollectionSkills[] = [{ + collection: collections[0], + // Named to sort ahead of every collection title, which is what used to evict them. + skills: Array.from({length: AGENT_SKILL_CATALOG_MAX_ENTRIES + 50}, (_, index) => ({ + path: `aa-skill-${index}/SKILL.md`, + description: `Skill ${index}`, + skillName: `aa-skill-${String(index).padStart(4, "0")}`, + })), + }]; + + let catalog = buildContextCatalog(collections, loaded); + + let ids = new Set(catalog.entries.map(entry => entry.id)); + expect(collections.every(each => ids.has(each.id))).toBe(true); + expect(catalog.entries).toHaveLength(collections.length + AGENT_SKILL_CATALOG_MAX_ENTRIES); + expect(catalog.truncated).toBe(true); + }); + + it("reports no truncation when every skill fits", () => { + let collections = [collection("c0", "Runbooks")]; + let loaded: CollectionSkills[] = [{ + collection: collections[0], + skills: [{path: "deploy/SKILL.md", description: "Deploy", skillName: "deploy"}], + }]; + + let catalog = buildContextCatalog(collections, loaded); + + expect(catalog.entries.map(entry => entry.id)).toEqual(["c0", "c0/deploy/SKILL.md"]); + expect(catalog.truncated).toBe(false); + }); +}); diff --git a/packages/gatekeeper-context/src/agent-skill.ts b/packages/gatekeeper-context/src/agent-skill.ts index fdeaaf7b5..9afd14dca 100644 --- a/packages/gatekeeper-context/src/agent-skill.ts +++ b/packages/gatekeeper-context/src/agent-skill.ts @@ -1,11 +1,21 @@ import { parse as parseYaml } from "yaml"; import { z } from "zod"; -import type { SlashCommandDescriptor } from "@gadgets/workshop-shared/gatekeeper"; +import { boundAgentCatalog } from "@gadgets/workshop-shared/gatekeeper"; +import type { AgentCatalog, SlashCommandDescriptor } from "@gadgets/workshop-shared/gatekeeper"; import type { EnabledCollectionInfo } from "./context-types.js"; import { encodeDocId } from "./context-types.js"; const AGENT_SKILL_NAME_MAX_LENGTH = 64; +/** + * How many skills the catalog advertises. Skills are the one item class here that grows without + * limit (one git-backed collection can import hundreds), so they get their own cap well under + * AGENT_CATALOG_MAX_ENTRIES. That leaves the shared ceiling as headroom for collections, which are + * the agent's entry points and must not be dropped. Skills past the cap stay reachable through the + * session's list()/search(). + */ +export const AGENT_SKILL_CATALOG_MAX_ENTRIES = 150; + /** Fields read from SKILL.md frontmatter. */ export type SkillManifestMetadata = { name: string; @@ -60,6 +70,31 @@ export function buildAgentSkillCatalogEntries( left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); } +/** + * The catalog the library advertises: collections first, then up to + * AGENT_SKILL_CATALOG_MAX_ENTRIES skills. Collections get the shared 1000-entry ceiling before any + * skill, so a large skill set cannot displace them. The merged list stays unsorted because the + * Workshop sorts the survivors; sorting here would only decide alphabetically which entries lose. + */ +export function buildContextCatalog( + collections: EnabledCollectionInfo[], loaded: CollectionSkills[]): AgentCatalog { + let collectionEntries = collections + .map(collection => ({ + id: collection.id, + title: collection.title, + description: collection.description, + })) + .toSorted((left, right) => + left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); + let skillEntries = buildAgentSkillCatalogEntries(loaded); + let catalog = boundAgentCatalog([ + ...collectionEntries, + ...skillEntries.slice(0, AGENT_SKILL_CATALOG_MAX_ENTRIES), + ]); + if (skillEntries.length > AGENT_SKILL_CATALOG_MAX_ENTRIES) catalog.truncated = true; + return catalog; +} + /** * Context builds this complete message. Workshop stores it as normal chat text. * $ARGUMENT uses the raw command text. If missing, the text is appended after the skill. diff --git a/packages/gatekeeper-context/src/library-gatekeeper.ts b/packages/gatekeeper-context/src/library-gatekeeper.ts index f2ec9df72..45689bdbb 100644 --- a/packages/gatekeeper-context/src/library-gatekeeper.ts +++ b/packages/gatekeeper-context/src/library-gatekeeper.ts @@ -5,9 +5,8 @@ import { WorkerEntrypoint, DurableObject, RpcStub as NativeRpcStub, RpcTarget as NativeRpcTarget } from "cloudflare:workers"; import { RpcStub } from "capnweb"; import { validateRpc, skipRpcValidation } from "capnweb-validate"; -import { boundAgentCatalog } from "@gadgets/workshop-shared/gatekeeper"; import type { - VendorDescription, AccountDescription, AgentCatalog, AgentCatalogRequest, + VendorDescription, AccountDescription, AgentCatalog, AppUiContext, GatekeeperUser, GatekeeperUiFrame, ApprovalQueue, ObservationAuthorizer, GatekeeperConnectCallback, GatekeeperConnectOptions, SupportedResource, Gatekeeper, GatekeeperUserVerifier, ResourceDescription, ActionKind, @@ -18,8 +17,7 @@ import { ContextApiImpl, loadEnabledContextCollections } from "./context-api.js" import { ContextObserverTracker } from "./context-observers.js"; import type { ContextVerifierApi } from "./context-observers.js"; import { - buildAgentSkillCatalogEntries, buildAgentSkillCommands, buildAgentSkillMessage, - parseSkillManifest, + buildAgentSkillCommands, buildAgentSkillMessage, buildContextCatalog, parseSkillManifest, type CollectionSkills, } from "./agent-skill.js"; import type { EnabledCollectionInfo } from "./context-types.js"; @@ -318,25 +316,12 @@ export class ContextGatekeeper } async getAgentCatalog( - request: AgentCatalogRequest, authorizer: NativeRpcStub): Promise { let domain = this.ctx.props.sharingDomain; let userLibrary = this.#userLibraries().get( this.#userLibraries().idFromName(domainName(domain, this.ctx.props.accountId))); let collections = await loadEnabledContextCollections(this.env, domain, userLibrary); - let loaded = await this.#loadSkills(collections); - let skillEntries = buildAgentSkillCatalogEntries(loaded); - let collectionEntries = collections - .map(collection => ({ - id: collection.id, - title: collection.title, - description: collection.description, - })) - .toSorted((left, right) => - left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); - let entries = [...skillEntries, ...collectionEntries].toSorted((left, right) => - left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); - let catalog = boundAgentCatalog(entries, request); + let catalog = buildContextCatalog(collections, await this.#loadSkills(collections)); if (catalog.entries.length > 0) { let collectionIds = [...new Set(catalog.entries.map(entry => { let slash = entry.id.indexOf("/"); diff --git a/packages/gatekeeper-scheduler/src/scheduler.ts b/packages/gatekeeper-scheduler/src/scheduler.ts index 0140ecd8e..06dac8167 100644 --- a/packages/gatekeeper-scheduler/src/scheduler.ts +++ b/packages/gatekeeper-scheduler/src/scheduler.ts @@ -9,7 +9,6 @@ import type { AccountDescription, ActionKind, AgentCatalog, - AgentCatalogRequest, AppUiContext, ApprovalQueue, Gatekeeper, @@ -271,7 +270,6 @@ export class SchedulerGatekeeper /** Returns no catalog because schedule discovery happens through list(). */ async getAgentCatalog( - _request: AgentCatalogRequest, _authorizer: NativeRpcStub, ): Promise { return null; diff --git a/packages/workshop-backend/__tests__/agent-catalog.test.ts b/packages/workshop-backend/__tests__/agent-catalog.test.ts index 99038d899..5e58f1dcf 100644 --- a/packages/workshop-backend/__tests__/agent-catalog.test.ts +++ b/packages/workshop-backend/__tests__/agent-catalog.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - AGENT_CATALOG_MAX_DESCRIPTION_LENGTH, AGENT_CATALOG_MAX_ENTRIES, AGENT_CATALOG_MAX_TITLE_LENGTH, - boundAgentCatalog, + AGENT_CATALOG_MAX_DESCRIPTION_LENGTH, AGENT_CATALOG_MAX_ENTRIES, AGENT_CATALOG_MAX_ID_LENGTH, + AGENT_CATALOG_MAX_TITLE_LENGTH, boundAgentCatalog, } from "@gadgets/workshop-shared/gatekeeper"; import { completeAgentCatalogSnapshot, formatAgentCatalogPrompt, @@ -12,7 +12,8 @@ describe("normalizeAgentCatalog", () => { it("sorts entries, strips control characters, and truncates long fields to the max bounds", () => { let catalog = normalizeAgentCatalog({ entries: [ - { id: "2", title: " Zebra\u0000 ", description: "D".repeat(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH + 100) }, + { id: "2".repeat(AGENT_CATALOG_MAX_ID_LENGTH + 10), title: " Zebra\u0000 ", + description: "D".repeat(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH + 100) }, { id: "1", title: "T".repeat(AGENT_CATALOG_MAX_TITLE_LENGTH + 50), description: " First collection " }, ], }); @@ -20,7 +21,8 @@ describe("normalizeAgentCatalog", () => { expect(catalog).toEqual({ entries: [ { id: "1", title: "T".repeat(AGENT_CATALOG_MAX_TITLE_LENGTH), description: "First collection" }, - { id: "2", title: "Zebra", description: "D".repeat(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH) }, + { id: "2".repeat(AGENT_CATALOG_MAX_ID_LENGTH), title: "Zebra", + description: "D".repeat(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH) }, ], }); }); @@ -36,6 +38,23 @@ describe("normalizeAgentCatalog", () => { expect(catalog.truncated).toBe(true); }); + it("drops from the tail in provider order, then sorts the survivors", () => { + // The gatekeeper puts what must survive first (the Context Library leads with its collections), + // so a title that sorts last must still be kept when the cap clamps the list. + let entries = [ + {id: "keep", title: "Zulu collection", description: "survives despite sorting last"}, + ...Array.from({length: AGENT_CATALOG_MAX_ENTRIES}, (_, i) => ({ + id: `skill${i}`, title: `aa-skill-${String(i).padStart(4, "0")}`, description: "x", + })), + ]; + + let catalog = normalizeAgentCatalog({entries}); + + expect(catalog.entries).toHaveLength(AGENT_CATALOG_MAX_ENTRIES); + expect(catalog.entries.at(-1)).toEqual(entries[0]); + expect(catalog.truncated).toBe(true); + }); + it("normalizes control characters without emitting false truncation", () => { expect(normalizeAgentCatalog({ entries: [{id: "id", title: "Title\u009f", description: "Description"}], @@ -84,25 +103,29 @@ describe("normalizeAgentCatalog", () => { }); describe("boundAgentCatalog", () => { - it("enforces provider-side count and metadata limits", () => { - let entries = Array.from({length: 30}, (_, index) => ({ - id: `${index}`.repeat(300), - title: `Title ${index}`.repeat(30), - description: `Description ${index}`.repeat(100), + it("clamps the count and each field, keeping the order it was given", () => { + let entries = Array.from({length: AGENT_CATALOG_MAX_ENTRIES + 5}, (_, index) => ({ + id: `${index}-${"i".repeat(AGENT_CATALOG_MAX_ID_LENGTH)}`, + title: `${index}-${"t".repeat(AGENT_CATALOG_MAX_TITLE_LENGTH)}`, + description: `${index}-${"d".repeat(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH)}`, })); - let catalog = boundAgentCatalog(entries, {limit: Number.POSITIVE_INFINITY}); + let catalog = boundAgentCatalog(entries); - expect(catalog.entries).toHaveLength(0); + expect(catalog.entries).toHaveLength(AGENT_CATALOG_MAX_ENTRIES); + expect(catalog.entries[0].id).toHaveLength(AGENT_CATALOG_MAX_ID_LENGTH); + expect(catalog.entries[0].title).toHaveLength(AGENT_CATALOG_MAX_TITLE_LENGTH); + expect(catalog.entries[0].description).toHaveLength(AGENT_CATALOG_MAX_DESCRIPTION_LENGTH); + // Order preserved, so the caller decides which entries survive. + expect(catalog.entries[0].id.startsWith("0-")).toBe(true); expect(catalog.truncated).toBe(true); - let bounded = boundAgentCatalog(entries, {limit: 1000}); - expect(bounded.entries).toHaveLength(25); - expect(bounded.entries[0].id).toHaveLength(256); - expect(bounded.entries[0].title).toHaveLength(100); - expect(bounded.entries[0].description).toHaveLength(400); - expect(bounded.truncated).toBe(true); - expect(boundAgentCatalog(entries, {limit: -1}).entries).toEqual([]); - expect(boundAgentCatalog(entries, {limit: 2.9}).entries).toHaveLength(2); + }); + + it("reports no truncation when everything fits", () => { + expect(boundAgentCatalog([{id: "a", title: "A", description: "d"}])).toEqual({ + entries: [{id: "a", title: "A", description: "d"}], + truncated: false, + }); }); }); diff --git a/packages/workshop-backend/src/agent-catalog.ts b/packages/workshop-backend/src/agent-catalog.ts index 084297fd4..f48df5a74 100644 --- a/packages/workshop-backend/src/agent-catalog.ts +++ b/packages/workshop-backend/src/agent-catalog.ts @@ -17,11 +17,12 @@ function normalizeText(value: string, maxLength: number): string { } /** - * Workshop-side re-validation of a gatekeeper's catalog (defense-in-depth — the gatekeeper output is - * untrusted): strip control chars / collapse whitespace, drop unusable entries, sort, and re-clamp to - * the global AGENT_CATALOG_MAX_* bounds. This intentionally overlaps the provider-side - * boundAgentCatalog() (shared) — we don't trust the gatekeeper to have applied it. `id` keeps the full - * bound since it's the opaque key the agent passes back; only the title/description need shortening. + * Workshop-side re-validation of a gatekeeper's catalog (the gatekeeper output is untrusted): strip + * control chars / collapse whitespace, drop unusable entries, and re-clamp to the global + * AGENT_CATALOG_MAX_* bounds. Gatekeepers should apply the same caps before RPC, but the Workshop + * does not trust them to do so. `id` keeps the full bound since it is the opaque key the agent passes + * back; only the title/description need shortening. The count clamp drops from the tail so the + * gatekeeper's priority order decides what survives; the survivors are then sorted for display. */ export function normalizeAgentCatalog(catalog: AgentCatalog): AgentCatalog { let entries = catalog.entries @@ -30,12 +31,18 @@ export function normalizeAgentCatalog(catalog: AgentCatalog): AgentCatalog { title: normalizeText(entry.title, AGENT_CATALOG_MAX_TITLE_LENGTH), description: normalizeText(entry.description, AGENT_CATALOG_MAX_DESCRIPTION_LENGTH), })) - .filter(entry => entry.id.length > 0 && entry.title.length > 0) - .toSorted((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)); - let truncated = catalog.truncated === true || entries.length > AGENT_CATALOG_MAX_ENTRIES; + .filter(entry => entry.id.length > 0 && entry.title.length > 0); + let dropped = entries.length > AGENT_CATALOG_MAX_ENTRIES; + if (dropped) { + logger.warn("agent catalog exceeded the entry cap", { + event: "agent.catalog.truncated", size: entries.length, + }); + } return { - entries: entries.slice(0, AGENT_CATALOG_MAX_ENTRIES), - ...(truncated ? {truncated: true} : {}), + entries: entries + .slice(0, AGENT_CATALOG_MAX_ENTRIES) + .toSorted((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)), + ...(catalog.truncated === true || dropped ? {truncated: true} : {}), }; } diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index e5fed5bb2..43361797e 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -1,7 +1,7 @@ import { RpcCompatible, RpcStub, RpcTarget } from "capnweb"; import { validateRpc } from "capnweb-validate"; import { Overseer, GadgetMetadata, UiBundle, WorkpieceId, WorkpieceSummary, WorkpiecesSubscriber, GadgetClient, GadgetBindingInfo, GatekeeperClient, ActionState, ActionLogEntry, ActionsSubscriber, CodeUpdate, CodeSubscriber, AiChatMetadata, AiChatMessage, AiChatHistoryPage, AiChatSubscriber, AiChatAuthorInfo, AiModelConfig, AiChatMessageBody, AgentSpawnerConfig, ConsoleLogSubscriber, ConsoleLogEvent, CapsuleSpecifier, CollaboratorInfo, CollaboratorRole, AffectedCollaborator, ShareLinkInfo, GatekeeperCreationSpec, ObserverConfigCallback, ObserverBindingNeed, ObserverBindingFailure, BlueprintBindingAnnotation, BlueprintBinding, BlueprintMetadata, BlueprintOutput, MessageFormatRef, isOutputIcon, SpawnerEnvTarget, BlueprintGadgetSummary, AiChatStreamEvent, BlueprintScreenshotUpload, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ChatAttachmentUpload, ChatAttachmentHandle, ChatAttachmentRef, BoundHookInfo, PreApprovableAction, PresenceParticipant, PresenceSubscriber, SlashCommandChoice, SlashCommandRequest, validateBindingName, createOpenGadgetError, OPEN_GADGET_ERROR_CODES, resolveSiteName } from '@gadgets/workshop-shared/api'; -import { Gatekeeper, HookInitiator, ResourceDescription, ApprovalQueue, ActionDescription, ObservationAuthorizer, ObservationDescription, VendorDescription, SupportedResource, resolveRequestedResource, HookController, HookDescription, AGENT_CATALOG_MAX_ENTRIES, ActionKind } from "@gadgets/workshop-shared/gatekeeper"; +import { Gatekeeper, HookInitiator, ResourceDescription, ApprovalQueue, ActionDescription, ObservationAuthorizer, ObservationDescription, VendorDescription, SupportedResource, resolveRequestedResource, HookController, HookDescription, ActionKind } from "@gadgets/workshop-shared/gatekeeper"; import { DurableObject, WorkerEntrypoint, RpcStub as NativeRpcStub, RpcTarget as NativeRpcTarget, restore, @@ -4864,7 +4864,6 @@ class OverseerImpl implements AgentHooks { // native stub forwards transparently at runtime. let facet = this.getGatekeeperFacet(gatekeeperId) as unknown as CatalogGatekeeperFacet; let catalog = await facet.getAgentCatalog( - {limit: AGENT_CATALOG_MAX_ENTRIES}, authorizer as unknown as ObservationAuthorizer); return catalog ? normalizeAgentCatalog(catalog) : null; } catch (error) { diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 991ac786e..48bcdd729 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -103,44 +103,44 @@ export type AgentCatalogEntry = { /** The discovery metadata returned for one gatekeeper session. */ export type AgentCatalog = { - /** The discoverable items, already truncated to the requested/maximum count. */ + /** + * The discoverable items, in the gatekeeper's priority order: the Workshop clamps the list by + * dropping from the tail, so entries that must survive belong first. + */ entries: AgentCatalogEntry[]; - /** True if entries were dropped to fit the limit, so the agent knows the list is partial. */ + /** True if entries were dropped to fit the caps, so the agent knows the list is partial. */ truncated?: boolean; }; -/** Parameters the Workshop passes when requesting a catalog. */ -export type AgentCatalogRequest = { - /** Maximum number of entries to return. The gatekeeper must also enforce AGENT_CATALOG_MAX_ENTRIES. */ - limit: number; -}; - /** * Hard caps the Workshop enforces on any catalog, regardless of what the gatekeeper returns, since * the catalog is injected into the agent's context as untrusted data and must stay bounded. + * + * The entry count is a ceiling, not a budget. At the field caps below one entry serializes to about + * 793 ASCII bytes, so 1000 entries is ~775 KiB of prompt, and the catalog sits in the system prompt + * on every turn where compaction never reaches it. A gatekeeper is expected to return far fewer than + * the ceiling and to bound whichever of its item classes can grow without limit (the Context Library + * caps its skills), leaving this as the backstop against one that doesn't. */ -export const AGENT_CATALOG_MAX_ENTRIES = 25; +export const AGENT_CATALOG_MAX_ENTRIES = 1000; export const AGENT_CATALOG_MAX_ID_LENGTH = 256; export const AGENT_CATALOG_MAX_TITLE_LENGTH = 100; export const AGENT_CATALOG_MAX_DESCRIPTION_LENGTH = 400; /** - * Helper for gatekeepers to produce a well-formed AgentCatalog: clamps the entry count to the - * smaller of the request's limit and AGENT_CATALOG_MAX_ENTRIES, truncates each field to its cap, and - * sets `truncated` when entries were dropped. Gatekeepers should call this rather than hand-rolling - * the limits. + * Clamps a catalog to the AGENT_CATALOG_MAX_* caps and sets `truncated` when entries were dropped. + * Gatekeepers must apply this before returning, so an oversized library is bounded before it crosses + * the RPC boundary rather than after; the Workshop re-clamps what it receives regardless. Entries + * are kept in the order given, so the caller decides what survives. */ -export function boundAgentCatalog( - entries: AgentCatalogEntry[], request: AgentCatalogRequest): AgentCatalog { - let requestedLimit = Number.isFinite(request.limit) ? Math.max(0, Math.floor(request.limit)) : 0; - let limit = Math.min(requestedLimit, AGENT_CATALOG_MAX_ENTRIES); +export function boundAgentCatalog(entries: AgentCatalogEntry[]): AgentCatalog { return { - entries: entries.slice(0, limit).map(entry => ({ + entries: entries.slice(0, AGENT_CATALOG_MAX_ENTRIES).map(entry => ({ id: entry.id.slice(0, AGENT_CATALOG_MAX_ID_LENGTH), title: entry.title.slice(0, AGENT_CATALOG_MAX_TITLE_LENGTH), description: entry.description.slice(0, AGENT_CATALOG_MAX_DESCRIPTION_LENGTH), })), - truncated: entries.length > limit, + truncated: entries.length > AGENT_CATALOG_MAX_ENTRIES, }; } @@ -743,10 +743,10 @@ export interface Gatekeeper extends DurableObject { * whose session benefits from a discovery index (e.g. an agent singleton like the Context * Library); most gatekeepers omit it. Catalog access is an observation, so the implementation * must authorize it via `authorizer.authorizeObservation()` before returning metadata. Returns - * null when there is no catalog. Use `boundAgentCatalog()` to enforce the size limits. + * null when there is no catalog. Return the entries the agent most needs first and pass them + * through `boundAgentCatalog()`, since both that clamp and the Workshop's drop from the tail. */ getAgentCatalog?( - request: AgentCatalogRequest, authorizer: RpcStub, ): Promise;