Skip to content
Merged
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
44 changes: 43 additions & 1 deletion packages/gatekeeper-context/__tests__/agent-skill.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
37 changes: 36 additions & 1 deletion packages/gatekeeper-context/src/agent-skill.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 3 additions & 18 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 All @@ -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";
Expand Down Expand Up @@ -318,25 +316,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,
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);
Comment thread
AshishKumar4 marked this conversation as resolved.
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("/");
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
61 changes: 42 additions & 19 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, boundAgentCatalog,
} 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 @@ -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,
});
});
});

Expand Down
27 changes: 17 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,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
Expand All @@ -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} : {}),
};
}

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
Loading
Loading