refactor(x): skills declare their own availability; excludeIds dies

Catalog membership for connection-gated skills (composio-integration,
code-with-agents, slack) was decided by hand-rolled excludeIds forks
inside buildCopilotInstructions — a third place that had to know which
skill depends on which connection. Now each entry declares an
availability() check (repos resolved via the new lazyResolve helper, so
the skills module keeps no static DI edge; failures read as
unavailable, the historical default), buildAvailableSkillCatalog
evaluates them concurrently for the system prompt, and the excludeIds
mechanism is deleted. Availability gates catalog VISIBILITY only —
loadSkill still resolves explicitly-requested ids, exactly matching the
old behavior. Tests cover the pure filter and pin that gated ids remain
resolvable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-10 08:48:24 +05:30
parent efeaae1e19
commit 8dd5a2b49f
5 changed files with 114 additions and 19 deletions

View file

@ -70,6 +70,11 @@ export interface ModelCapability {
content: string;
// BuiltinTools keys this capability owns; attached mid-turn on load.
tools?: string[];
// Catalog visibility: when false, the capability is omitted from the
// loadSkill catalog (e.g. slack when no workspace is connected).
// Deliberately NOT a loadSkill fence — an explicitly-requested id still
// resolves, matching the historical excludeIds behavior. Defaults true.
availability?: () => Promise<boolean> | boolean;
}
// App/always-activated: an eager system-prompt contribution composed at

View file

@ -1,4 +1,4 @@
import { skillCatalog, buildSkillCatalog } from "./skills/index.js";
import { skillCatalog, buildAvailableSkillCatalog } from "./skills/index.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
@ -409,13 +409,11 @@ export async function buildCopilotInstructions(): Promise<string> {
// knowledge sources unavailable — fall back to no channel hint
}
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
if (!slackConnected) excludeIds.push('slack');
// Always build from the live skill set so disk skills added/removed at
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
const catalog = buildSkillCatalog({ excludeIds });
// Catalog membership is each skill's own availability() (connection
// gates declared on the entries in skills/index.ts), evaluated against
// the live skill set so disk skills added/removed at runtime (after
// refreshDiskSkills + cache invalidation) are reflected.
const catalog = await buildAvailableSkillCatalog();
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint, googleConnected);
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
cachedInstructions = composioPrompt

View file

@ -200,3 +200,32 @@ describe("capability activation boundary", () => {
}
});
});
describe("availability (catalog visibility)", () => {
it("drops unavailable entries from the catalog but keeps ungated ones", async () => {
const { filterAvailableEntries, buildCatalogFromEntries } = await import("./index.js");
const entries = [
{ id: "always-on", title: "Always", summary: "s", content: "c" },
{ id: "connected", title: "Connected", summary: "s", content: "c", availability: () => true },
{ id: "disconnected", title: "Disconnected", summary: "s", content: "c", availability: async () => false },
];
const available = await filterAvailableEntries(entries);
expect(available.map((e) => e.id)).toEqual(["always-on", "connected"]);
const catalog = buildCatalogFromEntries(available);
expect(catalog).toContain("always-on");
expect(catalog).toContain("connected");
expect(catalog).not.toContain("disconnected");
});
it("the connection-gated bundled skills declare availability", async () => {
const skills = await import("./index.js");
const catalog = skills.buildSkillCatalog();
// The ungated builder still lists them (loadSkill resolves explicit ids
// regardless of availability — visibility gating is catalog-only).
for (const id of ["composio-integration", "code-with-agents", "slack"]) {
expect(catalog).toContain(id);
expect(skills.resolveSkill(id)).not.toBeNull();
}
});
});

View file

