diff --git a/apps/x/packages/core/src/application/assistant/capabilities/types.ts b/apps/x/packages/core/src/application/assistant/capabilities/types.ts index 1472a428..f37035d4 100644 --- a/apps/x/packages/core/src/application/assistant/capabilities/types.ts +++ b/apps/x/packages/core/src/application/assistant/capabilities/types.ts @@ -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; } // App/always-activated: an eager system-prompt contribution composed at diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index f4009352..d5fb4953 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -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 { // 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 diff --git a/apps/x/packages/core/src/application/assistant/skills/index.test.ts b/apps/x/packages/core/src/application/assistant/skills/index.test.ts index 48784e27..71056630 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.test.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.test.ts @@ -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(); + } + }); +}); diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index bcbfc5e2..d21c7d47 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -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 { + try { + const { isConfigured } = await import("../../../composio/client.js"); + return await isConfigured(); + } catch { + return false; + } +} + +async function isCodeModeAvailable(): Promise { + try { + const { lazyResolve } = await import("../../../di/lazy-resolve.js"); + const repo = await lazyResolve("codeModeConfigRepo"); + return (await repo.getConfig()).enabled; + } catch { + return false; + } +} + +async function isSlackAvailable(): Promise { + try { + const { lazyResolve } = await import("../../../di/lazy-resolve.js"); + const repo = await lazyResolve("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 Promise | boolean }>( + entries: readonly T[], +): Promise { + 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 { + 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, - 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`}\``, diff --git a/apps/x/packages/core/src/di/lazy-resolve.ts b/apps/x/packages/core/src/di/lazy-resolve.ts new file mode 100644 index 00000000..e3eb65fe --- /dev/null +++ b/apps/x/packages/core/src/di/lazy-resolve.ts @@ -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(token: string): Promise { + const { default: container } = await import("./container.js"); + return container.resolve(token); +}