diff --git a/apps/gateway-worker/README.md b/apps/gateway-worker/README.md index fa5c3c18..cf2b6272 100644 --- a/apps/gateway-worker/README.md +++ b/apps/gateway-worker/README.md @@ -92,6 +92,10 @@ OAuth link. Composio v3.1 REST pages and catalog/tool payloads are byte-bounded before parsing, then schema- and cardinality-bounded so a provider pagination fault cannot grow Worker memory without limit. +The toolkit-action endpoint returns product-owned starter prompts, not Composio's +agent-facing API descriptions. One shared presentation boundary covers every +toolkit, removes transport jargon, asks for missing details in plain language, +and requires confirmation before permanent changes. The lightweight `/v1/composer/skills` catalog reads active connected-app slugs from the reconciled database state alongside custom skills; opening `@` never waits on a provider catalog or account sync. Catalog and connected-account provider snapshots may load in parallel, but DB diff --git a/apps/gateway-worker/src/integration-action-presentation-support.ts b/apps/gateway-worker/src/integration-action-presentation-support.ts new file mode 100644 index 00000000..804bdc25 --- /dev/null +++ b/apps/gateway-worker/src/integration-action-presentation-support.ts @@ -0,0 +1,177 @@ +import type { ComposioTool } from "@cheatcode/composio"; +import type { ToolkitAction } from "@cheatcode/types/api"; + +const DESTRUCTIVE_ACTION_PATTERN = + /\b(delete|destroy|disconnect|empty|erase|purge|remove|revoke|uninstall)\b/i; +const READ_ACTION_PATTERN = + /^(check|download|export|find|inspect|list|look up|read|retrieve|search|show|view)\b/i; +const DRAFT_ACTION_PATTERN = /^(compose|create|write)\b.*\bdraft\b/i; +const SEND_EXISTING_ACTION_PATTERN = /^(publish|send)\b.*\b(draft|post)\b/i; +const MESSAGE_ACTION_PATTERN = /^(forward|post|reply|send)\b/i; +const CHANGE_ACTION_PATTERN = + /^(add|approve|archive|assign|cancel|close|connect|create|disable|edit|enable|invite|log|mark|merge|move|publish|record|reject|restore|schedule|set|start|stop|update|upload)\b/i; +const ARTICLE_ACTION_PATTERN = + /^(add|create|delete|edit|find|forward|move|open|post|publish|remove|reply to|restore|search|send|update|upload|view)\s+(.+)$/i; +const DETERMINER_PATTERN = /^(a|all|an|any|every|my|one|some|the|these|this|those|your)\b/i; +const PREPOSITION_PATTERN = /^(and|by|for|from|in|inside|on|or|to|with)\b/i; +const UNCOUNTABLE_NOUNS = new Set(["access", "content", "data", "information", "mail"]); +const IRREGULAR_PLURAL_NOUNS = new Set(["children", "feet", "men", "people", "teeth", "women"]); +const SINGULAR_NOUNS_ENDING_IN_S = new Set([ + "alias", + "analysis", + "basis", + "crisis", + "source", + "status", +]); +const CONSONANT_SOUND_VOWEL_WORDS = + /^(ewe|euro|one|uni(?:corn|form|que|t|vers)|user|utility|u[rs]l)\b/i; + +export function presentIntegrationAction( + tool: ComposioTool, + toolkitDisplayName?: string, +): ToolkitAction { + const fallbackName = actionNameFromSlug(tool.slug); + const toolkitName = toolkitDisplayName ?? tool.toolkit?.name; + const name = humanizeActionName(tool.name ?? fallbackName, toolkitName) || fallbackName; + return { + name, + prompt: actionPrompt(name, tool), + slug: tool.slug, + }; +} + +function humanizeActionName(value: string, toolkitName: string | undefined): string { + const cleaned = value + .trim() + .replace(/\s*\([^)]*\)\s*$/u, "") + .replace(/\bfrom natural language\b/giu, "") + .replace(/\bauth(?:enticated)? user\b/giu, "your account") + .replace(/\s+by\s+user IDs?\b/giu, " for an account") + .replace(/\s+(?:by|using|with)\s+(?:its\s+)?(?:[A-Za-z]+\s+){0,2}IDs?\b/giu, "") + .replace(/\buser IDs?\b/giu, "account") + .replace(/\bCRM object\b/giu, "CRM record") + .replace(/^get about user$/iu, "View user profile") + .replace(/^get about me$/iu, "View my profile") + .replace(/^trash\s+(.+)$/iu, "Move $1 to trash") + .replace(/^move to trash$/iu, "Move an item to trash") + .replace(/^untrash\s+(.+)$/iu, "Restore $1 from trash") + .replace(/^insert row database\b/iu, "Add database row") + .replace(/^insert\b/iu, "Add") + .replace(/^patch\b/iu, "Update") + .replace(/^query\b/iu, "Search") + .replace(/^replace\b/iu, "Update") + .replace(/^batch modify\b/iu, "Update multiple") + .replace(/^(fetch|get|list|retrieve)\b/iu, "View") + .replace(/^real-time search\b/iu, "Search") + .replace(/\bsend-as alias\b/giu, "email alias") + .replace(/\bpage markdown\b/giu, "page content") + .replace(/\bview query results\b/giu, "filtered results") + .replace(/\bview query\b/giu, "filtered view") + .replace(/\b(?:([A-Za-z]+)\s+)?block children\b/giu, "content inside $1 block") + .replace(/\bfile upload\b/giu, "uploaded file") + .replace(/\bchanges start page token\b/giu, "change tracking token") + .replace(/\bgoogle about this result\b/giu, "details about this result") + .replace(/\bwith filter\b/giu, "with filters") + .replace(/\s+/gu, " ") + .trim(); + return sentenceCaseActionName(cleaned, toolkitName); +} + +function sentenceCaseActionName(value: string, toolkitName: string | undefined): string { + const brandWords = new Map( + toolkitName?.split(/\s+/u).map((word) => [word.toLocaleLowerCase(), word]) ?? [], + ); + return value + .split(" ") + .map((word, index) => { + const brandedWord = brandWords.get(word.toLocaleLowerCase()); + if (brandedWord) { + return brandedWord; + } + if (index === 0) { + return word; + } + return /^[A-Z][a-z]+$/u.test(word) ? word.toLocaleLowerCase() : word; + }) + .join(" "); +} + +function actionNameFromSlug(slug: string): string { + const words = slug.split("_").slice(1).join(" ").toLocaleLowerCase(); + return words ? words.charAt(0).toLocaleUpperCase() + words.slice(1) : "Use this action"; +} + +function actionPrompt(name: string, tool: ComposioTool): string { + const goal = naturalActionGoal(lowerFirst(name)); + if (isDestructiveAction(name, tool)) { + return `Help me ${goal}. Find the right item and ask for confirmation before making permanent changes.`; + } + if (DRAFT_ACTION_PATTERN.test(name)) { + return `Help me ${goal}. Ask who it is for, the subject, and what it should say.`; + } + if (SEND_EXISTING_ACTION_PATTERN.test(name)) { + return `Help me ${goal}. Find the right one and show it to me before sending.`; + } + if (MESSAGE_ACTION_PATTERN.test(name)) { + return `Help me ${goal}. Ask for the recipient and content, then show me the final version before sending.`; + } + if (READ_ACTION_PATTERN.test(name)) { + return `Help me ${goal}. Ask what I am looking for if needed.`; + } + if (CHANGE_ACTION_PATTERN.test(name)) { + return `Help me ${goal}. Ask for the details you need, then show me what will change before doing it.`; + } + return `Help me ${goal}. Ask for any details you need in plain language.`; +} + +function isDestructiveAction(name: string, tool: ComposioTool): boolean { + if (DESTRUCTIVE_ACTION_PATTERN.test(`${name} ${tool.slug.replaceAll("_", " ")}`)) { + return true; + } + return /\bpermanently\b/i.test(`${tool.humanDescription ?? ""} ${tool.description ?? ""}`); +} + +function lowerFirst(value: string): string { + return value.charAt(0).toLocaleLowerCase() + value.slice(1); +} + +function naturalActionGoal(value: string): string { + const match = ARTICLE_ACTION_PATTERN.exec(value); + if ( + !match?.[1] || + !match[2] || + DETERMINER_PATTERN.test(match[2]) || + PREPOSITION_PATTERN.test(match[2]) + ) { + return value; + } + const nounPhrase = match[2].split(/\s+(?:by|for|from|in|inside|on|to|with)\s+/iu)[0] ?? match[2]; + const firstNoun = nounPhrase.split(/\s+/u)[0]?.toLocaleLowerCase() ?? ""; + const noun = nounPhrase.split(/\s+/u).at(-1)?.toLocaleLowerCase() ?? ""; + if ( + !noun || + UNCOUNTABLE_NOUNS.has(firstNoun) || + UNCOUNTABLE_NOUNS.has(noun) || + isPluralNoun(firstNoun) || + isPluralNoun(noun) + ) { + return value; + } + const article = articleFor(match[2]); + return `${match[1]} ${article} ${match[2]}`; +} + +function articleFor(value: string): "a" | "an" { + if (CONSONANT_SOUND_VOWEL_WORDS.test(value)) { + return "a"; + } + return /^[aeiou]/iu.test(value) ? "an" : "a"; +} + +function isPluralNoun(value: string): boolean { + return ( + IRREGULAR_PLURAL_NOUNS.has(value) || + (value.endsWith("s") && !value.endsWith("ss") && !SINGULAR_NOUNS_ENDING_IN_S.has(value)) + ); +} diff --git a/apps/gateway-worker/src/integrations-catalog.ts b/apps/gateway-worker/src/integrations-catalog.ts index afd9b025..97ca44ce 100644 --- a/apps/gateway-worker/src/integrations-catalog.ts +++ b/apps/gateway-worker/src/integrations-catalog.ts @@ -11,6 +11,7 @@ import { type ToolkitCategory, } from "@cheatcode/types/api"; import { z } from "zod"; +import { presentIntegrationAction } from "./integration-action-presentation-support"; import { loadIntegrationAccountSnapshot, reconcileIntegrationAccountSnapshot, @@ -112,17 +113,9 @@ export async function getIntegrationCatalog( const TOOLKIT_ACTION_LIMIT = 30; -const RawComposioToolSchema = z.object({ - description: z.string().max(4_000).optional(), - isDeprecated: z.boolean().optional(), - name: z.string().max(200), - slug: z.string().max(200), -}); -const RawComposioToolsSchema = z.array(RawComposioToolSchema).max(TOOLKIT_ACTION_LIMIT); - -// Lists a toolkit's top actions for the detail drawer (name + description). Uses the -// raw, user-independent tool definitions so it works whether or not the user has -// connected the toolkit yet. +// Lists a toolkit's top actions for the detail drawer. Provider descriptions are +// agent-facing API documentation, so this boundary converts them into safe starter +// prompts that remain useful without exposing IDs, schemas, or transport jargon. export async function listToolkitActions( env: IntegrationCatalogEnv, slug: string, @@ -130,23 +123,25 @@ export async function listToolkitActions( const apiKey = await requireComposioApiKey(env.COMPOSIO_API_KEY); const composio = new ComposioClient(apiKey); try { - const page = await composio.listTools( - { - important: true, - limit: TOOLKIT_ACTION_LIMIT, - toolkit: slug, - }, - COMPOSIO_REQUEST_TIMEOUT_MS, - ); - const tools = RawComposioToolsSchema.parse(page.items) + const [page, catalog] = await Promise.all([ + composio.listTools( + { + important: true, + limit: TOOLKIT_ACTION_LIMIT, + toolkit: slug, + }, + COMPOSIO_REQUEST_TIMEOUT_MS, + ), + readCachedCatalog(env.ENTITLEMENTS_CACHE), + ]); + const toolkitDisplayName = catalog?.toolkits.find( + (toolkit) => toolkit.name === slug, + )?.displayName; + const tools = page.items .filter((tool) => tool.isDeprecated !== true) .slice(0, TOOLKIT_ACTION_LIMIT); return { - actions: tools.map((tool) => ({ - description: tool.description ?? "", - name: tool.name ?? tool.slug, - slug: tool.slug, - })), + actions: tools.map((tool) => presentIntegrationAction(tool, toolkitDisplayName)), }; } catch (error) { throw new APIError(503, "upstream_provider_outage", "Unable to load toolkit actions", { diff --git a/apps/web/README.md b/apps/web/README.md index 13123ff4..fc865a58 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -40,6 +40,9 @@ Deliverable inserts its stable `/deliverables//` project re `@` is exclusively the skill picker: it merges custom skills with active connected apps from the lightweight database-backed skill catalog. The file browser reads durable project-file metadata and does not create or wake Daytona merely because the user opens it. +Connected-app detail drawers launch the gateway's product-owned starter prompt for an action. +The browser never derives user copy from provider API documentation; clicking an action produces a +plain-language request that can be sent immediately and asks for missing or high-stakes details. Computer preview wakeups run only while Browser is selected; opening Files never revives an unrelated dev server or changes the selected surface. Browser wakeups rotate the preview session and reload the visible iframe once after an actual sandbox/process recovery. Silent capability diff --git a/apps/web/src/components/skills/integration-skill-drawer.tsx b/apps/web/src/components/skills/integration-skill-drawer.tsx index 938d3cda..f711e2a3 100644 --- a/apps/web/src/components/skills/integration-skill-drawer.tsx +++ b/apps/web/src/components/skills/integration-skill-drawer.tsx @@ -287,7 +287,6 @@ function ActionRow({ toolkit: ToolkitCatalogEntry; }) { const Icon = actionIcon(action.slug); - const example = actionExample(action); return (
{isLast ? null : ( @@ -296,7 +295,7 @@ function ActionRow({ @@ -308,7 +307,7 @@ function ActionRow({ {action.name} - “{example}” + “{action.prompt}” @@ -338,18 +337,6 @@ function actionIcon(slug: string) { return FileText; } -function actionExample(action: ToolkitAction): string { - const description = action.description - .trim() - .replace(/^[-–—\s]+/, "") - .replace(/\s+/g, " "); - if (!description) { - return action.name; - } - const firstSentence = description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim(); - return firstSentence ?? description; -} - function accountDescription(account: IntegrationAccount): string { if (account.isDefault) { return "Saved as the default connected account."; diff --git a/packages/composio/README.md b/packages/composio/README.md index 1d983229..cf2fab93 100644 --- a/packages/composio/README.md +++ b/packages/composio/README.md @@ -21,6 +21,10 @@ The client intentionally exposes only the v3.1 routes Cheatcode owns: - list toolkits and tools - execute a version-selected tool +Tool parsing preserves Composio's agent-facing description, optional human +description, and input schema as separate fields. Callers define their own +presentation contract instead of displaying provider tool documentation as UI copy. + There is no generic request escape hatch. ## Code Checks diff --git a/packages/composio/src/index.ts b/packages/composio/src/index.ts index 5b002ff5..d0600712 100644 --- a/packages/composio/src/index.ts +++ b/packages/composio/src/index.ts @@ -1 +1,2 @@ export { ComposioClient, isComposioNotFoundError } from "./client"; +export type { ComposioTool } from "./types"; diff --git a/packages/composio/src/schemas.ts b/packages/composio/src/schemas.ts index 320628b8..8b7cf29d 100644 --- a/packages/composio/src/schemas.ts +++ b/packages/composio/src/schemas.ts @@ -12,6 +12,7 @@ import type { const IdentifierSchema = z.string().min(1).max(500); const SlugSchema = z.string().min(1).max(200); const TimestampSchema = z.string().datetime(); +const ToolDescriptionSchema = z.string().max(16_000); const RawConnectedAccountSchema = z .object({ @@ -85,11 +86,16 @@ const RawToolkitPageSchema = z.object({ items: z.array(RawToolkitSchema).max(500 const RawToolSchema = z .object({ - description: z.string().max(4_000).optional(), + description: ToolDescriptionSchema.optional(), + human_description: ToolDescriptionSchema.optional(), input_parameters: z.unknown().optional(), is_deprecated: z.boolean().optional(), name: z.string().max(200).optional(), slug: z.string().min(1).max(200), + toolkit: z + .object({ name: z.string().min(1).max(200), slug: SlugSchema }) + .strip() + .optional(), version: z.string().max(120).optional(), }) .strip(); @@ -173,10 +179,12 @@ export function parseToolPage(value: unknown): ComposioToolPage { const inputParameters = normalizeToolParameters(item.input_parameters); return { ...(item.description !== undefined ? { description: item.description } : {}), + ...(item.human_description !== undefined ? { humanDescription: item.human_description } : {}), ...(inputParameters !== undefined ? { inputParameters } : {}), ...(item.is_deprecated !== undefined ? { isDeprecated: item.is_deprecated } : {}), ...(item.name !== undefined ? { name: item.name } : {}), slug: item.slug, + ...(item.toolkit !== undefined ? { toolkit: item.toolkit } : {}), ...(item.version !== undefined ? { version: item.version } : {}), }; }); diff --git a/packages/composio/src/types.ts b/packages/composio/src/types.ts index 4e130d6d..d3243c52 100644 --- a/packages/composio/src/types.ts +++ b/packages/composio/src/types.ts @@ -42,10 +42,12 @@ export interface ComposioToolkit { export interface ComposioTool { description?: string; + humanDescription?: string; inputParameters?: unknown; isDeprecated?: boolean; name?: string; slug: string; + toolkit?: { name: string; slug: string }; version?: string; } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 4cf43c80..f0b31a73 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -292,9 +292,9 @@ export const IntegrationCatalogSchema = z.strictObject({ }); const ToolkitActionSchema = z.strictObject({ - description: z.string(), - name: z.string(), - slug: z.string(), + name: z.string().min(1).max(200), + prompt: z.string().min(1).max(400), + slug: z.string().min(1).max(200), }); export const ToolkitActionsResponseSchema = z.strictObject({