@ -104,6 +104,7 @@ const definitions: SkillDefinition[] = [
},
{
id: "composio-integration",
availability: isComposioAvailable,
title: "Composio Integration",
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
content: composioIntegrationSkill,
@ -131,6 +132,7 @@ const definitions: SkillDefinition[] = [
},
{
id: "code-with-agents",
availability: isCodeModeAvailable,
title: "Code with Agents",
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
content: codeWithAgentsSkill,
@ -178,9 +180,8 @@ const definitions: SkillDefinition[] = [
tools: ["browser-control", "load-browser-skill"],
},
{
// Excluded from the catalog when Slack isn't connected (see
// buildCopilotInstructions excludeIds), mirroring composio-integration.
id: "slack",
availability: isSlackAvailable,
title: "Slack",
summary: "Read, search, and send Slack messages via the agent-slack CLI — catch up on channels, summarize threads, list users. Load FIRST for ANY Slack request; Slack is connected natively, never through Composio.",
content: slackSkill,
@ -237,12 +238,67 @@ function getSkillEntries(): SkillEntry[] {
return [...bundledEntries, ...diskEntries];
}
// ---- Availability checks (catalog visibility) ----
// Connection-state gates for the catalog, evaluated per build. Repos resolve
// lazily so this module keeps no static edge into the DI graph; any failure
// reads as "not available" (the historical default).
async function isComposioAvailable(): Promise<boolean> {
try {
const { isConfigured } = await import("../../../composio/client.js");
return await isConfigured();
} catch {
return false;
}
}
async function isCodeModeAvailable(): Promise<boolean> {
try {
const { lazyResolve } = await import("../../../di/lazy-resolve.js");
const repo = await lazyResolve<import("../../../code-mode/repo.js").ICodeModeConfigRepo>("codeModeConfigRepo");
return (await repo.getConfig()).enabled;
} catch {
return false;
}
}
async function isSlackAvailable(): Promise<boolean> {
try {
const { lazyResolve } = await import("../../../di/lazy-resolve.js");
const repo = await lazyResolve<import("../../../slack/repo.js").ISlackConfigRepo>("slackConfigRepo");
const config = await repo.getConfig();
return config.enabled && config.workspaces.length > 0;
} catch {
return false;
}
}
// Pure availability filter over an explicit entry list (exported for tests):
// entries without an availability check are kept; checks are evaluated
// concurrently and a false drops the entry from the CATALOG only (loadSkill
// still resolves explicit ids, matching the historical excludeIds behavior).
export async function filterAvailableEntries<T extends { availability?: () => Promise<boolean> | boolean }>(
entries: readonly T[],
): Promise<T[]> {
const verdicts = await Promise.all(
entries.map((entry) => (entry.availability ? entry.availability() : true)),
);
return entries.filter((_, i) => verdicts[i]);
}
// The catalog restricted to currently-available skills — what
// buildCopilotInstructions embeds in the system prompt.
export async function buildAvailableSkillCatalog(): Promise<string> {
return buildCatalogFromEntries(await filterAvailableEntries(getSkillEntries()));
}
/**
* Build a skill catalog string, optionally excluding specific skills by ID.
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
* Build a skill catalog string from the live skill set (availability
* ignored), so it reflects disk skills added/removed at runtime. The
* system prompt uses buildAvailableSkillCatalog instead.
*/
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
return buildCatalogFromEntries(getSkillEntries(), options);
export function buildSkillCatalog(): string {
return buildCatalogFromEntries(getSkillEntries());
}
// Pure catalog builder over an explicit entry list (exported for the
@ -251,15 +307,11 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
// by the assembly layer, never offered for loadSkill.
export function buildCatalogFromEntries(
all: ReadonlyArray<CapabilityDefinition & { catalogPath?: string }>,
options?: { excludeIds?: string[] },
): string {
// The type-guard filter must keep the catalogPath intersection alive.
const modelEntries = all.filter(
const entries = all.filter(
(e): e is ModelCapability & { catalogPath?: string } => isModelActivated(e),
);
const entries = options?.excludeIds
? modelEntries.filter(e => !options.excludeIds!.includes(e.id))
: modelEntries;
const sections = entries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath ?? `${CATALOG_PREFIX}/${entry.id}/skill.ts`}\``,

View file

@ -0,0 +1,11 @@
// Lazily import the DI container and resolve one token. Use this instead of
// a static `import container from "./container.js"` when the importing
// module must not add a static edge into the DI graph (the container's
// module tree is large, and some importers — the agent registry, tool
// handlers, capability availability checks — are themselves imported while
// the container initializes). One helper holds the pattern and this
// rationale instead of hand-rolled copies at every call site.
export async function lazyResolve<T>(token: string): Promise<T> {
const { default: container } = await import("./container.js");
return container.resolve<T>(token);
}