Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions packages/gatekeeper-context/src/library-gatekeeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -318,14 +317,12 @@ export class ContextGatekeeper
}

async getAgentCatalog(
request: AgentCatalogRequest,
authorizer: NativeRpcStub<ObservationAuthorizer>): Promise<AgentCatalog> {
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,
Expand All @@ -334,9 +331,11 @@ export class ContextGatekeeper
}))
.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);
// Collections first: they are the agent's entry points into the library, and the Workshop
// clamps by dropping from the tail, so hundreds of skills can't push them out (skills past
// the cap stay reachable via the session's list()/search()). The merged list is left
// unsorted: the Workshop sorts the survivors, so sorting here would only pick the losers.
let catalog = {entries: [...collectionEntries, ...buildAgentSkillCatalogEntries(loaded)]};
if (catalog.entries.length > 0) {
let collectionIds = [...new Set(catalog.entries.map(entry => {
let slash = entry.id.indexOf("/");
Expand Down
2 changes: 0 additions & 2 deletions packages/gatekeeper-scheduler/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
AccountDescription,
ActionKind,
AgentCatalog,
AgentCatalogRequest,
AppUiContext,
ApprovalQueue,
Gatekeeper,
Expand Down Expand Up @@ -271,7 +270,6 @@ export class SchedulerGatekeeper

/** Returns no catalog because schedule discovery happens through list(). */
async getAgentCatalog(
_request: AgentCatalogRequest,
_authorizer: NativeRpcStub<ObservationAuthorizer>,
): Promise<AgentCatalog | null> {
return null;
Expand Down
50 changes: 23 additions & 27 deletions packages/workshop-backend/__tests__/agent-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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,
} from "@gadgets/workshop-shared/gatekeeper";
import {
completeAgentCatalogSnapshot, formatAgentCatalogPrompt,
Expand All @@ -12,15 +12,17 @@ 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 " },
],
});

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) },
],
});
});
Expand All @@ -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"}],
Expand Down Expand Up @@ -83,29 +102,6 @@ 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),
}));

let catalog = boundAgentCatalog(entries, {limit: Number.POSITIVE_INFINITY});

expect(catalog.entries).toHaveLength(0);
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);
});
});

describe("completeAgentCatalogSnapshot", () => {
it("loads each catalog once and preserves null snapshots", async () => {
let calls: number[] = [];
Expand Down
28 changes: 18 additions & 10 deletions packages/workshop-backend/src/agent-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ 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 validation of a gatekeeper's catalog (the gatekeeper output is untrusted): strip
* control chars / collapse whitespace, drop unusable entries, and clamp to the global
* AGENT_CATALOG_MAX_* bounds. This is the only bound applied to a catalog, so it is what keeps an
* arbitrarily large gatekeeper response out of the agent's context. `id` keeps the full bound since
* it's 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 stable presentation.
*/
export function normalizeAgentCatalog(catalog: AgentCatalog): AgentCatalog {
let entries = catalog.entries
Expand All @@ -30,12 +32,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} : {}),
};
}

Expand Down
3 changes: 1 addition & 2 deletions packages/workshop-backend/src/overseer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 12 additions & 32 deletions packages/workshop-shared/src/gatekeeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,47 +103,27 @@ 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 catalog is injected into the agent's context as untrusted data and must stay bounded. The
* entry count is what the context cost scales with: the catalog is inlined in the system prompt on
* every turn and compaction never reaches it, so at these field caps 200 entries is already ~80 KB.
* Anything past the cap is reached through the session API instead.
*/
export const AGENT_CATALOG_MAX_ENTRIES = 25;
export const AGENT_CATALOG_MAX_ENTRIES = 200;
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.
*/
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);
return {
entries: entries.slice(0, limit).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,
};
}

/** Describes a connected user account on an external service, for display purposes. */
export type AccountDescription = {
/** User's display name, e.g. "John Doe". This is a non-unique name that is human-readable. */
Expand Down Expand Up @@ -739,10 +719,10 @@ export interface Gatekeeper<Session> 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. Don't pre-truncate: the Workshop applies the AGENT_CATALOG_MAX_*
* caps, dropping from the tail, so list the entries the agent most needs first.
*/
getAgentCatalog?(
request: AgentCatalogRequest,
authorizer: RpcStub<ObservationAuthorizer>,
): Promise<AgentCatalog | null>;

Expand Down
Loading