From 44f98393e35833db2a8f1f0338d1d47d3dcaad49 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 23 May 2026 14:32:30 -0700 Subject: [PATCH 01/15] Add server-side Composio managed auth --- agents/base2/base2.ts | 3 + agents/types/secret-agent-definition.ts | 8 +- bun.lock | 19 + common/src/constants/composio.ts | 12 + .../db/migrations/0056_simple_tyrannus.sql | 9 + .../src/db/migrations/meta/0056_snapshot.json | 3649 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 9 +- packages/internal/src/db/schema.ts | 16 + packages/internal/src/env-schema.ts | 2 + sdk/src/composio.ts | 147 + sdk/src/custom-tool.ts | 6 +- sdk/src/run-state.ts | 15 +- sdk/src/run.ts | 27 +- web/package.json | 1 + .../v1/composio/__tests__/composio.test.ts | 294 ++ web/src/app/api/v1/composio/_auth.ts | 60 + web/src/app/api/v1/composio/execute/_post.ts | 130 + web/src/app/api/v1/composio/execute/route.ts | 18 + web/src/app/api/v1/composio/tools/_post.ts | 86 + web/src/app/api/v1/composio/tools/route.ts | 18 + .../__tests__/composio-rate-limiter.test.ts | 44 + web/src/server/composio-rate-limiter.ts | 116 + web/src/server/composio.ts | 345 ++ 23 files changed, 5022 insertions(+), 12 deletions(-) create mode 100644 common/src/constants/composio.ts create mode 100644 packages/internal/src/db/migrations/0056_simple_tyrannus.sql create mode 100644 packages/internal/src/db/migrations/meta/0056_snapshot.json create mode 100644 sdk/src/composio.ts create mode 100644 web/src/app/api/v1/composio/__tests__/composio.test.ts create mode 100644 web/src/app/api/v1/composio/_auth.ts create mode 100644 web/src/app/api/v1/composio/execute/_post.ts create mode 100644 web/src/app/api/v1/composio/execute/route.ts create mode 100644 web/src/app/api/v1/composio/tools/_post.ts create mode 100644 web/src/app/api/v1/composio/tools/route.ts create mode 100644 web/src/server/__tests__/composio-rate-limiter.test.ts create mode 100644 web/src/server/composio-rate-limiter.ts create mode 100644 web/src/server/composio.ts diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 662cc2a775..087de11d4c 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -1,4 +1,5 @@ import { buildArray } from '@codebuff/common/util/array' +import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' import { FREEBUFF_GEMINI_THINKER_AGENT_ID, FREEBUFF_GEMINI_THINKER_INSTRUCTIONS_PROMPT, @@ -105,6 +106,7 @@ export function createBase2( 'set_output', 'list_directory', 'glob', + ...COMPOSIO_META_TOOL_NAMES, ), spawnableAgents: buildArray( !isMax && 'file-picker', @@ -149,6 +151,7 @@ Current date: ${PLACEHOLDER.CURRENT_DATE}. - **Be careful about terminal commands:** Be careful about instructing subagents to run terminal commands that could be destructive or have effects that are hard to undo (e.g. git push, git commit, running any scripts -- especially ones that could alter production environments (!), installing packages globally, etc). Don't run any of these effectful commands unless the user explicitly asks you to. - **Do what the user asks:** If the user asks you to do something, even running a risky terminal command, do it. - **Don't use set_output:** The set_output tool is for spawned subagents to report results. Don't use it yourself. +- **External apps:** When Composio tools are available and the user asks to work with connected apps or services like Gmail, Google Calendar, GitHub, Slack, Linear, or Notion, use them to search for the right app tools, help the user connect their account, and execute the requested action. # Code Editing Mandates diff --git a/agents/types/secret-agent-definition.ts b/agents/types/secret-agent-definition.ts index cab28c2669..6718cea418 100644 --- a/agents/types/secret-agent-definition.ts +++ b/agents/types/secret-agent-definition.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from './agent-definition' import type * as Tools from './tools' +import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' export type { Tools } export type AllToolNames = @@ -9,9 +10,12 @@ export type AllToolNames = | 'create_plan' | 'spawn_agent_inline' | 'update_subgoal' + | ComposioMetaToolName -export interface SecretAgentDefinition - extends Omit { +export interface SecretAgentDefinition extends Omit< + AgentDefinition, + 'toolNames' +> { /** Tools this agent can use. */ toolNames?: AllToolNames[] } diff --git a/bun.lock b/bun.lock index 4f6021307f..8868dc26ae 100644 --- a/bun.lock +++ b/bun.lock @@ -272,6 +272,7 @@ "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", "@codebuff/sdk": "workspace:*", + "@composio/core": "^0.10.0", "@hookform/resolvers": "^3.9.0", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", @@ -550,6 +551,12 @@ "@commitlint/types": ["@commitlint/types@19.8.1", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw=="], + "@composio/client": ["@composio/client@0.1.0-alpha.72", "", {}, "sha512-2WYXgdlMvhoaJCsaSfyMAYGQ2tS5l2Fqdh2cMRqNi8NybIoOkFZy2ApBJJOoQwqfpUr41VymMW6FtiNQm7JavQ=="], + + "@composio/core": ["@composio/core@0.10.0", "", { "dependencies": { "@composio/client": "0.1.0-alpha.72", "@composio/json-schema-to-zod": "0.1.20", "@types/json-schema": "^7.0.15", "chalk": "^4.1.2", "openai": "^6.16.0", "pusher-js": "^8.4.0", "semver": "^7.7.2", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-dG2BKF4NRiE8HHDzWLLgjMMo3FU4NJ6SxR961EpdLWWEKhkl+yP/mZlIN7p/4WnCR/fuDBKH1Qh+7kBdcHmEHA=="], + + "@composio/json-schema-to-zod": ["@composio/json-schema-to-zod@0.1.20", "", { "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-d4V34itLrUWG/VBh7ciznKcxF/T22MBLHmuEzHoX0zsBOHsUmjYz5qtDh20S2p3FE+HHvLZxpXiv8yfdd4yI+Q=="], + "@contentlayer2/cli": ["@contentlayer2/cli@0.5.8", "", { "dependencies": { "@contentlayer2/core": "0.5.8", "@contentlayer2/utils": "0.5.8", "clipanion": "^3.2.1", "typanion": "^3.12.1" } }, "sha512-sPXTe24tXPpru6hE45riBj7xjVIDuTfjQXbwitwcNkm0yd0kNJaDPBA2C4U5mRFgg1a/aftlIKeVavlkBnuZQA=="], "@contentlayer2/client": ["@contentlayer2/client@0.5.8", "", { "dependencies": { "@contentlayer2/core": "0.5.8" } }, "sha512-mc6uGuHI5ygO6s5KHhoFfiUN7BUUHrPUsxjU+EnGHjMo3+rcYnHd+G6cDDcf0fB21cX7NbJ57V9UlAMu5JKM0w=="], @@ -2900,6 +2907,8 @@ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openai": ["openai@6.39.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ=="], + "openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -3094,6 +3103,8 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pusher-js": ["pusher-js@8.5.0", "", { "dependencies": { "tweetnacl": "^1.0.3" } }, "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw=="], + "qs": ["qs@6.11.0", "", { "dependencies": { "side-channel": "^1.0.4" } }, "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -3470,6 +3481,8 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -3698,6 +3711,10 @@ "@commitlint/top-level/find-up": ["find-up@7.0.0", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="], + "@composio/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@composio/core/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@contentlayer2/utils/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="], "@contentlayer2/utils/@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="], @@ -4310,6 +4327,8 @@ "@commitlint/top-level/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "@composio/core/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@contentlayer2/utils/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@contentlayer2/utils/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], diff --git a/common/src/constants/composio.ts b/common/src/constants/composio.ts new file mode 100644 index 0000000000..864051cfad --- /dev/null +++ b/common/src/constants/composio.ts @@ -0,0 +1,12 @@ +export const COMPOSIO_API_KEY_ENV_VAR = 'COMPOSIO_API_KEY' + +export const COMPOSIO_META_TOOL_NAMES = [ + 'COMPOSIO_MANAGE_CONNECTIONS', + 'COMPOSIO_MULTI_EXECUTE_TOOL', + 'COMPOSIO_REMOTE_BASH_TOOL', + 'COMPOSIO_REMOTE_WORKBENCH', + 'COMPOSIO_SEARCH_TOOLS', + 'COMPOSIO_GET_TOOL_SCHEMAS', +] as const + +export type ComposioMetaToolName = (typeof COMPOSIO_META_TOOL_NAMES)[number] diff --git a/packages/internal/src/db/migrations/0056_simple_tyrannus.sql b/packages/internal/src/db/migrations/0056_simple_tyrannus.sql new file mode 100644 index 0000000000..48cf0229cf --- /dev/null +++ b/packages/internal/src/db/migrations/0056_simple_tyrannus.sql @@ -0,0 +1,9 @@ +CREATE TABLE "composio_session" ( + "user_id" text PRIMARY KEY NOT NULL, + "session_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "composio_session_session_id_unique" UNIQUE("session_id") +); +--> statement-breakpoint +ALTER TABLE "composio_session" ADD CONSTRAINT "composio_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0056_snapshot.json b/packages/internal/src/db/migrations/meta/0056_snapshot.json new file mode 100644 index 0000000000..08e5837ae0 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0056_snapshot.json @@ -0,0 +1,3649 @@ +{ + "id": "d047421b-9f49-48d9-89db-fbd81b41ab64", + "prevId": "fffbacec-ce15-4625-9adb-5c5a45bb9c3e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.composio_session": { + "name": "composio_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "composio_session_user_id_user_id_fk": { + "name": "composio_session_user_id_user_id_fk", + "tableFrom": "composio_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "composio_session_session_id_unique": { + "name": "composio_session_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_mode_country_access_cache": { + "name": "free_mode_country_access_cache", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spur_ip_privacy_signals": { + "name": "spur_ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spur_status": { + "name": "spur_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scamalytics_ip_privacy_signals": { + "name": "scamalytics_ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scamalytics_status": { + "name": "scamalytics_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scamalytics_score": { + "name": "scamalytics_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scamalytics_risk": { + "name": "scamalytics_risk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "privacy_decision": { + "name": "privacy_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_provider_decision": { + "name": "privacy_provider_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_mode_country_cache_expires_at": { + "name": "idx_free_mode_country_cache_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_mode_country_access_cache_user_id_user_id_fk": { + "name": "free_mode_country_access_cache_user_id_user_id_fk", + "tableFrom": "free_mode_country_access_cache", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "free_mode_country_access_cache_user_id_client_ip_hash_pk": { + "name": "free_mode_country_access_cache_user_id_client_ip_hash_pk", + "columns": [ + "user_id", + "client_ip_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_checked_at": { + "name": "country_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session_admit": { + "name": "free_session_admit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_units": { + "name": "session_units", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + } + }, + "indexes": { + "idx_free_session_admit_user_model_time": { + "name": "idx_free_session_admit_user_model_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "admitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_admit_user_id_user_id_fk": { + "name": "free_session_admit_user_id_user_id_fk", + "tableFrom": "free_session_admit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_auth_hash": { + "name": "cli_auth_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_cli_auth_code_idx": { + "name": "session_cli_auth_code_idx", + "columns": [ + { + "expression": "fingerprint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cli_auth_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"session\".\"cli_auth_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": [ + "queued", + "active" + ] + }, + "public.freebuff_access_tier": { + "name": "freebuff_access_tier", + "schema": "public", + "values": [ + "full", + "limited" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 91e6898f02..72312bacf8 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1779661395724, "tag": "0055_glossy_gertrude_yorkes", "breakpoints": true + }, + { + "idx": 56, + "version": "7", + "when": 1779667810810, + "tag": "0056_simple_tyrannus", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 9fd30f3139..de46a11edb 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -305,6 +305,22 @@ export const encryptedApiKeys = pgTable( }), ) +export const composioSession = pgTable( + 'composio_session', + { + user_id: text('user_id') + .primaryKey() + .references(() => user.id, { onDelete: 'cascade' }), + session_id: text('session_id').notNull().unique(), + created_at: timestamp('created_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + }, +) + // Organization tables export const orgRoleEnum = pgEnum('org_role', ['owner', 'admin', 'member']) diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 644426b879..d42cb445bd 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -18,6 +18,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ IPINFO_TOKEN: z.string().min(1), SPUR_TOKEN: z.string().min(1), SCAMALYTICS_API_KEY: z.string().min(1), + COMPOSIO_API_KEY: z.string().min(1).optional(), // ZeroClick tenant API key used for server-side offer fallback requests. ZEROCLICK_API_KEY: z.string().min(1).optional(), // BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad. @@ -112,6 +113,7 @@ export const serverProcessEnv: ServerInput = { IPINFO_TOKEN: process.env.IPINFO_TOKEN, SPUR_TOKEN: process.env.SPUR_TOKEN, SCAMALYTICS_API_KEY: process.env.SCAMALYTICS_API_KEY, + COMPOSIO_API_KEY: process.env.COMPOSIO_API_KEY, ZEROCLICK_API_KEY: process.env.ZEROCLICK_API_KEY, CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts new file mode 100644 index 0000000000..ed43f9c218 --- /dev/null +++ b/sdk/src/composio.ts @@ -0,0 +1,147 @@ +import { WEBSITE_URL } from './constants' + +import type { CustomToolDefinition } from './custom-tool' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { JSONValue } from '@codebuff/common/types/json' +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' + +type ComposioToolsResponse = { + sessionId: string + tools: Array<{ + toolName: string + inputSchema: Record + description: string + }> +} + +type ComposioExecuteResponse = { + output: ToolResultOutput[] +} + +function toJsonValue(value: unknown): JSONValue { + try { + return JSON.parse(JSON.stringify(value ?? null)) as JSONValue + } catch { + return String(value) as JSONValue + } +} + +async function readErrorMessage(response: Response): Promise { + try { + const body = (await response.json()) as { + error?: unknown + message?: unknown + } + return String(body.error ?? body.message ?? response.statusText) + } catch { + return response.statusText + } +} + +async function executeComposioToolViaServer(params: { + apiKey: string + sessionId: string + toolName: string + input: Record +}): Promise { + try { + const response = await fetch( + new URL('/api/v1/composio/execute', WEBSITE_URL), + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sessionId: params.sessionId, + toolName: params.toolName, + input: params.input, + }), + }, + ) + + if (!response.ok) { + return [ + { + type: 'json', + value: { + errorMessage: await readErrorMessage(response), + status: response.status, + }, + }, + ] + } + + const body = (await response.json()) as ComposioExecuteResponse + return body.output + } catch (error) { + return [ + { + type: 'json', + value: { + errorMessage: error instanceof Error ? error.message : String(error), + }, + }, + ] + } +} + +export async function getComposioCustomToolDefinitions(params: { + apiKey: string + logger?: Pick +}): Promise { + let response: Response + try { + response = await fetch(new URL('/api/v1/composio/tools', WEBSITE_URL), { + method: 'POST', + headers: { + Authorization: `Bearer ${params.apiKey}`, + }, + }) + } catch (error) { + params.logger?.warn( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to fetch Composio tools', + ) + return [] + } + + if (!response.ok) { + if (response.status !== 503) { + params.logger?.warn( + { status: response.status, error: await readErrorMessage(response) }, + 'Failed to fetch Composio tools', + ) + } + return [] + } + + try { + const body = (await response.json()) as ComposioToolsResponse + return body.tools.map((tool) => ({ + toolName: tool.toolName, + inputSchema: tool.inputSchema, + description: tool.description, + endsAgentStep: true, + exampleInputs: [], + execute: async (input) => { + return executeComposioToolViaServer({ + apiKey: params.apiKey, + sessionId: body.sessionId, + toolName: tool.toolName, + input: + input && typeof input === 'object' + ? (input as Record) + : { value: toJsonValue(input) }, + }) + }, + })) + } catch (error) { + params.logger?.warn( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to parse Composio tools response', + ) + return [] + } +} diff --git a/sdk/src/custom-tool.ts b/sdk/src/custom-tool.ts index 943ac22c6d..adc745b09b 100644 --- a/sdk/src/custom-tool.ts +++ b/sdk/src/custom-tool.ts @@ -2,6 +2,10 @@ import type { ToolName } from '@codebuff/common/tools/constants' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' import type { z } from 'zod/v4' +export type CustomToolInputSchema = + | z.ZodType + | Record + export type CustomToolDefinition< N extends string = string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -10,7 +14,7 @@ export type CustomToolDefinition< Input extends any = any, > = { toolName: N - inputSchema: z.ZodType + inputSchema: CustomToolInputSchema description: string endsAgentStep: boolean exampleInputs: Input[] diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index 7fcc35a42b..59f84e1eba 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -112,11 +112,16 @@ function processCustomToolDefinitions( ): CustomToolDefinitions { return Object.fromEntries( customToolDefinitions.map((toolDefinition) => { - // Convert Zod schema to JSON Schema format so it survives JSON serialization - // The agent-runtime will wrap this with AI SDK's jsonSchema() helper - const jsonSchema = z.toJSONSchema(toolDefinition.inputSchema, { - io: 'input', - }) as Record + const isZodSchema = + typeof (toolDefinition.inputSchema as { safeParse?: unknown }) + .safeParse === 'function' + // Convert Zod schemas to JSON Schema so they survive JSON serialization. + // Some adapters already provide JSON Schema directly. + const jsonSchema = isZodSchema + ? (z.toJSONSchema(toolDefinition.inputSchema as z.ZodType, { + io: 'input', + }) as Record) + : { ...(toolDefinition.inputSchema as Record) } delete jsonSchema['$schema'] return [ diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 4014e85449..d4c455959e 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -12,12 +12,14 @@ import { listMCPTools, callMCPTool, } from '@codebuff/common/mcp/client' +import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' import { extractApiErrorDetails } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' +import { getComposioCustomToolDefinitions } from './composio' import { getErrorStatusCode } from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' @@ -233,6 +235,7 @@ async function runOnce({ spawn = require('child_process').spawn as CodebuffSpawn } const preparedContent = wrapContentForUserMessage(content) + let activeCustomToolDefinitions = customToolDefinitions ?? [] // Init session state let agentId @@ -396,9 +399,9 @@ async function runOnce({ mcpConfig, }, overrides: overrideTools ?? {}, - customToolDefinitions: customToolDefinitions + customToolDefinitions: activeCustomToolDefinitions ? Object.fromEntries( - customToolDefinitions.map((def) => [def.toolName, def]), + activeCustomToolDefinitions.map((def) => [def.toolName, def]), ) : {}, cwd, @@ -511,9 +514,27 @@ async function runOnce({ if (!userInfo) { return getCancelledRunState('Invalid API key or user not found') } - const userId = userInfo.id + const composioCustomToolDefinitions = await getComposioCustomToolDefinitions({ + apiKey, + logger, + }) + + for (const toolName of COMPOSIO_META_TOOL_NAMES) { + delete sessionState.fileContext.customToolDefinitions[toolName] + } + + if (composioCustomToolDefinitions.length > 0) { + activeCustomToolDefinitions = [ + ...activeCustomToolDefinitions, + ...composioCustomToolDefinitions, + ] + sessionState = await applyOverridesToSessionState(cwd, sessionState, { + customToolDefinitions: activeCustomToolDefinitions, + }) + } + if (signal?.aborted) { return getCancelledRunState('Run cancelled by user.') } diff --git a/web/package.json b/web/package.json index 830cbbdc36..e1b947073e 100644 --- a/web/package.json +++ b/web/package.json @@ -42,6 +42,7 @@ "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", "@codebuff/sdk": "workspace:*", + "@composio/core": "^0.10.0", "@hookform/resolvers": "^3.9.0", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts new file mode 100644 index 0000000000..dc613fe038 --- /dev/null +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -0,0 +1,294 @@ +import { + describe, + expect, + mock, + test, + beforeAll, + beforeEach, + afterEach, +} from 'bun:test' +import { NextRequest } from 'next/server' + +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { postComposioExecute as PostComposioExecute } from '../execute/_post' +import type { postComposioTools as PostComposioTools } from '../tools/_post' + +let postComposioExecute: typeof PostComposioExecute +let postComposioTools: typeof PostComposioTools + +beforeAll(async () => { + mock.module('server-only', () => ({})) + ;({ postComposioExecute } = await import('../execute/_post')) + ;({ postComposioTools } = await import('../tools/_post')) +}) + +describe('/api/v1/composio', () => { + const mockDb = {} as any + let logger: Logger + let loggerWithContext: LoggerWithContextFn + let getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + + beforeEach(() => { + logger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } + loggerWithContext = mock(() => logger) + getUserInfoFromApiKey = mock(async ({ apiKey }) => { + if (apiKey !== 'valid-key') return null + return { + id: 'user-123', + email: 'user@example.com', + discord_id: null, + } as Awaited> + }) + }) + + afterEach(() => { + mock.restore() + }) + + test('lists Composio tools for an authenticated user', async () => { + const getToolsForUser = mock(async () => ({ + sessionId: 'session-123', + tools: [ + { + toolName: 'COMPOSIO_SEARCH_TOOLS', + inputSchema: { type: 'object', properties: {} }, + description: 'Search Composio tools', + }, + ], + })) + const checkRateLimit = mock(() => ({ limited: false as const })) + const req = new NextRequest('http://localhost/api/v1/composio/tools', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + }) + + const response = await postComposioTools({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + getToolsForUser, + checkRateLimit, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + sessionId: 'session-123', + tools: [ + { + toolName: 'COMPOSIO_SEARCH_TOOLS', + inputSchema: { type: 'object', properties: {} }, + description: 'Search Composio tools', + }, + ], + }) + expect(getToolsForUser).toHaveBeenCalledTimes(1) + expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'tools') + }) + + test('rate limits Composio tool listing', async () => { + const getToolsForUser = mock(async () => ({ + sessionId: 'session-123', + tools: [], + })) + const checkRateLimit = mock(() => ({ + limited: true as const, + retryAfterMs: 12_500, + windowName: '1 minute', + })) + const req = new NextRequest('http://localhost/api/v1/composio/tools', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + }) + + const response = await postComposioTools({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + getToolsForUser, + checkRateLimit, + }) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('13') + expect(await response.json()).toEqual({ + error: 'Rate limited', + retryAfterSeconds: 13, + }) + expect(getToolsForUser).not.toHaveBeenCalled() + }) + + test('executes a Composio tool for an authenticated user', async () => { + const executeTool = mock(async () => [ + { type: 'json' as const, value: { ok: true } }, + ]) + const checkRateLimit = mock(() => ({ limited: false as const })) + const req = new NextRequest('http://localhost/api/v1/composio/execute', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { query: 'gmail' }, + }), + }) + + const response = await postComposioExecute({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + executeTool, + checkRateLimit, + isConfigured: () => true, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + output: [{ type: 'json', value: { ok: true } }], + }) + expect(executeTool).toHaveBeenCalledWith({ + db: mockDb, + userId: 'user-123', + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { query: 'gmail' }, + }) + expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'execute') + }) + + test('returns 404 when a Composio session cannot be found for execute', async () => { + const executeTool = mock(async () => null) + const req = new NextRequest('http://localhost/api/v1/composio/execute', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + sessionId: 'unknown-session', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: {}, + }), + }) + + const response = await postComposioExecute({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + executeTool, + checkRateLimit: mock(() => ({ limited: false as const })), + isConfigured: () => true, + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: 'Composio session not found', + }) + }) + + test('returns 503 when Composio execute is not configured', async () => { + const executeTool = mock(async () => [ + { type: 'json' as const, value: { ok: true } }, + ]) + const req = new NextRequest('http://localhost/api/v1/composio/execute', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: {}, + }), + }) + + const response = await postComposioExecute({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + executeTool, + checkRateLimit: mock(() => ({ limited: false as const })), + isConfigured: () => false, + }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'Composio is not configured', + }) + expect(executeTool).not.toHaveBeenCalled() + }) + + test('rate limits Composio execute requests', async () => { + const executeTool = mock(async () => [ + { type: 'json' as const, value: { ok: true } }, + ]) + const req = new NextRequest('http://localhost/api/v1/composio/execute', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: {}, + }), + }) + + const response = await postComposioExecute({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + executeTool, + checkRateLimit: mock(() => ({ + limited: true as const, + retryAfterMs: 1_000, + windowName: '1 minute', + })), + isConfigured: () => true, + }) + + expect(response.status).toBe(429) + expect(await response.json()).toEqual({ + error: 'Rate limited', + retryAfterSeconds: 1, + }) + expect(executeTool).not.toHaveBeenCalled() + }) + + test('rejects unauthenticated Composio requests', async () => { + const req = new NextRequest('http://localhost/api/v1/composio/tools', { + method: 'POST', + }) + + const response = await postComposioTools({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + getToolsForUser: mock(async () => ({ + sessionId: 'session-123', + tools: [], + })), + checkRateLimit: mock(() => ({ limited: false as const })), + }) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: 'Missing or invalid Authorization header', + }) + }) +}) diff --git a/web/src/app/api/v1/composio/_auth.ts b/web/src/app/api/v1/composio/_auth.ts new file mode 100644 index 0000000000..a65ae343b6 --- /dev/null +++ b/web/src/app/api/v1/composio/_auth.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server' + +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +import { extractApiKeyFromHeader } from '@/util/auth' + +type ComposioUser = { + id: string + email: string + discord_id: string | null +} + +export async function requireComposioUser(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn +}): Promise< + | { ok: true; userInfo: ComposioUser; logger: Logger } + | { ok: false; response: NextResponse } +> { + const { req, getUserInfoFromApiKey, logger, loggerWithContext } = params + + const apiKey = extractApiKeyFromHeader(req) + if (!apiKey) { + return { + ok: false, + response: NextResponse.json( + { error: 'Missing or invalid Authorization header' }, + { status: 401 }, + ), + } + } + + const userInfo = await getUserInfoFromApiKey({ + apiKey, + fields: ['id', 'email', 'discord_id'], + logger, + }) + if (!userInfo) { + return { + ok: false, + response: NextResponse.json( + { error: 'Invalid API key or user not found' }, + { status: 401 }, + ), + } + } + + return { + ok: true, + userInfo, + logger: loggerWithContext({ userInfo }), + } +} diff --git a/web/src/app/api/v1/composio/execute/_post.ts b/web/src/app/api/v1/composio/execute/_post.ts new file mode 100644 index 0000000000..8b6939eda5 --- /dev/null +++ b/web/src/app/api/v1/composio/execute/_post.ts @@ -0,0 +1,130 @@ +import { getErrorObject } from '@codebuff/common/util/error' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' +import type { NextRequest } from 'next/server' + +import { executeComposioTool, isComposioConfigured } from '@/server/composio' +import { checkComposioRateLimit } from '@/server/composio-rate-limiter' + +import { requireComposioUser } from '../_auth' + +type ExecuteComposioToolFn = typeof executeComposioTool +type CheckComposioRateLimitFn = typeof checkComposioRateLimit +type IsComposioConfiguredFn = typeof isComposioConfigured + +const composioExecuteBodySchema = z.object({ + sessionId: z.string().min(1), + toolName: z.string().min(1), + input: z.record(z.string(), z.unknown()).default({}), +}) + +export async function postComposioExecute(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + db: CodebuffPgDatabase + logger: Logger + loggerWithContext: LoggerWithContextFn + executeTool?: ExecuteComposioToolFn + checkRateLimit?: CheckComposioRateLimitFn + isConfigured?: IsComposioConfiguredFn +}) { + const { + db, + executeTool = executeComposioTool, + checkRateLimit = checkComposioRateLimit, + isConfigured = isComposioConfigured, + } = params + const auth = await requireComposioUser(params) + if (!auth.ok) return auth.response + const { userInfo, logger } = auth + const { req } = params + + if (!isConfigured()) { + return NextResponse.json( + { error: 'Composio is not configured' }, + { status: 503 }, + ) + } + + const rateLimit = checkRateLimit(userInfo.id, 'execute') + if (rateLimit.limited) { + const retryAfterSeconds = Math.ceil(rateLimit.retryAfterMs / 1000) + logger.warn( + { + userId: userInfo.id, + retryAfterSeconds, + windowName: rateLimit.windowName, + }, + 'Rate limited Composio execute request', + ) + return NextResponse.json( + { error: 'Rate limited', retryAfterSeconds }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds) }, + }, + ) + } + + let json: unknown + try { + json = await req.json() + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body' }, + { status: 400 }, + ) + } + + const parsed = composioExecuteBodySchema.safeParse(json) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + + try { + logger.info( + { userId: userInfo.id, toolName: parsed.data.toolName }, + 'Executing Composio tool', + ) + const output = await executeTool({ + db, + userId: userInfo.id, + ...parsed.data, + }) + if (!output) { + return NextResponse.json( + { error: 'Composio session not found' }, + { status: 404 }, + ) + } + + logger.info( + { + userId: userInfo.id, + toolName: parsed.data.toolName, + outputCount: output.length, + }, + 'Executed Composio tool', + ) + return NextResponse.json({ output }) + } catch (error) { + logger.error( + { error: getErrorObject(error), userId: userInfo.id }, + 'Failed to execute Composio tool', + ) + return NextResponse.json( + { error: 'Failed to execute Composio tool' }, + { status: 502 }, + ) + } +} diff --git a/web/src/app/api/v1/composio/execute/route.ts b/web/src/app/api/v1/composio/execute/route.ts new file mode 100644 index 0000000000..ab64001d2b --- /dev/null +++ b/web/src/app/api/v1/composio/execute/route.ts @@ -0,0 +1,18 @@ +import db from '@codebuff/internal/db' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +import { postComposioExecute } from './_post' + +import type { NextRequest } from 'next/server' + +export async function POST(req: NextRequest) { + return postComposioExecute({ + req, + getUserInfoFromApiKey, + db, + logger, + loggerWithContext, + }) +} diff --git a/web/src/app/api/v1/composio/tools/_post.ts b/web/src/app/api/v1/composio/tools/_post.ts new file mode 100644 index 0000000000..4068be61b3 --- /dev/null +++ b/web/src/app/api/v1/composio/tools/_post.ts @@ -0,0 +1,86 @@ +import { getErrorObject } from '@codebuff/common/util/error' +import { NextResponse } from 'next/server' + +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' +import type { NextRequest } from 'next/server' + +import { getComposioToolsForUser } from '@/server/composio' +import { checkComposioRateLimit } from '@/server/composio-rate-limiter' + +import { requireComposioUser } from '../_auth' + +type GetComposioToolsForUserFn = typeof getComposioToolsForUser +type CheckComposioRateLimitFn = typeof checkComposioRateLimit + +export async function postComposioTools(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + db: CodebuffPgDatabase + logger: Logger + loggerWithContext: LoggerWithContextFn + getToolsForUser?: GetComposioToolsForUserFn + checkRateLimit?: CheckComposioRateLimitFn +}) { + const { + db, + getToolsForUser = getComposioToolsForUser, + checkRateLimit = checkComposioRateLimit, + } = params + const auth = await requireComposioUser(params) + if (!auth.ok) return auth.response + const { userInfo, logger } = auth + + const rateLimit = checkRateLimit(userInfo.id, 'tools') + if (rateLimit.limited) { + const retryAfterSeconds = Math.ceil(rateLimit.retryAfterMs / 1000) + logger.warn( + { + userId: userInfo.id, + retryAfterSeconds, + windowName: rateLimit.windowName, + }, + 'Rate limited Composio tools request', + ) + return NextResponse.json( + { error: 'Rate limited', retryAfterSeconds }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds) }, + }, + ) + } + + try { + const tools = await getToolsForUser({ + db, + userId: userInfo.id, + logger, + }) + if (!tools) { + return NextResponse.json( + { error: 'Composio is not configured' }, + { status: 503 }, + ) + } + + logger.info( + { userId: userInfo.id, toolCount: tools.tools.length }, + 'Loaded Composio tools', + ) + return NextResponse.json(tools) + } catch (error) { + logger.error( + { error: getErrorObject(error), userId: userInfo.id }, + 'Failed to load Composio tools', + ) + return NextResponse.json( + { error: 'Failed to load Composio tools' }, + { status: 502 }, + ) + } +} diff --git a/web/src/app/api/v1/composio/tools/route.ts b/web/src/app/api/v1/composio/tools/route.ts new file mode 100644 index 0000000000..c0fcb6e723 --- /dev/null +++ b/web/src/app/api/v1/composio/tools/route.ts @@ -0,0 +1,18 @@ +import db from '@codebuff/internal/db' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +import { postComposioTools } from './_post' + +import type { NextRequest } from 'next/server' + +export async function POST(req: NextRequest) { + return postComposioTools({ + req, + getUserInfoFromApiKey, + db, + logger, + loggerWithContext, + }) +} diff --git a/web/src/server/__tests__/composio-rate-limiter.test.ts b/web/src/server/__tests__/composio-rate-limiter.test.ts new file mode 100644 index 0000000000..5de7b23058 --- /dev/null +++ b/web/src/server/__tests__/composio-rate-limiter.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test } from 'bun:test' + +import { + checkComposioRateLimit, + resetComposioRateLimits, +} from '../composio-rate-limiter' + +describe('checkComposioRateLimit', () => { + beforeEach(() => { + resetComposioRateLimits() + }) + + test('allows requests below the per-minute limit', () => { + for (let i = 0; i < 30; i++) { + expect(checkComposioRateLimit('user-1', 'tools')).toEqual({ + limited: false, + }) + } + }) + + test('limits tool listing after the per-minute limit', () => { + for (let i = 0; i < 30; i++) { + checkComposioRateLimit('user-1', 'tools') + } + + const result = checkComposioRateLimit('user-1', 'tools') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('1 minute') + expect(result.retryAfterMs).toBeGreaterThan(0) + } + }) + + test('tracks execute and tools limits independently', () => { + for (let i = 0; i < 30; i++) { + checkComposioRateLimit('user-1', 'tools') + } + + expect(checkComposioRateLimit('user-1', 'tools').limited).toBe(true) + expect(checkComposioRateLimit('user-1', 'execute')).toEqual({ + limited: false, + }) + }) +}) diff --git a/web/src/server/composio-rate-limiter.ts b/web/src/server/composio-rate-limiter.ts new file mode 100644 index 0000000000..8b9174e539 --- /dev/null +++ b/web/src/server/composio-rate-limiter.ts @@ -0,0 +1,116 @@ +const SECOND_MS = 1000 +const MINUTE_MS = 60 * SECOND_MS +const HOUR_MS = 60 * MINUTE_MS + +export type ComposioRateLimitAction = 'tools' | 'execute' + +export type ComposioRateLimitResult = + | { limited: false } + | { limited: true; retryAfterMs: number; windowName: string } + +type RateWindow = { + name: string + windowMs: number + maxRequests: number +} + +type WindowTracker = { + count: number + windowStart: number +} + +const RATE_WINDOWS_BY_ACTION: Record = { + tools: [ + { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 30 }, + { name: '1 hour', windowMs: HOUR_MS, maxRequests: 300 }, + ], + execute: [ + { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 120 }, + { name: '1 hour', windowMs: HOUR_MS, maxRequests: 1_000 }, + ], +} + +const userWindows = new Map>() +let lastCleanupTime = 0 +const CLEANUP_INTERVAL_MS = 5 * MINUTE_MS + +function getRateLimitKey(userId: string, action: ComposioRateLimitAction) { + return `${userId}:${action}` +} + +function cleanupExpiredEntries(): void { + const now = Date.now() + for (const [key, windows] of userWindows) { + const action = key.split(':').at(-1) as ComposioRateLimitAction | undefined + for (const [windowName, tracker] of windows) { + const matchingWindow = + action && + RATE_WINDOWS_BY_ACTION[action]?.find((w) => w.name === windowName) + if ( + !matchingWindow || + now - tracker.windowStart >= matchingWindow.windowMs + ) { + windows.delete(windowName) + } + } + if (windows.size === 0) { + userWindows.delete(key) + } + } +} + +export function checkComposioRateLimit( + userId: string, + action: ComposioRateLimitAction, +): ComposioRateLimitResult { + const now = Date.now() + if (now - lastCleanupTime > CLEANUP_INTERVAL_MS) { + cleanupExpiredEntries() + lastCleanupTime = now + } + + const windowsForAction = RATE_WINDOWS_BY_ACTION[action] + const key = getRateLimitKey(userId, action) + let windows = userWindows.get(key) + if (!windows) { + windows = new Map() + userWindows.set(key, windows) + } + + // First pass checks every window without mutating counters. + for (const rateWindow of windowsForAction) { + let tracker = windows.get(rateWindow.name) + if (tracker && now - tracker.windowStart >= rateWindow.windowMs) { + windows.delete(rateWindow.name) + tracker = undefined + } + + if ((tracker?.count ?? 0) >= rateWindow.maxRequests) { + return { + limited: true, + windowName: rateWindow.name, + retryAfterMs: Math.max( + 0, + rateWindow.windowMs - (now - tracker!.windowStart), + ), + } + } + } + + // Second pass increments only allowed requests. + for (const rateWindow of windowsForAction) { + let tracker = windows.get(rateWindow.name) + if (!tracker) { + tracker = { count: 0, windowStart: now } + windows.set(rateWindow.name, tracker) + } + tracker.count++ + } + + return { limited: false } +} + +export function resetComposioRateLimits(): void { + userWindows.clear() + lastCleanupTime = 0 +} diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts new file mode 100644 index 0000000000..9f9368339a --- /dev/null +++ b/web/src/server/composio.ts @@ -0,0 +1,345 @@ +import 'server-only' + +import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import path from 'path' + +import { + COMPOSIO_API_KEY_ENV_VAR, + COMPOSIO_META_TOOL_NAMES, +} from '@codebuff/common/constants/composio' +import { getErrorObject } from '@codebuff/common/util/error' +import { env } from '@codebuff/internal/env' +import * as schema from '@codebuff/internal/db/schema' +import { Composio, type Tool } from '@composio/core' +import { and, eq } from 'drizzle-orm' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { JSONValue } from '@codebuff/common/types/json' +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' +import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' + +const COMPOSIO_HOME_ENV_PATH = path.join(homedir(), 'codebuff', '.env.local') +const allowedToolNames = new Set(COMPOSIO_META_TOOL_NAMES) + +type ComposioSession = Awaited> +type ComposioClient = Composio + +export type ComposioToolDefinition = { + toolName: string + inputSchema: Record + description: string +} + +type CachedComposioSession = { + userId: string + sessionId: string + session: ComposioSession + tools: ComposioToolDefinition[] +} + +const sessionsByUserId = new Map>() +const sessionsBySessionId = new Map() + +function parseEnvFileValue(contents: string, key: string): string | undefined { + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/) + if (!match || match[1] !== key) continue + + const value = match[2].trim() + return value.replace(/^(['"])(.*)\1$/, '$2') + } + return undefined +} + +function getComposioApiKey(): string | undefined { + const configuredApiKey = env.COMPOSIO_API_KEY?.trim() + if (configuredApiKey) return configuredApiKey + + if (!existsSync(COMPOSIO_HOME_ENV_PATH)) return undefined + + try { + return parseEnvFileValue( + readFileSync(COMPOSIO_HOME_ENV_PATH, 'utf8'), + COMPOSIO_API_KEY_ENV_VAR, + )?.trim() + } catch { + return undefined + } +} + +export function isComposioConfigured(): boolean { + return !!getComposioApiKey() +} + +function toJsonValue(value: unknown): JSONValue { + try { + return JSON.parse(JSON.stringify(value ?? null)) as JSONValue + } catch { + return String(value) as JSONValue + } +} + +function normalizeInputSchema(schema: Tool['inputParameters']) { + if (schema && typeof schema === 'object') { + const jsonSchema = JSON.parse(JSON.stringify(schema)) as Record< + string, + unknown + > + delete jsonSchema['$schema'] + return jsonSchema + } + + return { + type: 'object', + properties: {}, + additionalProperties: true, + } satisfies Record +} + +function getComposioClient(apiKey: string): ComposioClient { + return new Composio({ + apiKey, + host: 'codebuff', + }) +} + +async function getToolDefinitionsForSession(params: { + composio: ComposioClient + sessionId: string +}): Promise { + const rawTools = await params.composio.tools.getRawToolRouterSessionTools( + params.sessionId, + ) + + return rawTools + .filter((tool) => allowedToolNames.has(tool.slug)) + .map((tool) => ({ + toolName: tool.slug, + inputSchema: normalizeInputSchema(tool.inputParameters), + description: tool.description ?? `Execute ${tool.slug} with Composio.`, + })) +} + +async function saveSession(params: { + db: CodebuffPgDatabase + userId: string + sessionId: string +}) { + await params.db + .insert(schema.composioSession) + .values({ + user_id: params.userId, + session_id: params.sessionId, + }) + .onConflictDoUpdate({ + target: schema.composioSession.user_id, + set: { + session_id: params.sessionId, + updated_at: new Date(), + }, + }) +} + +async function getStoredSessionByUser(params: { + db: CodebuffPgDatabase + userId: string +}) { + return params.db.query.composioSession.findFirst({ + where: eq(schema.composioSession.user_id, params.userId), + }) +} + +async function getStoredSessionById(params: { + db: CodebuffPgDatabase + userId: string + sessionId: string +}) { + return params.db.query.composioSession.findFirst({ + where: and( + eq(schema.composioSession.user_id, params.userId), + eq(schema.composioSession.session_id, params.sessionId), + ), + }) +} + +async function createSessionForUser(params: { + db: CodebuffPgDatabase + userId: string + apiKey: string +}): Promise { + const composio = getComposioClient(params.apiKey) + const session = await composio.create(params.userId) + await saveSession({ + db: params.db, + userId: params.userId, + sessionId: session.sessionId, + }) + + const cachedSession: CachedComposioSession = { + userId: params.userId, + sessionId: session.sessionId, + session, + tools: await getToolDefinitionsForSession({ + composio, + sessionId: session.sessionId, + }), + } + + sessionsBySessionId.set(cachedSession.sessionId, cachedSession) + return cachedSession +} + +async function rehydrateSession(params: { + userId: string + sessionId: string + apiKey: string + includeTools: boolean +}): Promise { + const composio = getComposioClient(params.apiKey) + const session = await composio.use(params.sessionId) + const cachedSession: CachedComposioSession = { + userId: params.userId, + sessionId: params.sessionId, + session, + tools: params.includeTools + ? await getToolDefinitionsForSession({ + composio, + sessionId: params.sessionId, + }) + : [], + } + + if (params.includeTools) { + sessionsByUserId.set(params.userId, Promise.resolve(cachedSession)) + } + sessionsBySessionId.set(params.sessionId, cachedSession) + return cachedSession +} + +async function getCachedSession(params: { + db: CodebuffPgDatabase + userId: string + logger: Logger +}): Promise { + const apiKey = getComposioApiKey() + if (!apiKey) return null + + let cached = sessionsByUserId.get(params.userId) + if (!cached) { + cached = (async () => { + const storedSession = await getStoredSessionByUser({ + db: params.db, + userId: params.userId, + }) + if (storedSession) { + params.logger.info( + { userId: params.userId }, + 'Rehydrating Composio session from database', + ) + return rehydrateSession({ + userId: params.userId, + sessionId: storedSession.session_id, + apiKey, + includeTools: true, + }) + } + + params.logger.info( + { userId: params.userId }, + 'Creating new Composio session', + ) + return createSessionForUser({ + db: params.db, + userId: params.userId, + apiKey, + }) + })() + sessionsByUserId.set(params.userId, cached) + } + + try { + return await cached + } catch (error) { + sessionsByUserId.delete(params.userId) + params.logger.error( + { error: getErrorObject(error), userId: params.userId }, + 'Failed to initialize Composio session', + ) + throw error + } +} + +export async function getComposioToolsForUser(params: { + db: CodebuffPgDatabase + userId: string + logger: Logger +}): Promise<{ sessionId: string; tools: ComposioToolDefinition[] } | null> { + const cached = await getCachedSession(params) + if (!cached) return null + + return { + sessionId: cached.sessionId, + tools: cached.tools, + } +} + +export async function executeComposioTool(params: { + db: CodebuffPgDatabase + userId: string + sessionId: string + toolName: string + input: Record +}): Promise { + if (!allowedToolNames.has(params.toolName)) { + return [ + { + type: 'json', + value: { + errorMessage: `Unsupported Composio tool: ${params.toolName}`, + }, + }, + ] + } + + const apiKey = getComposioApiKey() + if (!apiKey) return null + + let cached = sessionsBySessionId.get(params.sessionId) + if (!cached) { + const storedSession = await getStoredSessionById({ + db: params.db, + userId: params.userId, + sessionId: params.sessionId, + }) + if (storedSession) { + cached = await rehydrateSession({ + userId: params.userId, + sessionId: params.sessionId, + apiKey, + includeTools: false, + }) + } + } + + if (!cached || cached.userId !== params.userId) { + return null + } + + try { + const result = await cached.session.execute(params.toolName, params.input) + return [{ type: 'json', value: toJsonValue(result) }] + } catch (error) { + return [ + { + type: 'json', + value: { + errorMessage: error instanceof Error ? error.message : String(error), + }, + }, + ] + } +} From 9568ffa590dc94a715e1ab5e2ef504f8b26ddea0 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 23 May 2026 15:36:04 -0700 Subject: [PATCH 02/15] Address Composio review feedback --- .../db/migrations/0056_simple_tyrannus.sql | 2 +- packages/internal/src/db/schema.ts | 27 +++--- sdk/src/run.ts | 3 +- web/src/server/composio.ts | 93 +++++++------------ 4 files changed, 49 insertions(+), 76 deletions(-) diff --git a/packages/internal/src/db/migrations/0056_simple_tyrannus.sql b/packages/internal/src/db/migrations/0056_simple_tyrannus.sql index 48cf0229cf..e63baa377e 100644 --- a/packages/internal/src/db/migrations/0056_simple_tyrannus.sql +++ b/packages/internal/src/db/migrations/0056_simple_tyrannus.sql @@ -6,4 +6,4 @@ CREATE TABLE "composio_session" ( CONSTRAINT "composio_session_session_id_unique" UNIQUE("session_id") ); --> statement-breakpoint -ALTER TABLE "composio_session" ADD CONSTRAINT "composio_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file +ALTER TABLE "composio_session" ADD CONSTRAINT "composio_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index de46a11edb..ef93b70f57 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -305,21 +305,18 @@ export const encryptedApiKeys = pgTable( }), ) -export const composioSession = pgTable( - 'composio_session', - { - user_id: text('user_id') - .primaryKey() - .references(() => user.id, { onDelete: 'cascade' }), - session_id: text('session_id').notNull().unique(), - created_at: timestamp('created_at', { mode: 'date', withTimezone: true }) - .notNull() - .defaultNow(), - updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }) - .notNull() - .defaultNow(), - }, -) +export const composioSession = pgTable('composio_session', { + user_id: text('user_id') + .primaryKey() + .references(() => user.id, { onDelete: 'cascade' }), + session_id: text('session_id').notNull().unique(), + created_at: timestamp('created_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), +}) // Organization tables export const orgRoleEnum = pgEnum('org_role', ['owner', 'admin', 'member']) diff --git a/sdk/src/run.ts b/sdk/src/run.ts index d4c455959e..44ad3cafc9 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -295,7 +295,7 @@ async function runOnce({ // Comparing array identity detects progress more robustly than length: // context pruning could shrink history below its starting length without // meaning the runtime never ran. - const initialMessageHistory = sessionState.mainAgentState.messageHistory + let initialMessageHistory = sessionState.mainAgentState.messageHistory /** Calculates the current session state if cancelled. * @@ -533,6 +533,7 @@ async function runOnce({ sessionState = await applyOverridesToSessionState(cwd, sessionState, { customToolDefinitions: activeCustomToolDefinitions, }) + initialMessageHistory = sessionState.mainAgentState.messageHistory } if (signal?.aborted) { diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 9f9368339a..21d501e746 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -38,9 +38,6 @@ type CachedComposioSession = { tools: ComposioToolDefinition[] } -const sessionsByUserId = new Map>() -const sessionsBySessionId = new Map() - function parseEnvFileValue(contents: string, key: string): string | undefined { for (const rawLine of contents.split(/\r?\n/)) { const line = rawLine.trim() @@ -188,8 +185,6 @@ async function createSessionForUser(params: { sessionId: session.sessionId, }), } - - sessionsBySessionId.set(cachedSession.sessionId, cachedSession) return cachedSession } @@ -212,15 +207,10 @@ async function rehydrateSession(params: { }) : [], } - - if (params.includeTools) { - sessionsByUserId.set(params.userId, Promise.resolve(cachedSession)) - } - sessionsBySessionId.set(params.sessionId, cachedSession) return cachedSession } -async function getCachedSession(params: { +async function getSessionForUser(params: { db: CodebuffPgDatabase userId: string logger: Logger @@ -228,43 +218,34 @@ async function getCachedSession(params: { const apiKey = getComposioApiKey() if (!apiKey) return null - let cached = sessionsByUserId.get(params.userId) - if (!cached) { - cached = (async () => { - const storedSession = await getStoredSessionByUser({ - db: params.db, - userId: params.userId, - }) - if (storedSession) { - params.logger.info( - { userId: params.userId }, - 'Rehydrating Composio session from database', - ) - return rehydrateSession({ - userId: params.userId, - sessionId: storedSession.session_id, - apiKey, - includeTools: true, - }) - } - + try { + const storedSession = await getStoredSessionByUser({ + db: params.db, + userId: params.userId, + }) + if (storedSession) { params.logger.info( { userId: params.userId }, - 'Creating new Composio session', + 'Rehydrating Composio session from database', ) - return createSessionForUser({ - db: params.db, + return rehydrateSession({ userId: params.userId, + sessionId: storedSession.session_id, apiKey, + includeTools: true, }) - })() - sessionsByUserId.set(params.userId, cached) - } + } - try { - return await cached + params.logger.info( + { userId: params.userId }, + 'Creating new Composio session', + ) + return createSessionForUser({ + db: params.db, + userId: params.userId, + apiKey, + }) } catch (error) { - sessionsByUserId.delete(params.userId) params.logger.error( { error: getErrorObject(error), userId: params.userId }, 'Failed to initialize Composio session', @@ -278,7 +259,7 @@ export async function getComposioToolsForUser(params: { userId: string logger: Logger }): Promise<{ sessionId: string; tools: ComposioToolDefinition[] } | null> { - const cached = await getCachedSession(params) + const cached = await getSessionForUser(params) if (!cached) return null return { @@ -308,28 +289,22 @@ export async function executeComposioTool(params: { const apiKey = getComposioApiKey() if (!apiKey) return null - let cached = sessionsBySessionId.get(params.sessionId) - if (!cached) { - const storedSession = await getStoredSessionById({ - db: params.db, - userId: params.userId, - sessionId: params.sessionId, - }) - if (storedSession) { - cached = await rehydrateSession({ - userId: params.userId, - sessionId: params.sessionId, - apiKey, - includeTools: false, - }) - } - } - - if (!cached || cached.userId !== params.userId) { + const storedSession = await getStoredSessionById({ + db: params.db, + userId: params.userId, + sessionId: params.sessionId, + }) + if (!storedSession) { return null } try { + const cached = await rehydrateSession({ + userId: params.userId, + sessionId: params.sessionId, + apiKey, + includeTools: false, + }) const result = await cached.session.execute(params.toolName, params.input) return [{ type: 'json', value: toJsonValue(result) }] } catch (error) { From 0fb42de9b3dcd6f53b67b71be2e26c8e206b7f6e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 23 May 2026 16:23:55 -0700 Subject: [PATCH 03/15] Fix Composio discovery and auth checks --- sdk/src/composio.ts | 110 +++++++++++++++++- sdk/src/run.ts | 5 + .../v1/composio/__tests__/composio.test.ts | 38 ++++++ web/src/app/api/v1/composio/_auth.ts | 17 ++- 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index ed43f9c218..80f329fa53 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -18,6 +18,16 @@ type ComposioExecuteResponse = { output: ToolResultOutput[] } +const COMPOSIO_DISCOVERY_TIMEOUT_MS = 750 +const COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS = 5 * 60 * 1000 +const COMPOSIO_DISCOVERY_FAILURE_CACHE_MS = 60 * 1000 +const COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES = 64 + +const composioToolDefinitionsCache = new Map< + string, + { expiresAt: number; tools: CustomToolDefinition[] } +>() + function toJsonValue(value: unknown): JSONValue { try { return JSON.parse(JSON.stringify(value ?? null)) as JSONValue @@ -26,6 +36,70 @@ function toJsonValue(value: unknown): JSONValue { } } +function createTimeoutSignal( + parentSignal: AbortSignal | undefined, + timeoutMs: number, +) { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort(new Error('Composio discovery timed out')) + }, timeoutMs) + const onAbort = () => controller.abort(parentSignal?.reason) + + if (parentSignal?.aborted) { + onAbort() + } else { + parentSignal?.addEventListener('abort', onAbort, { once: true }) + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout) + parentSignal?.removeEventListener('abort', onAbort) + }, + } +} + +function getCachedComposioTools(apiKey: string): CustomToolDefinition[] | null { + const cached = composioToolDefinitionsCache.get(apiKey) + if (!cached) return null + if (Date.now() >= cached.expiresAt) { + composioToolDefinitionsCache.delete(apiKey) + return null + } + return cached.tools +} + +function pruneComposioToolsCache(now: number) { + for (const [apiKey, cached] of composioToolDefinitionsCache) { + if (now >= cached.expiresAt) { + composioToolDefinitionsCache.delete(apiKey) + } + } + + while ( + composioToolDefinitionsCache.size >= COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES + ) { + const oldest = composioToolDefinitionsCache.keys().next() + if (oldest.done) break + composioToolDefinitionsCache.delete(oldest.value) + } +} + +function cacheComposioTools( + apiKey: string, + tools: CustomToolDefinition[], + ttlMs: number, +) { + const now = Date.now() + pruneComposioToolsCache(now) + composioToolDefinitionsCache.set(apiKey, { + tools, + expiresAt: now + ttlMs, + }) +} + async function readErrorMessage(response: Response): Promise { try { const body = (await response.json()) as { @@ -90,7 +164,17 @@ async function executeComposioToolViaServer(params: { export async function getComposioCustomToolDefinitions(params: { apiKey: string logger?: Pick + signal?: AbortSignal }): Promise { + const cachedTools = getCachedComposioTools(params.apiKey) + if (cachedTools) return cachedTools + if (params.signal?.aborted) return [] + + const discoverySignal = createTimeoutSignal( + params.signal, + COMPOSIO_DISCOVERY_TIMEOUT_MS, + ) + let response: Response try { response = await fetch(new URL('/api/v1/composio/tools', WEBSITE_URL), { @@ -98,16 +182,23 @@ export async function getComposioCustomToolDefinitions(params: { headers: { Authorization: `Bearer ${params.apiKey}`, }, + signal: discoverySignal.signal, }) } catch (error) { - params.logger?.warn( - { error: error instanceof Error ? error.message : String(error) }, - 'Failed to fetch Composio tools', - ) + if (!params.signal?.aborted) { + cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) + params.logger?.warn( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to fetch Composio tools', + ) + } return [] + } finally { + discoverySignal.cleanup() } if (!response.ok) { + cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) if (response.status !== 503) { params.logger?.warn( { status: response.status, error: await readErrorMessage(response) }, @@ -119,13 +210,13 @@ export async function getComposioCustomToolDefinitions(params: { try { const body = (await response.json()) as ComposioToolsResponse - return body.tools.map((tool) => ({ + const tools = body.tools.map((tool) => ({ toolName: tool.toolName, inputSchema: tool.inputSchema, description: tool.description, endsAgentStep: true, exampleInputs: [], - execute: async (input) => { + execute: async (input: unknown) => { return executeComposioToolViaServer({ apiKey: params.apiKey, sessionId: body.sessionId, @@ -137,7 +228,14 @@ export async function getComposioCustomToolDefinitions(params: { }) }, })) + cacheComposioTools( + params.apiKey, + tools, + COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS, + ) + return tools } catch (error) { + cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) params.logger?.warn( { error: error instanceof Error ? error.message : String(error) }, 'Failed to parse Composio tools response', diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 44ad3cafc9..230d141516 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -516,9 +516,14 @@ async function runOnce({ } const userId = userInfo.id + if (signal?.aborted) { + return getCancelledRunState('Run cancelled by user.') + } + const composioCustomToolDefinitions = await getComposioCustomToolDefinitions({ apiKey, logger, + signal, }) for (const toolName of COMPOSIO_META_TOOL_NAMES) { diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index dc613fe038..b52fc584ed 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -41,11 +41,20 @@ describe('/api/v1/composio', () => { } loggerWithContext = mock(() => logger) getUserInfoFromApiKey = mock(async ({ apiKey }) => { + if (apiKey === 'banned-key') { + return { + id: 'banned-user', + email: 'banned@example.com', + discord_id: null, + banned: true, + } as Awaited> + } if (apiKey !== 'valid-key') return null return { id: 'user-123', email: 'user@example.com', discord_id: null, + banned: false, } as Awaited> }) }) @@ -291,4 +300,33 @@ describe('/api/v1/composio', () => { error: 'Missing or invalid Authorization header', }) }) + + test('rejects suspended users before rate limiting or tool lookup', async () => { + const getToolsForUser = mock(async () => ({ + sessionId: 'session-123', + tools: [], + })) + const checkRateLimit = mock(() => ({ limited: false as const })) + const req = new NextRequest('http://localhost/api/v1/composio/tools', { + method: 'POST', + headers: { Authorization: 'Bearer banned-key' }, + }) + + const response = await postComposioTools({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + getToolsForUser, + checkRateLimit, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('account_suspended') + expect(body.message).toContain('Your account has been suspended') + expect(getToolsForUser).not.toHaveBeenCalled() + expect(checkRateLimit).not.toHaveBeenCalled() + }) }) diff --git a/web/src/app/api/v1/composio/_auth.ts b/web/src/app/api/v1/composio/_auth.ts index a65ae343b6..9150cab7a9 100644 --- a/web/src/app/api/v1/composio/_auth.ts +++ b/web/src/app/api/v1/composio/_auth.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { env } from '@codebuff/internal/env' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { @@ -13,6 +14,7 @@ type ComposioUser = { id: string email: string discord_id: string | null + banned: boolean } export async function requireComposioUser(params: { @@ -39,7 +41,7 @@ export async function requireComposioUser(params: { const userInfo = await getUserInfoFromApiKey({ apiKey, - fields: ['id', 'email', 'discord_id'], + fields: ['id', 'email', 'discord_id', 'banned'], logger, }) if (!userInfo) { @@ -52,6 +54,19 @@ export async function requireComposioUser(params: { } } + if (userInfo.banned) { + return { + ok: false, + response: NextResponse.json( + { + error: 'account_suspended', + message: `Your account has been suspended. Please contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} if you did not expect this.`, + }, + { status: 403 }, + ), + } + } + return { ok: true, userInfo, From b62d6ee0d2c7210d5407b6a770c8640a28206c2a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 00:03:11 -0700 Subject: [PATCH 04/15] Recover invalid Composio sessions --- web/src/server/__tests__/composio.test.ts | 137 ++++++++++++++++++++++ web/src/server/composio.ts | 89 +++++++++++++- 2 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 web/src/server/__tests__/composio.test.ts diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts new file mode 100644 index 0000000000..164d9c462c --- /dev/null +++ b/web/src/server/__tests__/composio.test.ts @@ -0,0 +1,137 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { getComposioToolsForUser as GetComposioToolsForUser } from '../composio' + +let getComposioToolsForUser: typeof GetComposioToolsForUser + +let createSession: ReturnType +let useSession: ReturnType +let getRawToolRouterSessionTools: ReturnType + +beforeAll(async () => { + mock.module('server-only', () => ({})) + mock.module('@codebuff/internal/env', () => ({ + env: { COMPOSIO_API_KEY: 'test-composio-api-key' }, + })) + mock.module('@composio/core', () => ({ + Composio: class { + tools = { + getRawToolRouterSessionTools, + } + + create = createSession + use = useSession + }, + })) + ;({ getComposioToolsForUser } = await import('../composio')) +}) + +describe('getComposioToolsForUser', () => { + let logger: Logger + + beforeEach(() => { + logger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } + createSession = mock(async () => ({ sessionId: 'fresh-session' })) + useSession = mock(async () => ({ sessionId: 'stored-session' })) + getRawToolRouterSessionTools = mock(async () => [ + { + slug: 'COMPOSIO_SEARCH_TOOLS', + inputParameters: { type: 'object', properties: {} }, + description: 'Search tools', + }, + ]) + }) + + function makeDb(storedSessionId: string | null) { + const findFirst = mock(async () => + storedSessionId + ? { + user_id: 'user-123', + session_id: storedSessionId, + created_at: new Date(), + updated_at: new Date(), + } + : null, + ) + const onConflictDoUpdate = mock(async () => undefined) + const values = mock(() => ({ onConflictDoUpdate })) + const whereDelete = mock(async () => undefined) + + return { + db: { + query: { + composioSession: { + findFirst, + }, + }, + insert: mock(() => ({ values })), + delete: mock(() => ({ where: whereDelete })), + } as any, + findFirst, + onConflictDoUpdate, + values, + whereDelete, + } + } + + test('replaces a stored session when Composio can no longer rehydrate it', async () => { + const notFound = Object.assign(new Error('Composio session not found'), { + status: 404, + }) + useSession = mock(async () => { + throw notFound + }) + const { db, whereDelete, values } = makeDb('stored-session') + + const result = await getComposioToolsForUser({ + db, + userId: 'user-123', + logger, + }) + + expect(result).toEqual({ + sessionId: 'fresh-session', + tools: [ + { + toolName: 'COMPOSIO_SEARCH_TOOLS', + inputSchema: { type: 'object', properties: {} }, + description: 'Search tools', + }, + ], + }) + expect(useSession).toHaveBeenCalledWith('stored-session') + expect(whereDelete).toHaveBeenCalledTimes(1) + expect(createSession).toHaveBeenCalledWith('user-123') + expect(values).toHaveBeenCalledWith({ + user_id: 'user-123', + session_id: 'fresh-session', + }) + }) + + test('keeps the stored session row when rehydration fails transiently', async () => { + const transientError = Object.assign(new Error('Composio unavailable'), { + status: 502, + }) + useSession = mock(async () => { + throw transientError + }) + const { db, whereDelete } = makeDb('stored-session') + + await expect( + getComposioToolsForUser({ + db, + userId: 'user-123', + logger, + }), + ).rejects.toThrow('Composio unavailable') + + expect(whereDelete).not.toHaveBeenCalled() + expect(createSession).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 21d501e746..e50930194f 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -163,6 +163,21 @@ async function getStoredSessionById(params: { }) } +async function deleteStoredSession(params: { + db: CodebuffPgDatabase + userId: string + sessionId: string +}) { + await params.db + .delete(schema.composioSession) + .where( + and( + eq(schema.composioSession.user_id, params.userId), + eq(schema.composioSession.session_id, params.sessionId), + ), + ) +} + async function createSessionForUser(params: { db: CodebuffPgDatabase userId: string @@ -188,6 +203,48 @@ async function createSessionForUser(params: { return cachedSession } +function getErrorStatus(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined + + const candidates = [ + 'status', + 'statusCode', + 'code', + 'responseStatus', + 'httpStatus', + ] + for (const key of candidates) { + const value = (error as Record)[key] + if (typeof value === 'number') return value + if (typeof value === 'string' && /^\d+$/.test(value)) { + return Number(value) + } + } + + const response = (error as Record)['response'] + return getErrorStatus(response) +} + +function isInvalidStoredSessionError(error: unknown): boolean { + const status = getErrorStatus(error) + if (status && [400, 401, 403, 404, 410].includes(status)) { + return true + } + + if (!(error instanceof Error)) return false + + const message = error.message.toLowerCase() + return ( + message.includes('session') && + (message.includes('not found') || + message.includes('not exist') || + message.includes('invalid') || + message.includes('expired') || + message.includes('unauthorized') || + message.includes('forbidden')) + ) +} + async function rehydrateSession(params: { userId: string sessionId: string @@ -228,12 +285,32 @@ async function getSessionForUser(params: { { userId: params.userId }, 'Rehydrating Composio session from database', ) - return rehydrateSession({ - userId: params.userId, - sessionId: storedSession.session_id, - apiKey, - includeTools: true, - }) + try { + return await rehydrateSession({ + userId: params.userId, + sessionId: storedSession.session_id, + apiKey, + includeTools: true, + }) + } catch (error) { + if (!isInvalidStoredSessionError(error)) { + throw error + } + + params.logger.warn( + { + error: getErrorObject(error), + userId: params.userId, + sessionId: storedSession.session_id, + }, + 'Stored Composio session is invalid; replacing it', + ) + await deleteStoredSession({ + db: params.db, + userId: params.userId, + sessionId: storedSession.session_id, + }) + } } params.logger.info( From b0636e6429381d28c6a64891402d4dccc47eec75 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 00:39:10 -0700 Subject: [PATCH 05/15] Fix Composio test env mock leakage --- web/src/server/__tests__/composio.test.ts | 5 ++--- web/src/server/composio.ts | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index 164d9c462c..58a138aa8b 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -11,9 +11,6 @@ let getRawToolRouterSessionTools: ReturnType beforeAll(async () => { mock.module('server-only', () => ({})) - mock.module('@codebuff/internal/env', () => ({ - env: { COMPOSIO_API_KEY: 'test-composio-api-key' }, - })) mock.module('@composio/core', () => ({ Composio: class { tools = { @@ -93,6 +90,7 @@ describe('getComposioToolsForUser', () => { db, userId: 'user-123', logger, + apiKey: 'test-composio-api-key', }) expect(result).toEqual({ @@ -128,6 +126,7 @@ describe('getComposioToolsForUser', () => { db, userId: 'user-123', logger, + apiKey: 'test-composio-api-key', }), ).rejects.toThrow('Composio unavailable') diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index e50930194f..15c55404a7 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -271,8 +271,9 @@ async function getSessionForUser(params: { db: CodebuffPgDatabase userId: string logger: Logger + apiKey?: string }): Promise { - const apiKey = getComposioApiKey() + const apiKey = params.apiKey ?? getComposioApiKey() if (!apiKey) return null try { @@ -335,6 +336,7 @@ export async function getComposioToolsForUser(params: { db: CodebuffPgDatabase userId: string logger: Logger + apiKey?: string }): Promise<{ sessionId: string; tools: ComposioToolDefinition[] } | null> { const cached = await getSessionForUser(params) if (!cached) return null @@ -351,6 +353,7 @@ export async function executeComposioTool(params: { sessionId: string toolName: string input: Record + apiKey?: string }): Promise { if (!allowedToolNames.has(params.toolName)) { return [ @@ -363,7 +366,7 @@ export async function executeComposioTool(params: { ] } - const apiKey = getComposioApiKey() + const apiKey = params.apiKey ?? getComposioApiKey() if (!apiKey) return null const storedSession = await getStoredSessionById({ From 0b1dd15c1ea43061c6928589bba814625510cf4a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 14:21:58 -0700 Subject: [PATCH 06/15] Fix Composio session race and timeout cache --- sdk/src/__tests__/composio.test.ts | 66 +++++++++++++++++++++++ sdk/src/composio.ts | 16 ++++-- web/src/server/__tests__/composio.test.ts | 55 +++++++++++++++---- web/src/server/composio.ts | 37 ++++++++++--- 4 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 sdk/src/__tests__/composio.test.ts diff --git a/sdk/src/__tests__/composio.test.ts b/sdk/src/__tests__/composio.test.ts new file mode 100644 index 0000000000..2f5e270e62 --- /dev/null +++ b/sdk/src/__tests__/composio.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import { getComposioCustomToolDefinitions } from '../composio' + +describe('getComposioCustomToolDefinitions', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('does not cache an empty tool list after discovery timeout', async () => { + const apiKey = `timeout-key-${Date.now()}` + const timeoutFetch = mock( + async (_url: string | URL | Request, init?: RequestInit) => { + const signal = init?.signal + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(new Error('aborted')) + return + } + + signal?.addEventListener( + 'abort', + () => reject(new Error('aborted')), + { once: true }, + ) + }) + }, + ) + globalThis.fetch = timeoutFetch as unknown as typeof fetch + + const timedOutTools = await getComposioCustomToolDefinitions({ + apiKey, + logger: { warn: mock(() => {}) }, + }) + expect(timedOutTools).toEqual([]) + expect(timeoutFetch).toHaveBeenCalledTimes(1) + + const successFetch = mock(async () => { + return new Response( + JSON.stringify({ + sessionId: 'session-123', + tools: [ + { + toolName: 'COMPOSIO_SEARCH_TOOLS', + inputSchema: { type: 'object', properties: {} }, + description: 'Search tools', + }, + ], + }), + { status: 200 }, + ) + }) + globalThis.fetch = successFetch as unknown as typeof fetch + + const tools = await getComposioCustomToolDefinitions({ + apiKey, + logger: { warn: mock(() => {}) }, + }) + + expect(successFetch).toHaveBeenCalledTimes(1) + expect(tools).toHaveLength(1) + expect(tools[0]?.toolName).toBe('COMPOSIO_SEARCH_TOOLS') + }) +}) diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index 80f329fa53..ae543d3dde 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -185,13 +185,23 @@ export async function getComposioCustomToolDefinitions(params: { signal: discoverySignal.signal, }) } catch (error) { - if (!params.signal?.aborted) { - cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) + if (params.signal?.aborted) { + return [] + } + + if (discoverySignal.signal.aborted) { params.logger?.warn( { error: error instanceof Error ? error.message : String(error) }, - 'Failed to fetch Composio tools', + 'Timed out fetching Composio tools', ) + return [] } + + cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) + params.logger?.warn( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to fetch Composio tools', + ) return [] } finally { discoverySignal.cleanup() diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index 58a138aa8b..f9009796eb 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -45,19 +45,27 @@ describe('getComposioToolsForUser', () => { ]) }) - function makeDb(storedSessionId: string | null) { - const findFirst = mock(async () => - storedSessionId + function makeDb(storedSessionIds: string | null | Array) { + const storedSessionIdSequence = Array.isArray(storedSessionIds) + ? [...storedSessionIds] + : [storedSessionIds] + const findFirst = mock(async () => { + const storedSessionId = + storedSessionIdSequence.length > 1 + ? storedSessionIdSequence.shift() + : storedSessionIdSequence[0] + + return storedSessionId ? { user_id: 'user-123', session_id: storedSessionId, created_at: new Date(), updated_at: new Date(), } - : null, - ) - const onConflictDoUpdate = mock(async () => undefined) - const values = mock(() => ({ onConflictDoUpdate })) + : null + }) + const onConflictDoNothing = mock(async () => undefined) + const values = mock(() => ({ onConflictDoNothing })) const whereDelete = mock(async () => undefined) return { @@ -71,7 +79,7 @@ describe('getComposioToolsForUser', () => { delete: mock(() => ({ where: whereDelete })), } as any, findFirst, - onConflictDoUpdate, + onConflictDoNothing, values, whereDelete, } @@ -84,7 +92,10 @@ describe('getComposioToolsForUser', () => { useSession = mock(async () => { throw notFound }) - const { db, whereDelete, values } = makeDb('stored-session') + const { db, whereDelete, values } = makeDb([ + 'stored-session', + 'fresh-session', + ]) const result = await getComposioToolsForUser({ db, @@ -112,6 +123,32 @@ describe('getComposioToolsForUser', () => { }) }) + test('returns the persisted session when concurrent creation stores a different session', async () => { + createSession = mock(async () => ({ sessionId: 'losing-session' })) + useSession = mock(async () => ({ sessionId: 'winning-session' })) + const { db, values, onConflictDoNothing } = makeDb([ + null, + 'winning-session', + ]) + + const result = await getComposioToolsForUser({ + db, + userId: 'user-123', + logger, + apiKey: 'test-composio-api-key', + }) + + expect(result?.sessionId).toBe('winning-session') + expect(createSession).toHaveBeenCalledWith('user-123') + expect(values).toHaveBeenCalledWith({ + user_id: 'user-123', + session_id: 'losing-session', + }) + expect(onConflictDoNothing).toHaveBeenCalledTimes(1) + expect(useSession).toHaveBeenCalledWith('winning-session') + expect(getRawToolRouterSessionTools).toHaveBeenCalledWith('winning-session') + }) + test('keeps the stored session row when rehydration fails transiently', async () => { const transientError = Object.assign(new Error('Composio unavailable'), { status: 502, diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 15c55404a7..a6470c1906 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -121,7 +121,7 @@ async function getToolDefinitionsForSession(params: { })) } -async function saveSession(params: { +async function insertSessionIfAbsent(params: { db: CodebuffPgDatabase userId: string sessionId: string @@ -132,12 +132,8 @@ async function saveSession(params: { user_id: params.userId, session_id: params.sessionId, }) - .onConflictDoUpdate({ + .onConflictDoNothing({ target: schema.composioSession.user_id, - set: { - session_id: params.sessionId, - updated_at: new Date(), - }, }) } @@ -182,15 +178,41 @@ async function createSessionForUser(params: { db: CodebuffPgDatabase userId: string apiKey: string + logger: Logger }): Promise { const composio = getComposioClient(params.apiKey) const session = await composio.create(params.userId) - await saveSession({ + await insertSessionIfAbsent({ db: params.db, userId: params.userId, sessionId: session.sessionId, }) + const storedSession = await getStoredSessionByUser({ + db: params.db, + userId: params.userId, + }) + if (!storedSession) { + throw new Error('Failed to persist Composio session') + } + + if (storedSession.session_id !== session.sessionId) { + params.logger.info( + { + userId: params.userId, + createdSessionId: session.sessionId, + storedSessionId: storedSession.session_id, + }, + 'Using existing persisted Composio session after concurrent creation', + ) + return rehydrateSession({ + userId: params.userId, + sessionId: storedSession.session_id, + apiKey: params.apiKey, + includeTools: true, + }) + } + const cachedSession: CachedComposioSession = { userId: params.userId, sessionId: session.sessionId, @@ -322,6 +344,7 @@ async function getSessionForUser(params: { db: params.db, userId: params.userId, apiKey, + logger: params.logger, }) } catch (error) { params.logger.error( From 4a023f51c979b02cc31b87d88cdb1c7a30541e36 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 15:14:35 -0700 Subject: [PATCH 07/15] Simplify Composio meta tool execution --- sdk/src/__tests__/composio.test.ts | 93 +++--- sdk/src/composio.ts | 315 ++++++++---------- sdk/src/custom-tool.ts | 8 +- sdk/src/run-state.ts | 15 +- sdk/src/run.ts | 29 +- .../v1/composio/__tests__/composio.test.ts | 130 +++----- web/src/app/api/v1/composio/execute/_post.ts | 3 +- web/src/app/api/v1/composio/tools/_post.ts | 86 ----- web/src/app/api/v1/composio/tools/route.ts | 18 - .../__tests__/composio-rate-limiter.test.ts | 23 +- web/src/server/__tests__/composio.test.ts | 60 ++-- web/src/server/composio-rate-limiter.ts | 6 +- web/src/server/composio.ts | 108 ++---- 13 files changed, 310 insertions(+), 584 deletions(-) delete mode 100644 web/src/app/api/v1/composio/tools/_post.ts delete mode 100644 web/src/app/api/v1/composio/tools/route.ts diff --git a/sdk/src/__tests__/composio.test.ts b/sdk/src/__tests__/composio.test.ts index 2f5e270e62..1c524c09fc 100644 --- a/sdk/src/__tests__/composio.test.ts +++ b/sdk/src/__tests__/composio.test.ts @@ -1,66 +1,65 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' -import { getComposioCustomToolDefinitions } from '../composio' +import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' -describe('getComposioCustomToolDefinitions', () => { +import { getComposioMetaToolDefinitions } from '../composio' + +describe('getComposioMetaToolDefinitions', () => { const originalFetch = globalThis.fetch afterEach(() => { globalThis.fetch = originalFetch }) - test('does not cache an empty tool list after discovery timeout', async () => { - const apiKey = `timeout-key-${Date.now()}` - const timeoutFetch = mock( - async (_url: string | URL | Request, init?: RequestInit) => { - const signal = init?.signal - return new Promise((_resolve, reject) => { - if (signal?.aborted) { - reject(new Error('aborted')) - return - } + test('returns static Composio meta tool definitions without discovery fetch', () => { + const fetchMock = mock(async () => new Response('{}')) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const tools = getComposioMetaToolDefinitions({ + apiKey: 'codebuff-api-key', + }) + + expect(tools.map((tool) => tool.toolName)).toEqual([ + ...COMPOSIO_META_TOOL_NAMES, + ]) + expect(fetchMock).not.toHaveBeenCalled() + }) - signal?.addEventListener( - 'abort', - () => reject(new Error('aborted')), - { once: true }, - ) + test('executes a meta tool through the server execute endpoint', async () => { + const fetchMock = mock( + async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.method).toBe('POST') + expect(init?.headers).toEqual({ + Authorization: 'Bearer codebuff-api-key', + 'Content-Type': 'application/json', + }) + expect(JSON.parse(String(init?.body))).toEqual({ + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { + queries: ['find gmail tools'], + session: { generate_id: true }, + }, }) + return new Response( + JSON.stringify({ + output: [{ type: 'json', value: { ok: true } }], + }), + { status: 200 }, + ) }, ) - globalThis.fetch = timeoutFetch as unknown as typeof fetch + globalThis.fetch = fetchMock as unknown as typeof fetch - const timedOutTools = await getComposioCustomToolDefinitions({ - apiKey, - logger: { warn: mock(() => {}) }, - }) - expect(timedOutTools).toEqual([]) - expect(timeoutFetch).toHaveBeenCalledTimes(1) - - const successFetch = mock(async () => { - return new Response( - JSON.stringify({ - sessionId: 'session-123', - tools: [ - { - toolName: 'COMPOSIO_SEARCH_TOOLS', - inputSchema: { type: 'object', properties: {} }, - description: 'Search tools', - }, - ], - }), - { status: 200 }, - ) - }) - globalThis.fetch = successFetch as unknown as typeof fetch + const searchTool = getComposioMetaToolDefinitions({ + apiKey: 'codebuff-api-key', + }).find((tool) => tool.toolName === 'COMPOSIO_SEARCH_TOOLS') - const tools = await getComposioCustomToolDefinitions({ - apiKey, - logger: { warn: mock(() => {}) }, + const output = await searchTool?.execute({ + queries: ['find gmail tools'], + session: { generate_id: true }, }) - expect(successFetch).toHaveBeenCalledTimes(1) - expect(tools).toHaveLength(1) - expect(tools[0]?.toolName).toBe('COMPOSIO_SEARCH_TOOLS') + expect(output).toEqual([{ type: 'json', value: { ok: true } }]) + expect(fetchMock).toHaveBeenCalledTimes(1) }) }) diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index ae543d3dde..c7343010bc 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -1,32 +1,136 @@ +import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' +import { z } from 'zod/v4' + import { WEBSITE_URL } from './constants' +import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' import type { CustomToolDefinition } from './custom-tool' -import type { Logger } from '@codebuff/common/types/contracts/logger' import type { JSONValue } from '@codebuff/common/types/json' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' -type ComposioToolsResponse = { - sessionId: string - tools: Array<{ - toolName: string - inputSchema: Record - description: string - }> -} - type ComposioExecuteResponse = { output: ToolResultOutput[] } -const COMPOSIO_DISCOVERY_TIMEOUT_MS = 750 -const COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS = 5 * 60 * 1000 -const COMPOSIO_DISCOVERY_FAILURE_CACHE_MS = 60 * 1000 -const COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES = 64 +const sessionIdParam = z + .string() + .optional() + .describe('Session ID returned by COMPOSIO_SEARCH_TOOLS, when available.') + +const workflowStepParams = { + current_step: z + .string() + .optional() + .describe('Short enum-style label for the current workflow step.'), + current_step_metric: z + .string() + .optional() + .describe('Progress metric such as "3/10 emails" or "0/n messages".'), +} -const composioToolDefinitionsCache = new Map< - string, - { expiresAt: number; tools: CustomToolDefinition[] } ->() +const composioMetaToolSchemas = { + COMPOSIO_SEARCH_TOOLS: z + .object({ + queries: z + .array(z.unknown()) + .min(1) + .describe( + 'Structured English search queries. Split independent app/API actions into separate queries.', + ), + session: z + .object({ + generate_id: z.boolean().optional(), + id: z.string().optional(), + }) + .catchall(z.unknown()) + .describe( + 'Use { generate_id: true } for a new workflow, or { id } to continue one.', + ), + model: z.string().optional().describe('Client LLM model name.'), + }) + .catchall(z.unknown()), + COMPOSIO_GET_TOOL_SCHEMAS: z + .object({ + tool_slugs: z + .array(z.string()) + .min(1) + .describe('Composio tool slugs to retrieve schemas for.'), + include: z + .array(z.string()) + .optional() + .describe('Schema fields to include, e.g. input_schema/output_schema.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), + COMPOSIO_MANAGE_CONNECTIONS: z + .object({ + toolkits: z + .array(z.string()) + .min(1) + .describe('Toolkit slugs to check or connect, such as gmail/github.'), + reinitiate_all: z + .boolean() + .optional() + .describe('Force reconnection even if active credentials exist.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), + COMPOSIO_MULTI_EXECUTE_TOOL: z + .object({ + tools: z + .array(z.record(z.string(), z.unknown())) + .min(1) + .describe('Logically independent Composio tools to execute.'), + thought: z + .string() + .optional() + .describe('One concise sentence explaining the execution intent.'), + sync_response_to_workbench: z + .boolean() + .describe('Use true when the response may be large or reused later.'), + session_id: sessionIdParam, + ...workflowStepParams, + }) + .catchall(z.unknown()), + COMPOSIO_REMOTE_WORKBENCH: z + .object({ + code_to_execute: z + .string() + .describe('Python code to run in the persistent remote workbench.'), + thought: z + .string() + .optional() + .describe( + 'One concise sentence describing why the workbench is needed.', + ), + session_id: sessionIdParam, + ...workflowStepParams, + }) + .catchall(z.unknown()), + COMPOSIO_REMOTE_BASH_TOOL: z + .object({ + command: z + .string() + .describe('Bash command to run in the remote sandbox.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), +} satisfies Record + +const composioMetaToolDescriptions = { + COMPOSIO_SEARCH_TOOLS: + 'Discover relevant Composio tools across external apps. Use this first for requests involving services like Gmail, GitHub, Slack, Linear, Notion, Google Calendar, or Google Sheets.', + COMPOSIO_GET_TOOL_SCHEMAS: + 'Retrieve complete input schemas for specific Composio tool slugs returned by COMPOSIO_SEARCH_TOOLS.', + COMPOSIO_MANAGE_CONNECTIONS: + 'Check or initiate user authentication for external app toolkits. Use when search/execution indicates a toolkit is not connected.', + COMPOSIO_MULTI_EXECUTE_TOOL: + 'Execute one or more discovered Composio app tools in the current workflow session.', + COMPOSIO_REMOTE_WORKBENCH: + 'Run Python in a persistent Composio workbench for bulk app workflows, large responses, or data transformations.', + COMPOSIO_REMOTE_BASH_TOOL: + 'Run bash commands in the Composio remote sandbox for simple file and data processing.', +} satisfies Record function toJsonValue(value: unknown): JSONValue { try { @@ -36,70 +140,6 @@ function toJsonValue(value: unknown): JSONValue { } } -function createTimeoutSignal( - parentSignal: AbortSignal | undefined, - timeoutMs: number, -) { - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort(new Error('Composio discovery timed out')) - }, timeoutMs) - const onAbort = () => controller.abort(parentSignal?.reason) - - if (parentSignal?.aborted) { - onAbort() - } else { - parentSignal?.addEventListener('abort', onAbort, { once: true }) - } - - return { - signal: controller.signal, - cleanup: () => { - clearTimeout(timeout) - parentSignal?.removeEventListener('abort', onAbort) - }, - } -} - -function getCachedComposioTools(apiKey: string): CustomToolDefinition[] | null { - const cached = composioToolDefinitionsCache.get(apiKey) - if (!cached) return null - if (Date.now() >= cached.expiresAt) { - composioToolDefinitionsCache.delete(apiKey) - return null - } - return cached.tools -} - -function pruneComposioToolsCache(now: number) { - for (const [apiKey, cached] of composioToolDefinitionsCache) { - if (now >= cached.expiresAt) { - composioToolDefinitionsCache.delete(apiKey) - } - } - - while ( - composioToolDefinitionsCache.size >= COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES - ) { - const oldest = composioToolDefinitionsCache.keys().next() - if (oldest.done) break - composioToolDefinitionsCache.delete(oldest.value) - } -} - -function cacheComposioTools( - apiKey: string, - tools: CustomToolDefinition[], - ttlMs: number, -) { - const now = Date.now() - pruneComposioToolsCache(now) - composioToolDefinitionsCache.set(apiKey, { - tools, - expiresAt: now + ttlMs, - }) -} - async function readErrorMessage(response: Response): Promise { try { const body = (await response.json()) as { @@ -114,7 +154,6 @@ async function readErrorMessage(response: Response): Promise { async function executeComposioToolViaServer(params: { apiKey: string - sessionId: string toolName: string input: Record }): Promise { @@ -128,7 +167,6 @@ async function executeComposioToolViaServer(params: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - sessionId: params.sessionId, toolName: params.toolName, input: params.input, }), @@ -161,95 +199,24 @@ async function executeComposioToolViaServer(params: { } } -export async function getComposioCustomToolDefinitions(params: { +export function getComposioMetaToolDefinitions(params: { apiKey: string - logger?: Pick - signal?: AbortSignal -}): Promise { - const cachedTools = getCachedComposioTools(params.apiKey) - if (cachedTools) return cachedTools - if (params.signal?.aborted) return [] - - const discoverySignal = createTimeoutSignal( - params.signal, - COMPOSIO_DISCOVERY_TIMEOUT_MS, - ) - - let response: Response - try { - response = await fetch(new URL('/api/v1/composio/tools', WEBSITE_URL), { - method: 'POST', - headers: { - Authorization: `Bearer ${params.apiKey}`, - }, - signal: discoverySignal.signal, - }) - } catch (error) { - if (params.signal?.aborted) { - return [] - } - - if (discoverySignal.signal.aborted) { - params.logger?.warn( - { error: error instanceof Error ? error.message : String(error) }, - 'Timed out fetching Composio tools', - ) - return [] - } - - cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) - params.logger?.warn( - { error: error instanceof Error ? error.message : String(error) }, - 'Failed to fetch Composio tools', - ) - return [] - } finally { - discoverySignal.cleanup() - } - - if (!response.ok) { - cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) - if (response.status !== 503) { - params.logger?.warn( - { status: response.status, error: await readErrorMessage(response) }, - 'Failed to fetch Composio tools', - ) - } - return [] - } - - try { - const body = (await response.json()) as ComposioToolsResponse - const tools = body.tools.map((tool) => ({ - toolName: tool.toolName, - inputSchema: tool.inputSchema, - description: tool.description, - endsAgentStep: true, - exampleInputs: [], - execute: async (input: unknown) => { - return executeComposioToolViaServer({ - apiKey: params.apiKey, - sessionId: body.sessionId, - toolName: tool.toolName, - input: - input && typeof input === 'object' - ? (input as Record) - : { value: toJsonValue(input) }, - }) - }, - })) - cacheComposioTools( - params.apiKey, - tools, - COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS, - ) - return tools - } catch (error) { - cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS) - params.logger?.warn( - { error: error instanceof Error ? error.message : String(error) }, - 'Failed to parse Composio tools response', - ) - return [] - } +}): CustomToolDefinition[] { + return COMPOSIO_META_TOOL_NAMES.map((toolName) => ({ + toolName, + inputSchema: composioMetaToolSchemas[toolName], + description: composioMetaToolDescriptions[toolName], + endsAgentStep: true, + exampleInputs: [], + execute: async (input: unknown) => { + return executeComposioToolViaServer({ + apiKey: params.apiKey, + toolName, + input: + input && typeof input === 'object' + ? (input as Record) + : { value: toJsonValue(input) }, + }) + }, + })) } diff --git a/sdk/src/custom-tool.ts b/sdk/src/custom-tool.ts index adc745b09b..92fb0c51c7 100644 --- a/sdk/src/custom-tool.ts +++ b/sdk/src/custom-tool.ts @@ -1,11 +1,7 @@ -import type { ToolName } from '@codebuff/common/tools/constants' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' +import type { ToolName } from '@codebuff/common/tools/constants' import type { z } from 'zod/v4' -export type CustomToolInputSchema = - | z.ZodType - | Record - export type CustomToolDefinition< N extends string = string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,7 +10,7 @@ export type CustomToolDefinition< Input extends any = any, > = { toolName: N - inputSchema: CustomToolInputSchema + inputSchema: z.ZodType description: string endsAgentStep: boolean exampleInputs: Input[] diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index 59f84e1eba..7fcc35a42b 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -112,16 +112,11 @@ function processCustomToolDefinitions( ): CustomToolDefinitions { return Object.fromEntries( customToolDefinitions.map((toolDefinition) => { - const isZodSchema = - typeof (toolDefinition.inputSchema as { safeParse?: unknown }) - .safeParse === 'function' - // Convert Zod schemas to JSON Schema so they survive JSON serialization. - // Some adapters already provide JSON Schema directly. - const jsonSchema = isZodSchema - ? (z.toJSONSchema(toolDefinition.inputSchema as z.ZodType, { - io: 'input', - }) as Record) - : { ...(toolDefinition.inputSchema as Record) } + // Convert Zod schema to JSON Schema format so it survives JSON serialization + // The agent-runtime will wrap this with AI SDK's jsonSchema() helper + const jsonSchema = z.toJSONSchema(toolDefinition.inputSchema, { + io: 'input', + }) as Record delete jsonSchema['$schema'] return [ diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 230d141516..d635fe1697 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -12,14 +12,13 @@ import { listMCPTools, callMCPTool, } from '@codebuff/common/mcp/client' -import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' import { extractApiErrorDetails } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' -import { getComposioCustomToolDefinitions } from './composio' +import { getComposioMetaToolDefinitions } from './composio' import { getErrorStatusCode } from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' @@ -520,26 +519,18 @@ async function runOnce({ return getCancelledRunState('Run cancelled by user.') } - const composioCustomToolDefinitions = await getComposioCustomToolDefinitions({ + const composioCustomToolDefinitions = getComposioMetaToolDefinitions({ apiKey, - logger, - signal, }) - for (const toolName of COMPOSIO_META_TOOL_NAMES) { - delete sessionState.fileContext.customToolDefinitions[toolName] - } - - if (composioCustomToolDefinitions.length > 0) { - activeCustomToolDefinitions = [ - ...activeCustomToolDefinitions, - ...composioCustomToolDefinitions, - ] - sessionState = await applyOverridesToSessionState(cwd, sessionState, { - customToolDefinitions: activeCustomToolDefinitions, - }) - initialMessageHistory = sessionState.mainAgentState.messageHistory - } + activeCustomToolDefinitions = [ + ...activeCustomToolDefinitions, + ...composioCustomToolDefinitions, + ] + sessionState = await applyOverridesToSessionState(cwd, sessionState, { + customToolDefinitions: activeCustomToolDefinitions, + }) + initialMessageHistory = sessionState.mainAgentState.messageHistory if (signal?.aborted) { return getCancelledRunState('Run cancelled by user.') diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index b52fc584ed..f2ddf75714 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -15,15 +15,12 @@ import type { LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' import type { postComposioExecute as PostComposioExecute } from '../execute/_post' -import type { postComposioTools as PostComposioTools } from '../tools/_post' let postComposioExecute: typeof PostComposioExecute -let postComposioTools: typeof PostComposioTools beforeAll(async () => { mock.module('server-only', () => ({})) ;({ postComposioExecute } = await import('../execute/_post')) - ;({ postComposioTools } = await import('../tools/_post')) }) describe('/api/v1/composio', () => { @@ -63,94 +60,60 @@ describe('/api/v1/composio', () => { mock.restore() }) - test('lists Composio tools for an authenticated user', async () => { - const getToolsForUser = mock(async () => ({ - sessionId: 'session-123', - tools: [ - { - toolName: 'COMPOSIO_SEARCH_TOOLS', - inputSchema: { type: 'object', properties: {} }, - description: 'Search Composio tools', - }, - ], - })) + test('executes a Composio tool for an authenticated user', async () => { + const executeTool = mock(async () => [ + { type: 'json' as const, value: { ok: true } }, + ]) const checkRateLimit = mock(() => ({ limited: false as const })) - const req = new NextRequest('http://localhost/api/v1/composio/tools', { + const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { query: 'gmail' }, + }), }) - const response = await postComposioTools({ + const response = await postComposioExecute({ req, getUserInfoFromApiKey, db: mockDb, logger, loggerWithContext, - getToolsForUser, + executeTool, checkRateLimit, + isConfigured: () => true, }) expect(response.status).toBe(200) expect(await response.json()).toEqual({ - sessionId: 'session-123', - tools: [ - { - toolName: 'COMPOSIO_SEARCH_TOOLS', - inputSchema: { type: 'object', properties: {} }, - description: 'Search Composio tools', - }, - ], - }) - expect(getToolsForUser).toHaveBeenCalledTimes(1) - expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'tools') - }) - - test('rate limits Composio tool listing', async () => { - const getToolsForUser = mock(async () => ({ - sessionId: 'session-123', - tools: [], - })) - const checkRateLimit = mock(() => ({ - limited: true as const, - retryAfterMs: 12_500, - windowName: '1 minute', - })) - const req = new NextRequest('http://localhost/api/v1/composio/tools', { - method: 'POST', - headers: { Authorization: 'Bearer valid-key' }, + output: [{ type: 'json', value: { ok: true } }], }) - - const response = await postComposioTools({ - req, - getUserInfoFromApiKey, + expect(executeTool).toHaveBeenCalledWith({ db: mockDb, + userId: 'user-123', logger, - loggerWithContext, - getToolsForUser, - checkRateLimit, - }) - - expect(response.status).toBe(429) - expect(response.headers.get('Retry-After')).toBe('13') - expect(await response.json()).toEqual({ - error: 'Rate limited', - retryAfterSeconds: 13, + sessionId: 'session-123', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { query: 'gmail' }, }) - expect(getToolsForUser).not.toHaveBeenCalled() + expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'execute') }) - test('executes a Composio tool for an authenticated user', async () => { + test('executes a Composio tool without a client-provided session ID', async () => { const executeTool = mock(async () => [ { type: 'json' as const, value: { ok: true } }, ]) - const checkRateLimit = mock(() => ({ limited: false as const })) const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - sessionId: 'session-123', toolName: 'COMPOSIO_SEARCH_TOOLS', - input: { query: 'gmail' }, + input: { + queries: ['find gmail tools'], + session: { generate_id: true }, + }, }), }) @@ -161,22 +124,21 @@ describe('/api/v1/composio', () => { logger, loggerWithContext, executeTool, - checkRateLimit, + checkRateLimit: mock(() => ({ limited: false as const })), isConfigured: () => true, }) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - output: [{ type: 'json', value: { ok: true } }], - }) expect(executeTool).toHaveBeenCalledWith({ db: mockDb, userId: 'user-123', - sessionId: 'session-123', + logger, toolName: 'COMPOSIO_SEARCH_TOOLS', - input: { query: 'gmail' }, + input: { + queries: ['find gmail tools'], + session: { generate_id: true }, + }, }) - expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'execute') }) test('returns 404 when a Composio session cannot be found for execute', async () => { @@ -278,21 +240,19 @@ describe('/api/v1/composio', () => { }) test('rejects unauthenticated Composio requests', async () => { - const req = new NextRequest('http://localhost/api/v1/composio/tools', { + const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', }) - const response = await postComposioTools({ + const response = await postComposioExecute({ req, getUserInfoFromApiKey, db: mockDb, logger, loggerWithContext, - getToolsForUser: mock(async () => ({ - sessionId: 'session-123', - tools: [], - })), + executeTool: mock(async () => [{ type: 'json' as const, value: {} }]), checkRateLimit: mock(() => ({ limited: false as const })), + isConfigured: () => true, }) expect(response.status).toBe(401) @@ -301,32 +261,34 @@ describe('/api/v1/composio', () => { }) }) - test('rejects suspended users before rate limiting or tool lookup', async () => { - const getToolsForUser = mock(async () => ({ - sessionId: 'session-123', - tools: [], - })) + test('rejects suspended users before rate limiting or tool execution', async () => { + const executeTool = mock(async () => [{ type: 'json' as const, value: {} }]) const checkRateLimit = mock(() => ({ limited: false as const })) - const req = new NextRequest('http://localhost/api/v1/composio/tools', { + const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', headers: { Authorization: 'Bearer banned-key' }, + body: JSON.stringify({ + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: {}, + }), }) - const response = await postComposioTools({ + const response = await postComposioExecute({ req, getUserInfoFromApiKey, db: mockDb, logger, loggerWithContext, - getToolsForUser, + executeTool, checkRateLimit, + isConfigured: () => true, }) expect(response.status).toBe(403) const body = await response.json() expect(body.error).toBe('account_suspended') expect(body.message).toContain('Your account has been suspended') - expect(getToolsForUser).not.toHaveBeenCalled() + expect(executeTool).not.toHaveBeenCalled() expect(checkRateLimit).not.toHaveBeenCalled() }) }) diff --git a/web/src/app/api/v1/composio/execute/_post.ts b/web/src/app/api/v1/composio/execute/_post.ts index 8b6939eda5..461cd35f72 100644 --- a/web/src/app/api/v1/composio/execute/_post.ts +++ b/web/src/app/api/v1/composio/execute/_post.ts @@ -20,7 +20,7 @@ type CheckComposioRateLimitFn = typeof checkComposioRateLimit type IsComposioConfiguredFn = typeof isComposioConfigured const composioExecuteBodySchema = z.object({ - sessionId: z.string().min(1), + sessionId: z.string().min(1).optional(), toolName: z.string().min(1), input: z.record(z.string(), z.unknown()).default({}), }) @@ -99,6 +99,7 @@ export async function postComposioExecute(params: { const output = await executeTool({ db, userId: userInfo.id, + logger, ...parsed.data, }) if (!output) { diff --git a/web/src/app/api/v1/composio/tools/_post.ts b/web/src/app/api/v1/composio/tools/_post.ts deleted file mode 100644 index 4068be61b3..0000000000 --- a/web/src/app/api/v1/composio/tools/_post.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { getErrorObject } from '@codebuff/common/util/error' -import { NextResponse } from 'next/server' - -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { - Logger, - LoggerWithContextFn, -} from '@codebuff/common/types/contracts/logger' -import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' -import type { NextRequest } from 'next/server' - -import { getComposioToolsForUser } from '@/server/composio' -import { checkComposioRateLimit } from '@/server/composio-rate-limiter' - -import { requireComposioUser } from '../_auth' - -type GetComposioToolsForUserFn = typeof getComposioToolsForUser -type CheckComposioRateLimitFn = typeof checkComposioRateLimit - -export async function postComposioTools(params: { - req: NextRequest - getUserInfoFromApiKey: GetUserInfoFromApiKeyFn - db: CodebuffPgDatabase - logger: Logger - loggerWithContext: LoggerWithContextFn - getToolsForUser?: GetComposioToolsForUserFn - checkRateLimit?: CheckComposioRateLimitFn -}) { - const { - db, - getToolsForUser = getComposioToolsForUser, - checkRateLimit = checkComposioRateLimit, - } = params - const auth = await requireComposioUser(params) - if (!auth.ok) return auth.response - const { userInfo, logger } = auth - - const rateLimit = checkRateLimit(userInfo.id, 'tools') - if (rateLimit.limited) { - const retryAfterSeconds = Math.ceil(rateLimit.retryAfterMs / 1000) - logger.warn( - { - userId: userInfo.id, - retryAfterSeconds, - windowName: rateLimit.windowName, - }, - 'Rate limited Composio tools request', - ) - return NextResponse.json( - { error: 'Rate limited', retryAfterSeconds }, - { - status: 429, - headers: { 'Retry-After': String(retryAfterSeconds) }, - }, - ) - } - - try { - const tools = await getToolsForUser({ - db, - userId: userInfo.id, - logger, - }) - if (!tools) { - return NextResponse.json( - { error: 'Composio is not configured' }, - { status: 503 }, - ) - } - - logger.info( - { userId: userInfo.id, toolCount: tools.tools.length }, - 'Loaded Composio tools', - ) - return NextResponse.json(tools) - } catch (error) { - logger.error( - { error: getErrorObject(error), userId: userInfo.id }, - 'Failed to load Composio tools', - ) - return NextResponse.json( - { error: 'Failed to load Composio tools' }, - { status: 502 }, - ) - } -} diff --git a/web/src/app/api/v1/composio/tools/route.ts b/web/src/app/api/v1/composio/tools/route.ts deleted file mode 100644 index c0fcb6e723..0000000000 --- a/web/src/app/api/v1/composio/tools/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import db from '@codebuff/internal/db' - -import { getUserInfoFromApiKey } from '@/db/user' -import { logger, loggerWithContext } from '@/util/logger' - -import { postComposioTools } from './_post' - -import type { NextRequest } from 'next/server' - -export async function POST(req: NextRequest) { - return postComposioTools({ - req, - getUserInfoFromApiKey, - db, - logger, - loggerWithContext, - }) -} diff --git a/web/src/server/__tests__/composio-rate-limiter.test.ts b/web/src/server/__tests__/composio-rate-limiter.test.ts index 5de7b23058..845b276915 100644 --- a/web/src/server/__tests__/composio-rate-limiter.test.ts +++ b/web/src/server/__tests__/composio-rate-limiter.test.ts @@ -11,34 +11,23 @@ describe('checkComposioRateLimit', () => { }) test('allows requests below the per-minute limit', () => { - for (let i = 0; i < 30; i++) { - expect(checkComposioRateLimit('user-1', 'tools')).toEqual({ + for (let i = 0; i < 120; i++) { + expect(checkComposioRateLimit('user-1', 'execute')).toEqual({ limited: false, }) } }) - test('limits tool listing after the per-minute limit', () => { - for (let i = 0; i < 30; i++) { - checkComposioRateLimit('user-1', 'tools') + test('limits execution after the per-minute limit', () => { + for (let i = 0; i < 120; i++) { + checkComposioRateLimit('user-1', 'execute') } - const result = checkComposioRateLimit('user-1', 'tools') + const result = checkComposioRateLimit('user-1', 'execute') expect(result.limited).toBe(true) if (result.limited) { expect(result.windowName).toBe('1 minute') expect(result.retryAfterMs).toBeGreaterThan(0) } }) - - test('tracks execute and tools limits independently', () => { - for (let i = 0; i < 30; i++) { - checkComposioRateLimit('user-1', 'tools') - } - - expect(checkComposioRateLimit('user-1', 'tools').limited).toBe(true) - expect(checkComposioRateLimit('user-1', 'execute')).toEqual({ - limited: false, - }) - }) }) diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index f9009796eb..26c10392ef 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -1,30 +1,26 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test' import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { getComposioToolsForUser as GetComposioToolsForUser } from '../composio' +import type { executeComposioTool as ExecuteComposioTool } from '../composio' -let getComposioToolsForUser: typeof GetComposioToolsForUser +let executeComposioTool: typeof ExecuteComposioTool let createSession: ReturnType let useSession: ReturnType -let getRawToolRouterSessionTools: ReturnType +let execute: ReturnType beforeAll(async () => { mock.module('server-only', () => ({})) mock.module('@composio/core', () => ({ Composio: class { - tools = { - getRawToolRouterSessionTools, - } - create = createSession use = useSession }, })) - ;({ getComposioToolsForUser } = await import('../composio')) + ;({ executeComposioTool } = await import('../composio')) }) -describe('getComposioToolsForUser', () => { +describe('executeComposioTool', () => { let logger: Logger beforeEach(() => { @@ -34,15 +30,9 @@ describe('getComposioToolsForUser', () => { info: mock(() => {}), debug: mock(() => {}), } - createSession = mock(async () => ({ sessionId: 'fresh-session' })) - useSession = mock(async () => ({ sessionId: 'stored-session' })) - getRawToolRouterSessionTools = mock(async () => [ - { - slug: 'COMPOSIO_SEARCH_TOOLS', - inputParameters: { type: 'object', properties: {} }, - description: 'Search tools', - }, - ]) + execute = mock(async () => ({ ok: true })) + createSession = mock(async () => ({ sessionId: 'fresh-session', execute })) + useSession = mock(async () => ({ sessionId: 'stored-session', execute })) }) function makeDb(storedSessionIds: string | null | Array) { @@ -97,23 +87,16 @@ describe('getComposioToolsForUser', () => { 'fresh-session', ]) - const result = await getComposioToolsForUser({ + const result = await executeComposioTool({ db, userId: 'user-123', logger, apiKey: 'test-composio-api-key', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { queries: ['gmail'], session: { generate_id: true } }, }) - expect(result).toEqual({ - sessionId: 'fresh-session', - tools: [ - { - toolName: 'COMPOSIO_SEARCH_TOOLS', - inputSchema: { type: 'object', properties: {} }, - description: 'Search tools', - }, - ], - }) + expect(result).toEqual([{ type: 'json', value: { ok: true } }]) expect(useSession).toHaveBeenCalledWith('stored-session') expect(whereDelete).toHaveBeenCalledTimes(1) expect(createSession).toHaveBeenCalledWith('user-123') @@ -124,21 +107,23 @@ describe('getComposioToolsForUser', () => { }) test('returns the persisted session when concurrent creation stores a different session', async () => { - createSession = mock(async () => ({ sessionId: 'losing-session' })) - useSession = mock(async () => ({ sessionId: 'winning-session' })) + createSession = mock(async () => ({ sessionId: 'losing-session', execute })) + useSession = mock(async () => ({ sessionId: 'winning-session', execute })) const { db, values, onConflictDoNothing } = makeDb([ null, 'winning-session', ]) - const result = await getComposioToolsForUser({ + const result = await executeComposioTool({ db, userId: 'user-123', logger, apiKey: 'test-composio-api-key', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { queries: ['gmail'], session: { generate_id: true } }, }) - expect(result?.sessionId).toBe('winning-session') + expect(result).toEqual([{ type: 'json', value: { ok: true } }]) expect(createSession).toHaveBeenCalledWith('user-123') expect(values).toHaveBeenCalledWith({ user_id: 'user-123', @@ -146,7 +131,10 @@ describe('getComposioToolsForUser', () => { }) expect(onConflictDoNothing).toHaveBeenCalledTimes(1) expect(useSession).toHaveBeenCalledWith('winning-session') - expect(getRawToolRouterSessionTools).toHaveBeenCalledWith('winning-session') + expect(execute).toHaveBeenCalledWith('COMPOSIO_SEARCH_TOOLS', { + queries: ['gmail'], + session: { generate_id: true }, + }) }) test('keeps the stored session row when rehydration fails transiently', async () => { @@ -159,11 +147,13 @@ describe('getComposioToolsForUser', () => { const { db, whereDelete } = makeDb('stored-session') await expect( - getComposioToolsForUser({ + executeComposioTool({ db, userId: 'user-123', logger, apiKey: 'test-composio-api-key', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { queries: ['gmail'], session: { generate_id: true } }, }), ).rejects.toThrow('Composio unavailable') diff --git a/web/src/server/composio-rate-limiter.ts b/web/src/server/composio-rate-limiter.ts index 8b9174e539..c6276a5a4f 100644 --- a/web/src/server/composio-rate-limiter.ts +++ b/web/src/server/composio-rate-limiter.ts @@ -2,7 +2,7 @@ const SECOND_MS = 1000 const MINUTE_MS = 60 * SECOND_MS const HOUR_MS = 60 * MINUTE_MS -export type ComposioRateLimitAction = 'tools' | 'execute' +export type ComposioRateLimitAction = 'execute' export type ComposioRateLimitResult = | { limited: false } @@ -20,10 +20,6 @@ type WindowTracker = { } const RATE_WINDOWS_BY_ACTION: Record = { - tools: [ - { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 30 }, - { name: '1 hour', windowMs: HOUR_MS, maxRequests: 300 }, - ], execute: [ { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 120 }, { name: '1 hour', windowMs: HOUR_MS, maxRequests: 1_000 }, diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index a6470c1906..90e7876389 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -11,7 +11,7 @@ import { import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' import * as schema from '@codebuff/internal/db/schema' -import { Composio, type Tool } from '@composio/core' +import { Composio } from '@composio/core' import { and, eq } from 'drizzle-orm' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -25,17 +25,10 @@ const allowedToolNames = new Set(COMPOSIO_META_TOOL_NAMES) type ComposioSession = Awaited> type ComposioClient = Composio -export type ComposioToolDefinition = { - toolName: string - inputSchema: Record - description: string -} - type CachedComposioSession = { userId: string sessionId: string session: ComposioSession - tools: ComposioToolDefinition[] } function parseEnvFileValue(contents: string, key: string): string | undefined { @@ -80,23 +73,6 @@ function toJsonValue(value: unknown): JSONValue { } } -function normalizeInputSchema(schema: Tool['inputParameters']) { - if (schema && typeof schema === 'object') { - const jsonSchema = JSON.parse(JSON.stringify(schema)) as Record< - string, - unknown - > - delete jsonSchema['$schema'] - return jsonSchema - } - - return { - type: 'object', - properties: {}, - additionalProperties: true, - } satisfies Record -} - function getComposioClient(apiKey: string): ComposioClient { return new Composio({ apiKey, @@ -104,23 +80,6 @@ function getComposioClient(apiKey: string): ComposioClient { }) } -async function getToolDefinitionsForSession(params: { - composio: ComposioClient - sessionId: string -}): Promise { - const rawTools = await params.composio.tools.getRawToolRouterSessionTools( - params.sessionId, - ) - - return rawTools - .filter((tool) => allowedToolNames.has(tool.slug)) - .map((tool) => ({ - toolName: tool.slug, - inputSchema: normalizeInputSchema(tool.inputParameters), - description: tool.description ?? `Execute ${tool.slug} with Composio.`, - })) -} - async function insertSessionIfAbsent(params: { db: CodebuffPgDatabase userId: string @@ -209,7 +168,6 @@ async function createSessionForUser(params: { userId: params.userId, sessionId: storedSession.session_id, apiKey: params.apiKey, - includeTools: true, }) } @@ -217,10 +175,6 @@ async function createSessionForUser(params: { userId: params.userId, sessionId: session.sessionId, session, - tools: await getToolDefinitionsForSession({ - composio, - sessionId: session.sessionId, - }), } return cachedSession } @@ -271,7 +225,6 @@ async function rehydrateSession(params: { userId: string sessionId: string apiKey: string - includeTools: boolean }): Promise { const composio = getComposioClient(params.apiKey) const session = await composio.use(params.sessionId) @@ -279,12 +232,6 @@ async function rehydrateSession(params: { userId: params.userId, sessionId: params.sessionId, session, - tools: params.includeTools - ? await getToolDefinitionsForSession({ - composio, - sessionId: params.sessionId, - }) - : [], } return cachedSession } @@ -313,7 +260,6 @@ async function getSessionForUser(params: { userId: params.userId, sessionId: storedSession.session_id, apiKey, - includeTools: true, }) } catch (error) { if (!isInvalidStoredSessionError(error)) { @@ -355,27 +301,13 @@ async function getSessionForUser(params: { } } -export async function getComposioToolsForUser(params: { - db: CodebuffPgDatabase - userId: string - logger: Logger - apiKey?: string -}): Promise<{ sessionId: string; tools: ComposioToolDefinition[] } | null> { - const cached = await getSessionForUser(params) - if (!cached) return null - - return { - sessionId: cached.sessionId, - tools: cached.tools, - } -} - export async function executeComposioTool(params: { db: CodebuffPgDatabase userId: string - sessionId: string + sessionId?: string toolName: string input: Record + logger: Logger apiKey?: string }): Promise { if (!allowedToolNames.has(params.toolName)) { @@ -392,22 +324,34 @@ export async function executeComposioTool(params: { const apiKey = params.apiKey ?? getComposioApiKey() if (!apiKey) return null - const storedSession = await getStoredSessionById({ - db: params.db, - userId: params.userId, - sessionId: params.sessionId, - }) - if (!storedSession) { - return null - } + let cached: CachedComposioSession + if (params.sessionId) { + const storedSession = await getStoredSessionById({ + db: params.db, + userId: params.userId, + sessionId: params.sessionId, + }) + if (!storedSession) { + return null + } - try { - const cached = await rehydrateSession({ + cached = await rehydrateSession({ userId: params.userId, sessionId: params.sessionId, apiKey, - includeTools: false, }) + } else { + const userSession = await getSessionForUser({ + db: params.db, + userId: params.userId, + logger: params.logger, + apiKey, + }) + if (!userSession) return null + cached = userSession + } + + try { const result = await cached.session.execute(params.toolName, params.input) return [{ type: 'json', value: toJsonValue(result) }] } catch (error) { From 5fdc4edbbaaf0827ee4eae916ae303bb48d4b67f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 15:15:43 -0700 Subject: [PATCH 08/15] Remove leftover Composio schema type churn --- sdk/src/custom-tool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/custom-tool.ts b/sdk/src/custom-tool.ts index 92fb0c51c7..943ac22c6d 100644 --- a/sdk/src/custom-tool.ts +++ b/sdk/src/custom-tool.ts @@ -1,5 +1,5 @@ -import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' import type { ToolName } from '@codebuff/common/tools/constants' +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' import type { z } from 'zod/v4' export type CustomToolDefinition< From 3d33384fd68a271b68a078d7f9972ecb42000cf7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 15:22:06 -0700 Subject: [PATCH 09/15] Disable Composio workbench tools --- common/src/constants/composio.ts | 2 -- sdk/src/composio.ts | 44 ++--------------------- web/src/server/__tests__/composio.test.ts | 8 +++-- web/src/server/composio.ts | 8 ++++- 4 files changed, 16 insertions(+), 46 deletions(-) diff --git a/common/src/constants/composio.ts b/common/src/constants/composio.ts index 864051cfad..96ef40aeed 100644 --- a/common/src/constants/composio.ts +++ b/common/src/constants/composio.ts @@ -3,8 +3,6 @@ export const COMPOSIO_API_KEY_ENV_VAR = 'COMPOSIO_API_KEY' export const COMPOSIO_META_TOOL_NAMES = [ 'COMPOSIO_MANAGE_CONNECTIONS', 'COMPOSIO_MULTI_EXECUTE_TOOL', - 'COMPOSIO_REMOTE_BASH_TOOL', - 'COMPOSIO_REMOTE_WORKBENCH', 'COMPOSIO_SEARCH_TOOLS', 'COMPOSIO_GET_TOOL_SCHEMAS', ] as const diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index c7343010bc..ecf26c442b 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -17,17 +17,6 @@ const sessionIdParam = z .optional() .describe('Session ID returned by COMPOSIO_SEARCH_TOOLS, when available.') -const workflowStepParams = { - current_step: z - .string() - .optional() - .describe('Short enum-style label for the current workflow step.'), - current_step_metric: z - .string() - .optional() - .describe('Progress metric such as "3/10 emails" or "0/n messages".'), -} - const composioMetaToolSchemas = { COMPOSIO_SEARCH_TOOLS: z .object({ @@ -87,31 +76,8 @@ const composioMetaToolSchemas = { .describe('One concise sentence explaining the execution intent.'), sync_response_to_workbench: z .boolean() - .describe('Use true when the response may be large or reused later.'), - session_id: sessionIdParam, - ...workflowStepParams, - }) - .catchall(z.unknown()), - COMPOSIO_REMOTE_WORKBENCH: z - .object({ - code_to_execute: z - .string() - .describe('Python code to run in the persistent remote workbench.'), - thought: z - .string() - .optional() - .describe( - 'One concise sentence describing why the workbench is needed.', - ), - session_id: sessionIdParam, - ...workflowStepParams, - }) - .catchall(z.unknown()), - COMPOSIO_REMOTE_BASH_TOOL: z - .object({ - command: z - .string() - .describe('Bash command to run in the remote sandbox.'), + .default(false) + .describe('Always use false. Codebuff disables Composio workbench.'), session_id: sessionIdParam, }) .catchall(z.unknown()), @@ -125,11 +91,7 @@ const composioMetaToolDescriptions = { COMPOSIO_MANAGE_CONNECTIONS: 'Check or initiate user authentication for external app toolkits. Use when search/execution indicates a toolkit is not connected.', COMPOSIO_MULTI_EXECUTE_TOOL: - 'Execute one or more discovered Composio app tools in the current workflow session.', - COMPOSIO_REMOTE_WORKBENCH: - 'Run Python in a persistent Composio workbench for bulk app workflows, large responses, or data transformations.', - COMPOSIO_REMOTE_BASH_TOOL: - 'Run bash commands in the Composio remote sandbox for simple file and data processing.', + 'Execute one or more discovered Composio app tools in the current workflow session. Do not use workbench offloading.', } satisfies Record function toJsonValue(value: unknown): JSONValue { diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index 26c10392ef..91fddfd941 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -99,7 +99,9 @@ describe('executeComposioTool', () => { expect(result).toEqual([{ type: 'json', value: { ok: true } }]) expect(useSession).toHaveBeenCalledWith('stored-session') expect(whereDelete).toHaveBeenCalledTimes(1) - expect(createSession).toHaveBeenCalledWith('user-123') + expect(createSession).toHaveBeenCalledWith('user-123', { + workbench: { enable: false }, + }) expect(values).toHaveBeenCalledWith({ user_id: 'user-123', session_id: 'fresh-session', @@ -124,7 +126,9 @@ describe('executeComposioTool', () => { }) expect(result).toEqual([{ type: 'json', value: { ok: true } }]) - expect(createSession).toHaveBeenCalledWith('user-123') + expect(createSession).toHaveBeenCalledWith('user-123', { + workbench: { enable: false }, + }) expect(values).toHaveBeenCalledWith({ user_id: 'user-123', session_id: 'losing-session', diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 90e7876389..5d9e712b82 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -80,6 +80,12 @@ function getComposioClient(apiKey: string): ComposioClient { }) } +const COMPOSIO_SESSION_CONFIG = { + workbench: { + enable: false, + }, +} as const + async function insertSessionIfAbsent(params: { db: CodebuffPgDatabase userId: string @@ -140,7 +146,7 @@ async function createSessionForUser(params: { logger: Logger }): Promise { const composio = getComposioClient(params.apiKey) - const session = await composio.create(params.userId) + const session = await composio.create(params.userId, COMPOSIO_SESSION_CONFIG) await insertSessionIfAbsent({ db: params.db, userId: params.userId, From b6946e8d86380acb70e3f1562bdddcb78c0478df Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 16:00:02 -0700 Subject: [PATCH 10/15] Simplify Composio tool integration --- common/src/tools/constants.ts | 3 + common/src/tools/list.ts | 20 ++- common/src/tools/params/tool/composio.ts | 131 ++++++++++++++++++ .../agent-runtime/src/tools/handlers/list.ts | 10 ++ .../src/tools/handlers/tool/composio.ts | 35 +++++ sdk/src/__tests__/composio.test.ts | 37 ++--- sdk/src/composio.ts | 116 ++-------------- sdk/src/run.ts | 77 +++++----- .../v1/composio/__tests__/composio.test.ts | 74 +--------- web/src/app/api/v1/composio/execute/_post.ts | 7 +- .../__tests__/composio-rate-limiter.test.ts | 6 +- web/src/server/__tests__/composio.test.ts | 22 +++ web/src/server/composio-rate-limiter.ts | 29 ++-- web/src/server/composio.ts | 65 ++++----- 14 files changed, 326 insertions(+), 306 deletions(-) create mode 100644 common/src/tools/params/tool/composio.ts create mode 100644 packages/agent-runtime/src/tools/handlers/tool/composio.ts diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 5fe789eb76..89c89f7038 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -1,3 +1,5 @@ +import { COMPOSIO_META_TOOL_NAMES } from '../constants/composio' + import type { ToolResultOutput } from '../types/messages/content-part' import type { Tool } from 'ai' @@ -56,6 +58,7 @@ export const toolNames = [ 'web_search', 'write_file', 'write_todos', + ...COMPOSIO_META_TOOL_NAMES, ] as const export const publishedTools = [ diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index 4f40570d0e..dd75531ab2 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -7,6 +7,7 @@ import { applyPatchParams } from './params/tool/apply-patch' import { askUserParams } from './params/tool/ask-user' import { browserLogsParams } from './params/tool/browser-logs' import { codeSearchParams } from './params/tool/code-search' +import { composioMetaToolParams } from './params/tool/composio' import { createPlanParams } from './params/tool/create-plan' import { endTurnParams } from './params/tool/end-turn' import { findFilesParams } from './params/tool/find-files' @@ -77,6 +78,7 @@ export const toolParams = { web_search: webSearchParams, write_file: writeFileParams, write_todos: writeTodosParams, + ...composioMetaToolParams, } satisfies { [K in ToolName]: $ToolParams } @@ -151,6 +153,22 @@ export const clientToolCallSchema = z.discriminatedUnion('toolName', [ toolName: z.literal('write_file'), input: FileChangeSchema, }), + z.object({ + toolName: z.literal('COMPOSIO_MANAGE_CONNECTIONS'), + input: toolParams.COMPOSIO_MANAGE_CONNECTIONS.inputSchema, + }), + z.object({ + toolName: z.literal('COMPOSIO_MULTI_EXECUTE_TOOL'), + input: toolParams.COMPOSIO_MULTI_EXECUTE_TOOL.inputSchema, + }), + z.object({ + toolName: z.literal('COMPOSIO_SEARCH_TOOLS'), + input: toolParams.COMPOSIO_SEARCH_TOOLS.inputSchema, + }), + z.object({ + toolName: z.literal('COMPOSIO_GET_TOOL_SCHEMAS'), + input: toolParams.COMPOSIO_GET_TOOL_SCHEMAS.inputSchema, + }), ]) export const clientToolNames = clientToolCallSchema.def.options.map( (opt) => opt.shape.toolName.value, @@ -163,4 +181,4 @@ export type ClientToolCall = Extract< > & Pick -export type PublishedClientToolName = ClientToolName & PublishedToolName +export type PublishedClientToolName = Extract diff --git a/common/src/tools/params/tool/composio.ts b/common/src/tools/params/tool/composio.ts new file mode 100644 index 0000000000..a6f32a6cea --- /dev/null +++ b/common/src/tools/params/tool/composio.ts @@ -0,0 +1,131 @@ +import { COMPOSIO_META_TOOL_NAMES } from '../../../constants/composio' +import z from 'zod/v4' + +import { jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const sessionIdParam = z + .string() + .optional() + .describe('Session ID returned by COMPOSIO_SEARCH_TOOLS, when available.') + +const composioMetaToolInputSchemas = { + COMPOSIO_SEARCH_TOOLS: z + .object({ + queries: z + .array(z.unknown()) + .min(1) + .describe( + 'Structured English search queries. Split independent app/API actions into separate queries.', + ), + session: z + .object({ + generate_id: z.boolean().optional(), + id: z.string().optional(), + }) + .catchall(z.unknown()) + .describe( + 'Use { generate_id: true } for a new workflow, or { id } to continue one.', + ), + model: z.string().optional().describe('Client LLM model name.'), + }) + .catchall(z.unknown()), + COMPOSIO_GET_TOOL_SCHEMAS: z + .object({ + tool_slugs: z + .array(z.string()) + .min(1) + .describe('Composio tool slugs to retrieve schemas for.'), + include: z + .array(z.string()) + .optional() + .describe('Schema fields to include, e.g. input_schema/output_schema.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), + COMPOSIO_MANAGE_CONNECTIONS: z + .object({ + toolkits: z + .array(z.string()) + .min(1) + .describe('Toolkit slugs to check or connect, such as gmail/github.'), + reinitiate_all: z + .boolean() + .optional() + .describe('Force reconnection even if active credentials exist.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), + COMPOSIO_MULTI_EXECUTE_TOOL: z + .object({ + tools: z + .array(z.record(z.string(), z.unknown())) + .min(1) + .describe('Logically independent Composio tools to execute.'), + thought: z + .string() + .optional() + .describe('One concise sentence explaining the execution intent.'), + sync_response_to_workbench: z + .boolean() + .default(false) + .describe('Always use false. Codebuff disables Composio workbench.'), + session_id: sessionIdParam, + }) + .catchall(z.unknown()), +} + +const composioMetaToolDescriptions = { + COMPOSIO_SEARCH_TOOLS: + 'Discover relevant Composio tools across external apps. Use this first for requests involving services like Gmail, GitHub, Slack, Linear, Notion, Google Calendar, or Google Sheets.', + COMPOSIO_GET_TOOL_SCHEMAS: + 'Retrieve complete input schemas for specific Composio tool slugs returned by COMPOSIO_SEARCH_TOOLS.', + COMPOSIO_MANAGE_CONNECTIONS: + 'Check or initiate user authentication for external app toolkits. Use when search/execution indicates a toolkit is not connected.', + COMPOSIO_MULTI_EXECUTE_TOOL: + 'Execute one or more discovered Composio app tools in the current workflow session. Do not use workbench offloading.', +} + +const composioOutputSchema = jsonToolResultSchema( + z.union([ + z.json(), + z.object({ + errorMessage: z.string(), + status: z.number().optional(), + }), + ]), +) + +export const composioMetaToolParams = { + COMPOSIO_MANAGE_CONNECTIONS: { + toolName: 'COMPOSIO_MANAGE_CONNECTIONS', + endsAgentStep: true, + description: composioMetaToolDescriptions.COMPOSIO_MANAGE_CONNECTIONS, + inputSchema: composioMetaToolInputSchemas.COMPOSIO_MANAGE_CONNECTIONS, + outputSchema: composioOutputSchema, + }, + COMPOSIO_MULTI_EXECUTE_TOOL: { + toolName: 'COMPOSIO_MULTI_EXECUTE_TOOL', + endsAgentStep: true, + description: composioMetaToolDescriptions.COMPOSIO_MULTI_EXECUTE_TOOL, + inputSchema: composioMetaToolInputSchemas.COMPOSIO_MULTI_EXECUTE_TOOL, + outputSchema: composioOutputSchema, + }, + COMPOSIO_SEARCH_TOOLS: { + toolName: 'COMPOSIO_SEARCH_TOOLS', + endsAgentStep: true, + description: composioMetaToolDescriptions.COMPOSIO_SEARCH_TOOLS, + inputSchema: composioMetaToolInputSchemas.COMPOSIO_SEARCH_TOOLS, + outputSchema: composioOutputSchema, + }, + COMPOSIO_GET_TOOL_SCHEMAS: { + toolName: 'COMPOSIO_GET_TOOL_SCHEMAS', + endsAgentStep: true, + description: composioMetaToolDescriptions.COMPOSIO_GET_TOOL_SCHEMAS, + inputSchema: composioMetaToolInputSchemas.COMPOSIO_GET_TOOL_SCHEMAS, + outputSchema: composioOutputSchema, + }, +} satisfies { + [K in (typeof COMPOSIO_META_TOOL_NAMES)[number]]: $ToolParams +} diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index abb7c340db..c302f4e373 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -4,6 +4,12 @@ import { handleApplyPatch } from './tool/apply-patch' import { handleAskUser } from './tool/ask-user' import { handleBrowserLogs } from './tool/browser-logs' import { handleCodeSearch } from './tool/code-search' +import { + handleComposioGetToolSchemas, + handleComposioManageConnections, + handleComposioMultiExecute, + handleComposioSearchTools, +} from './tool/composio' import { handleCreatePlan } from './tool/create-plan' import { handleEndTurn } from './tool/end-turn' import { handleFindFiles } from './tool/find-files' @@ -53,6 +59,10 @@ export const codebuffToolHandlers = { ask_user: handleAskUser, browser_logs: handleBrowserLogs, code_search: handleCodeSearch, + COMPOSIO_MANAGE_CONNECTIONS: handleComposioManageConnections, + COMPOSIO_MULTI_EXECUTE_TOOL: handleComposioMultiExecute, + COMPOSIO_SEARCH_TOOLS: handleComposioSearchTools, + COMPOSIO_GET_TOOL_SCHEMAS: handleComposioGetToolSchemas, create_plan: handleCreatePlan, end_turn: handleEndTurn, find_files: handleFindFiles, diff --git a/packages/agent-runtime/src/tools/handlers/tool/composio.ts b/packages/agent-runtime/src/tools/handlers/tool/composio.ts new file mode 100644 index 0000000000..e566440fd3 --- /dev/null +++ b/packages/agent-runtime/src/tools/handlers/tool/composio.ts @@ -0,0 +1,35 @@ +import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' +import type { CodebuffToolOutput } from '@codebuff/common/tools/list' +import type { CodebuffToolHandlerFunction } from '../handler-function-type' + +function makeComposioHandler() { + return (async ({ toolCall, requestClientToolCall }) => { + if (!requestClientToolCall) { + return { + output: [ + { + type: 'json', + value: { + errorMessage: 'Composio tools are not available in this runtime.', + }, + }, + ], + } + } + + return { + output: (await (requestClientToolCall as any)( + toolCall, + )) as CodebuffToolOutput, + } + }) satisfies CodebuffToolHandlerFunction +} + +export const handleComposioManageConnections = + makeComposioHandler<'COMPOSIO_MANAGE_CONNECTIONS'>() +export const handleComposioMultiExecute = + makeComposioHandler<'COMPOSIO_MULTI_EXECUTE_TOOL'>() +export const handleComposioSearchTools = + makeComposioHandler<'COMPOSIO_SEARCH_TOOLS'>() +export const handleComposioGetToolSchemas = + makeComposioHandler<'COMPOSIO_GET_TOOL_SCHEMAS'>() diff --git a/sdk/src/__tests__/composio.test.ts b/sdk/src/__tests__/composio.test.ts index 1c524c09fc..c43305b40a 100644 --- a/sdk/src/__tests__/composio.test.ts +++ b/sdk/src/__tests__/composio.test.ts @@ -1,27 +1,28 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' +import { clientToolNames, toolParams } from '@codebuff/common/tools/list' -import { getComposioMetaToolDefinitions } from '../composio' +import { + executeComposioToolViaServer, + normalizeComposioInput, +} from '../composio' -describe('getComposioMetaToolDefinitions', () => { +describe('Composio SDK tools', () => { const originalFetch = globalThis.fetch afterEach(() => { globalThis.fetch = originalFetch }) - test('returns static Composio meta tool definitions without discovery fetch', () => { + test('registers Composio meta tools as static client tools without discovery fetch', () => { const fetchMock = mock(async () => new Response('{}')) globalThis.fetch = fetchMock as unknown as typeof fetch - const tools = getComposioMetaToolDefinitions({ - apiKey: 'codebuff-api-key', - }) - - expect(tools.map((tool) => tool.toolName)).toEqual([ - ...COMPOSIO_META_TOOL_NAMES, - ]) + for (const toolName of COMPOSIO_META_TOOL_NAMES) { + expect(clientToolNames).toContain(toolName) + expect(toolParams[toolName].inputSchema).toBeDefined() + } expect(fetchMock).not.toHaveBeenCalled() }) @@ -50,16 +51,20 @@ describe('getComposioMetaToolDefinitions', () => { ) globalThis.fetch = fetchMock as unknown as typeof fetch - const searchTool = getComposioMetaToolDefinitions({ + const output = await executeComposioToolViaServer({ apiKey: 'codebuff-api-key', - }).find((tool) => tool.toolName === 'COMPOSIO_SEARCH_TOOLS') - - const output = await searchTool?.execute({ - queries: ['find gmail tools'], - session: { generate_id: true }, + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { + queries: ['find gmail tools'], + session: { generate_id: true }, + }, }) expect(output).toEqual([{ type: 'json', value: { ok: true } }]) expect(fetchMock).toHaveBeenCalledTimes(1) }) + + test('normalizes non-object Composio inputs for server execution', () => { + expect(normalizeComposioInput('gmail')).toEqual({ value: 'gmail' }) + }) }) diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index ecf26c442b..4de641a139 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -1,10 +1,6 @@ -import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' -import { z } from 'zod/v4' - import { WEBSITE_URL } from './constants' import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' -import type { CustomToolDefinition } from './custom-tool' import type { JSONValue } from '@codebuff/common/types/json' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' @@ -12,88 +8,6 @@ type ComposioExecuteResponse = { output: ToolResultOutput[] } -const sessionIdParam = z - .string() - .optional() - .describe('Session ID returned by COMPOSIO_SEARCH_TOOLS, when available.') - -const composioMetaToolSchemas = { - COMPOSIO_SEARCH_TOOLS: z - .object({ - queries: z - .array(z.unknown()) - .min(1) - .describe( - 'Structured English search queries. Split independent app/API actions into separate queries.', - ), - session: z - .object({ - generate_id: z.boolean().optional(), - id: z.string().optional(), - }) - .catchall(z.unknown()) - .describe( - 'Use { generate_id: true } for a new workflow, or { id } to continue one.', - ), - model: z.string().optional().describe('Client LLM model name.'), - }) - .catchall(z.unknown()), - COMPOSIO_GET_TOOL_SCHEMAS: z - .object({ - tool_slugs: z - .array(z.string()) - .min(1) - .describe('Composio tool slugs to retrieve schemas for.'), - include: z - .array(z.string()) - .optional() - .describe('Schema fields to include, e.g. input_schema/output_schema.'), - session_id: sessionIdParam, - }) - .catchall(z.unknown()), - COMPOSIO_MANAGE_CONNECTIONS: z - .object({ - toolkits: z - .array(z.string()) - .min(1) - .describe('Toolkit slugs to check or connect, such as gmail/github.'), - reinitiate_all: z - .boolean() - .optional() - .describe('Force reconnection even if active credentials exist.'), - session_id: sessionIdParam, - }) - .catchall(z.unknown()), - COMPOSIO_MULTI_EXECUTE_TOOL: z - .object({ - tools: z - .array(z.record(z.string(), z.unknown())) - .min(1) - .describe('Logically independent Composio tools to execute.'), - thought: z - .string() - .optional() - .describe('One concise sentence explaining the execution intent.'), - sync_response_to_workbench: z - .boolean() - .default(false) - .describe('Always use false. Codebuff disables Composio workbench.'), - session_id: sessionIdParam, - }) - .catchall(z.unknown()), -} satisfies Record - -const composioMetaToolDescriptions = { - COMPOSIO_SEARCH_TOOLS: - 'Discover relevant Composio tools across external apps. Use this first for requests involving services like Gmail, GitHub, Slack, Linear, Notion, Google Calendar, or Google Sheets.', - COMPOSIO_GET_TOOL_SCHEMAS: - 'Retrieve complete input schemas for specific Composio tool slugs returned by COMPOSIO_SEARCH_TOOLS.', - COMPOSIO_MANAGE_CONNECTIONS: - 'Check or initiate user authentication for external app toolkits. Use when search/execution indicates a toolkit is not connected.', - COMPOSIO_MULTI_EXECUTE_TOOL: - 'Execute one or more discovered Composio app tools in the current workflow session. Do not use workbench offloading.', -} satisfies Record - function toJsonValue(value: unknown): JSONValue { try { return JSON.parse(JSON.stringify(value ?? null)) as JSONValue @@ -114,9 +28,9 @@ async function readErrorMessage(response: Response): Promise { } } -async function executeComposioToolViaServer(params: { +export async function executeComposioToolViaServer(params: { apiKey: string - toolName: string + toolName: ComposioMetaToolName input: Record }): Promise { try { @@ -161,24 +75,10 @@ async function executeComposioToolViaServer(params: { } } -export function getComposioMetaToolDefinitions(params: { - apiKey: string -}): CustomToolDefinition[] { - return COMPOSIO_META_TOOL_NAMES.map((toolName) => ({ - toolName, - inputSchema: composioMetaToolSchemas[toolName], - description: composioMetaToolDescriptions[toolName], - endsAgentStep: true, - exampleInputs: [], - execute: async (input: unknown) => { - return executeComposioToolViaServer({ - apiKey: params.apiKey, - toolName, - input: - input && typeof input === 'object' - ? (input as Record) - : { value: toJsonValue(input) }, - }) - }, - })) +export function normalizeComposioInput( + input: unknown, +): Record { + return input && typeof input === 'object' + ? (input as Record) + : { value: toJsonValue(input) } } diff --git a/sdk/src/run.ts b/sdk/src/run.ts index d635fe1697..1106349629 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -12,13 +12,20 @@ import { listMCPTools, callMCPTool, } from '@codebuff/common/mcp/client' +import { + COMPOSIO_META_TOOL_NAMES, + type ComposioMetaToolName, +} from '@codebuff/common/constants/composio' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' import { extractApiErrorDetails } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' -import { getComposioMetaToolDefinitions } from './composio' +import { + executeComposioToolViaServer, + normalizeComposioInput, +} from './composio' import { getErrorStatusCode } from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' @@ -38,16 +45,8 @@ import type { RunState } from './run-state' import type { FileFilter } from './tools/read-files' import type { ServerAction } from '@codebuff/common/actions' import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition' -import type { - PublishedToolName, - ToolName, -} from '@codebuff/common/tools/constants' -import type { - ClientToolCall, - ClientToolName, - CodebuffToolOutput, - PublishedClientToolName, -} from '@codebuff/common/tools/list' +import type { ToolName } from '@codebuff/common/tools/constants' +import type { PublishedClientToolName } from '@codebuff/common/tools/list' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message' @@ -75,6 +74,17 @@ const wrapContentForUserMessage = ( return buildUserMessageContent(undefined, undefined, content) } +type OverrideToolHandlers = { + [K in PublishedClientToolName]?: ( + input: Record, + ) => Promise +} & { + // Include read_files separately, since it has a different signature. + read_files?: (input: { + filePaths: string[] + }) => Promise> +} + export type CodebuffClientOptions = { apiKey?: string @@ -108,18 +118,7 @@ export type CodebuffClientOptions = { /** Optional filter to classify files before reading (runs before gitignore check) */ fileFilter?: FileFilter - overrideTools?: Partial< - { - [K in ClientToolName & PublishedToolName]: ( - input: ClientToolCall['input'], - ) => Promise> - } & { - // Include read_files separately, since it has a different signature. - read_files: (input: { - filePaths: string[] - }) => Promise> - } - > + overrideTools?: OverrideToolHandlers customToolDefinitions?: CustomToolDefinition[] fsSource?: Source @@ -276,6 +275,10 @@ async function runOnce({ } const traceSessionId = previousRun?.traceSessionId ?? crypto.randomUUID() + for (const toolName of COMPOSIO_META_TOOL_NAMES) { + delete sessionState.fileContext.customToolDefinitions[toolName] + } + let resolve: (value: RunReturnType) => any = () => {} let _reject: (error: any) => any = () => {} const promise = new Promise((res, rej) => { @@ -406,6 +409,7 @@ async function runOnce({ cwd, fs, env, + apiKey, }) }, requestMcpToolData: async ({ mcpConfig, toolNames }) => { @@ -519,23 +523,6 @@ async function runOnce({ return getCancelledRunState('Run cancelled by user.') } - const composioCustomToolDefinitions = getComposioMetaToolDefinitions({ - apiKey, - }) - - activeCustomToolDefinitions = [ - ...activeCustomToolDefinitions, - ...composioCustomToolDefinitions, - ] - sessionState = await applyOverridesToSessionState(cwd, sessionState, { - customToolDefinitions: activeCustomToolDefinitions, - }) - initialMessageHistory = sessionState.mainAgentState.messageHistory - - if (signal?.aborted) { - return getCancelledRunState('Run cancelled by user.') - } - callMainPrompt({ ...agentRuntimeImpl, promptId, @@ -636,6 +623,7 @@ async function handleToolCall({ cwd, fs, env, + apiKey, }: { action: ServerAction<'tool-call-request'> overrides: NonNullable @@ -643,6 +631,7 @@ async function handleToolCall({ cwd?: string fs: CodebuffFileSystem env?: Record + apiKey: string }): Promise<{ output: ToolResultOutput[] }> { const toolName = action.toolName const input = action.input @@ -753,6 +742,14 @@ async function handleToolCall({ }, }, ] + } else if ( + COMPOSIO_META_TOOL_NAMES.includes(toolName as ComposioMetaToolName) + ) { + result = await executeComposioToolViaServer({ + apiKey, + toolName: toolName as ComposioMetaToolName, + input: normalizeComposioInput(input), + }) } else { throw new Error( `Tool not implemented in SDK. Please provide an override or modify your agent to not use this tool: ${toolName}`, diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index f2ddf75714..02f280e5d0 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -65,46 +65,6 @@ describe('/api/v1/composio', () => { { type: 'json' as const, value: { ok: true } }, ]) const checkRateLimit = mock(() => ({ limited: false as const })) - const req = new NextRequest('http://localhost/api/v1/composio/execute', { - method: 'POST', - headers: { Authorization: 'Bearer valid-key' }, - body: JSON.stringify({ - sessionId: 'session-123', - toolName: 'COMPOSIO_SEARCH_TOOLS', - input: { query: 'gmail' }, - }), - }) - - const response = await postComposioExecute({ - req, - getUserInfoFromApiKey, - db: mockDb, - logger, - loggerWithContext, - executeTool, - checkRateLimit, - isConfigured: () => true, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - output: [{ type: 'json', value: { ok: true } }], - }) - expect(executeTool).toHaveBeenCalledWith({ - db: mockDb, - userId: 'user-123', - logger, - sessionId: 'session-123', - toolName: 'COMPOSIO_SEARCH_TOOLS', - input: { query: 'gmail' }, - }) - expect(checkRateLimit).toHaveBeenCalledWith('user-123', 'execute') - }) - - test('executes a Composio tool without a client-provided session ID', async () => { - const executeTool = mock(async () => [ - { type: 'json' as const, value: { ok: true } }, - ]) const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, @@ -124,7 +84,7 @@ describe('/api/v1/composio', () => { logger, loggerWithContext, executeTool, - checkRateLimit: mock(() => ({ limited: false as const })), + checkRateLimit, isConfigured: () => true, }) @@ -139,35 +99,7 @@ describe('/api/v1/composio', () => { session: { generate_id: true }, }, }) - }) - - test('returns 404 when a Composio session cannot be found for execute', async () => { - const executeTool = mock(async () => null) - const req = new NextRequest('http://localhost/api/v1/composio/execute', { - method: 'POST', - headers: { Authorization: 'Bearer valid-key' }, - body: JSON.stringify({ - sessionId: 'unknown-session', - toolName: 'COMPOSIO_SEARCH_TOOLS', - input: {}, - }), - }) - - const response = await postComposioExecute({ - req, - getUserInfoFromApiKey, - db: mockDb, - logger, - loggerWithContext, - executeTool, - checkRateLimit: mock(() => ({ limited: false as const })), - isConfigured: () => true, - }) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: 'Composio session not found', - }) + expect(checkRateLimit).toHaveBeenCalledWith('user-123') }) test('returns 503 when Composio execute is not configured', async () => { @@ -178,7 +110,6 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - sessionId: 'session-123', toolName: 'COMPOSIO_SEARCH_TOOLS', input: {}, }), @@ -210,7 +141,6 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - sessionId: 'session-123', toolName: 'COMPOSIO_SEARCH_TOOLS', input: {}, }), diff --git a/web/src/app/api/v1/composio/execute/_post.ts b/web/src/app/api/v1/composio/execute/_post.ts index 461cd35f72..714ef7376f 100644 --- a/web/src/app/api/v1/composio/execute/_post.ts +++ b/web/src/app/api/v1/composio/execute/_post.ts @@ -20,7 +20,6 @@ type CheckComposioRateLimitFn = typeof checkComposioRateLimit type IsComposioConfiguredFn = typeof isComposioConfigured const composioExecuteBodySchema = z.object({ - sessionId: z.string().min(1).optional(), toolName: z.string().min(1), input: z.record(z.string(), z.unknown()).default({}), }) @@ -53,7 +52,7 @@ export async function postComposioExecute(params: { ) } - const rateLimit = checkRateLimit(userInfo.id, 'execute') + const rateLimit = checkRateLimit(userInfo.id) if (rateLimit.limited) { const retryAfterSeconds = Math.ceil(rateLimit.retryAfterMs / 1000) logger.warn( @@ -104,8 +103,8 @@ export async function postComposioExecute(params: { }) if (!output) { return NextResponse.json( - { error: 'Composio session not found' }, - { status: 404 }, + { error: 'Composio is not configured' }, + { status: 503 }, ) } diff --git a/web/src/server/__tests__/composio-rate-limiter.test.ts b/web/src/server/__tests__/composio-rate-limiter.test.ts index 845b276915..dd36e26720 100644 --- a/web/src/server/__tests__/composio-rate-limiter.test.ts +++ b/web/src/server/__tests__/composio-rate-limiter.test.ts @@ -12,7 +12,7 @@ describe('checkComposioRateLimit', () => { test('allows requests below the per-minute limit', () => { for (let i = 0; i < 120; i++) { - expect(checkComposioRateLimit('user-1', 'execute')).toEqual({ + expect(checkComposioRateLimit('user-1')).toEqual({ limited: false, }) } @@ -20,10 +20,10 @@ describe('checkComposioRateLimit', () => { test('limits execution after the per-minute limit', () => { for (let i = 0; i < 120; i++) { - checkComposioRateLimit('user-1', 'execute') + checkComposioRateLimit('user-1') } - const result = checkComposioRateLimit('user-1', 'execute') + const result = checkComposioRateLimit('user-1') expect(result.limited).toBe(true) if (result.limited) { expect(result.windowName).toBe('1 minute') diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index 91fddfd941..b4337cde85 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -141,6 +141,28 @@ describe('executeComposioTool', () => { }) }) + test('forces multi-execute workbench sync off before calling Composio', async () => { + const { db } = makeDb('stored-session') + + const result = await executeComposioTool({ + db, + userId: 'user-123', + logger, + apiKey: 'test-composio-api-key', + toolName: 'COMPOSIO_MULTI_EXECUTE_TOOL', + input: { + tools: [{ slug: 'GMAIL_FETCH_EMAILS', arguments: {} }], + sync_response_to_workbench: true, + }, + }) + + expect(result).toEqual([{ type: 'json', value: { ok: true } }]) + expect(execute).toHaveBeenCalledWith('COMPOSIO_MULTI_EXECUTE_TOOL', { + tools: [{ slug: 'GMAIL_FETCH_EMAILS', arguments: {} }], + sync_response_to_workbench: false, + }) + }) + test('keeps the stored session row when rehydration fails transiently', async () => { const transientError = Object.assign(new Error('Composio unavailable'), { status: 502, diff --git a/web/src/server/composio-rate-limiter.ts b/web/src/server/composio-rate-limiter.ts index c6276a5a4f..8ed8dd8225 100644 --- a/web/src/server/composio-rate-limiter.ts +++ b/web/src/server/composio-rate-limiter.ts @@ -2,8 +2,6 @@ const SECOND_MS = 1000 const MINUTE_MS = 60 * SECOND_MS const HOUR_MS = 60 * MINUTE_MS -export type ComposioRateLimitAction = 'execute' - export type ComposioRateLimitResult = | { limited: false } | { limited: true; retryAfterMs: number; windowName: string } @@ -19,29 +17,20 @@ type WindowTracker = { windowStart: number } -const RATE_WINDOWS_BY_ACTION: Record = { - execute: [ - { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 120 }, - { name: '1 hour', windowMs: HOUR_MS, maxRequests: 1_000 }, - ], -} +const RATE_WINDOWS: RateWindow[] = [ + { name: '1 minute', windowMs: MINUTE_MS, maxRequests: 120 }, + { name: '1 hour', windowMs: HOUR_MS, maxRequests: 1_000 }, +] const userWindows = new Map>() let lastCleanupTime = 0 const CLEANUP_INTERVAL_MS = 5 * MINUTE_MS -function getRateLimitKey(userId: string, action: ComposioRateLimitAction) { - return `${userId}:${action}` -} - function cleanupExpiredEntries(): void { const now = Date.now() for (const [key, windows] of userWindows) { - const action = key.split(':').at(-1) as ComposioRateLimitAction | undefined for (const [windowName, tracker] of windows) { - const matchingWindow = - action && - RATE_WINDOWS_BY_ACTION[action]?.find((w) => w.name === windowName) + const matchingWindow = RATE_WINDOWS.find((w) => w.name === windowName) if ( !matchingWindow || now - tracker.windowStart >= matchingWindow.windowMs @@ -57,7 +46,6 @@ function cleanupExpiredEntries(): void { export function checkComposioRateLimit( userId: string, - action: ComposioRateLimitAction, ): ComposioRateLimitResult { const now = Date.now() if (now - lastCleanupTime > CLEANUP_INTERVAL_MS) { @@ -65,8 +53,7 @@ export function checkComposioRateLimit( lastCleanupTime = now } - const windowsForAction = RATE_WINDOWS_BY_ACTION[action] - const key = getRateLimitKey(userId, action) + const key = userId let windows = userWindows.get(key) if (!windows) { windows = new Map() @@ -74,7 +61,7 @@ export function checkComposioRateLimit( } // First pass checks every window without mutating counters. - for (const rateWindow of windowsForAction) { + for (const rateWindow of RATE_WINDOWS) { let tracker = windows.get(rateWindow.name) if (tracker && now - tracker.windowStart >= rateWindow.windowMs) { windows.delete(rateWindow.name) @@ -94,7 +81,7 @@ export function checkComposioRateLimit( } // Second pass increments only allowed requests. - for (const rateWindow of windowsForAction) { + for (const rateWindow of RATE_WINDOWS) { let tracker = windows.get(rateWindow.name) if (!tracker) { tracker = { count: 0, windowStart: now } diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 5d9e712b82..3399c72c11 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -111,19 +111,6 @@ async function getStoredSessionByUser(params: { }) } -async function getStoredSessionById(params: { - db: CodebuffPgDatabase - userId: string - sessionId: string -}) { - return params.db.query.composioSession.findFirst({ - where: and( - eq(schema.composioSession.user_id, params.userId), - eq(schema.composioSession.session_id, params.sessionId), - ), - }) -} - async function deleteStoredSession(params: { db: CodebuffPgDatabase userId: string @@ -310,7 +297,6 @@ async function getSessionForUser(params: { export async function executeComposioTool(params: { db: CodebuffPgDatabase userId: string - sessionId?: string toolName: string input: Record logger: Logger @@ -330,37 +316,34 @@ export async function executeComposioTool(params: { const apiKey = params.apiKey ?? getComposioApiKey() if (!apiKey) return null - let cached: CachedComposioSession - if (params.sessionId) { - const storedSession = await getStoredSessionById({ - db: params.db, - userId: params.userId, - sessionId: params.sessionId, - }) - if (!storedSession) { - return null - } - - cached = await rehydrateSession({ - userId: params.userId, - sessionId: params.sessionId, - apiKey, - }) - } else { - const userSession = await getSessionForUser({ - db: params.db, - userId: params.userId, - logger: params.logger, - apiKey, - }) - if (!userSession) return null - cached = userSession - } + const cached = await getSessionForUser({ + db: params.db, + userId: params.userId, + logger: params.logger, + apiKey, + }) + if (!cached) return null try { - const result = await cached.session.execute(params.toolName, params.input) + const input = + params.toolName === 'COMPOSIO_MULTI_EXECUTE_TOOL' + ? { + ...params.input, + sync_response_to_workbench: false, + } + : params.input + const result = await cached.session.execute(params.toolName, input) return [{ type: 'json', value: toJsonValue(result) }] } catch (error) { + params.logger.warn( + { + error: getErrorObject(error), + userId: params.userId, + sessionId: cached.sessionId, + toolName: params.toolName, + }, + 'Composio tool execution failed', + ) return [ { type: 'json', From ca750d55a9a2d0bbb5c1476571542f2dd983b8b7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 16:05:25 -0700 Subject: [PATCH 11/15] Fix Composio CI type surfaces --- .../agent-runtime/src/tools/handlers/list.ts | 6 +++--- .../src/tools/handlers/tool/composio.ts | 16 +++++++++------- sdk/src/run.ts | 4 +--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index c302f4e373..c0cc69859a 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -52,7 +52,9 @@ import type { ToolName } from '@codebuff/common/tools/constants' * - Any additional arguments for the tool * - Returns a promise that will be awaited */ -export const codebuffToolHandlers = { +export const codebuffToolHandlers: { + [K in ToolName]: CodebuffToolHandlerFunction +} = { add_message: handleAddMessage, add_subgoal: handleAddSubgoal, apply_patch: handleApplyPatch, @@ -92,6 +94,4 @@ export const codebuffToolHandlers = { web_search: handleWebSearch, write_file: handleWriteFile, write_todos: handleWriteTodos, -} satisfies { - [K in ToolName]: CodebuffToolHandlerFunction } diff --git a/packages/agent-runtime/src/tools/handlers/tool/composio.ts b/packages/agent-runtime/src/tools/handlers/tool/composio.ts index e566440fd3..e21fa453af 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/composio.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/composio.ts @@ -2,8 +2,10 @@ import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' import type { CodebuffToolHandlerFunction } from '../handler-function-type' -function makeComposioHandler() { - return (async ({ toolCall, requestClientToolCall }) => { +function makeComposioHandler< + T extends ComposioMetaToolName, +>(): CodebuffToolHandlerFunction { + return async ({ toolCall, requestClientToolCall }) => { if (!requestClientToolCall) { return { output: [ @@ -22,14 +24,14 @@ function makeComposioHandler() { toolCall, )) as CodebuffToolOutput, } - }) satisfies CodebuffToolHandlerFunction + } } -export const handleComposioManageConnections = +export const handleComposioManageConnections: CodebuffToolHandlerFunction<'COMPOSIO_MANAGE_CONNECTIONS'> = makeComposioHandler<'COMPOSIO_MANAGE_CONNECTIONS'>() -export const handleComposioMultiExecute = +export const handleComposioMultiExecute: CodebuffToolHandlerFunction<'COMPOSIO_MULTI_EXECUTE_TOOL'> = makeComposioHandler<'COMPOSIO_MULTI_EXECUTE_TOOL'>() -export const handleComposioSearchTools = +export const handleComposioSearchTools: CodebuffToolHandlerFunction<'COMPOSIO_SEARCH_TOOLS'> = makeComposioHandler<'COMPOSIO_SEARCH_TOOLS'>() -export const handleComposioGetToolSchemas = +export const handleComposioGetToolSchemas: CodebuffToolHandlerFunction<'COMPOSIO_GET_TOOL_SCHEMAS'> = makeComposioHandler<'COMPOSIO_GET_TOOL_SCHEMAS'>() diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 1106349629..d6d400e0a2 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -75,9 +75,7 @@ const wrapContentForUserMessage = ( } type OverrideToolHandlers = { - [K in PublishedClientToolName]?: ( - input: Record, - ) => Promise + [K in PublishedClientToolName]?: (input: any) => Promise } & { // Include read_files separately, since it has a different signature. read_files?: (input: { From 90d16991f0de595bd5e388d7c223e8ec04ae8f90 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 16:16:11 -0700 Subject: [PATCH 12/15] Tighten Composio tool validation --- common/src/constants/composio.ts | 8 +++++ sdk/src/__tests__/composio.test.ts | 29 +++++++++++++++---- sdk/src/composio.ts | 27 +++++++---------- sdk/src/run.ts | 15 ++++------ .../v1/composio/__tests__/composio.test.ts | 28 ++++++++++++++++++ web/src/app/api/v1/composio/execute/_post.ts | 3 +- web/src/server/composio.ts | 20 ++----------- 7 files changed, 79 insertions(+), 51 deletions(-) diff --git a/common/src/constants/composio.ts b/common/src/constants/composio.ts index 96ef40aeed..bd975d0361 100644 --- a/common/src/constants/composio.ts +++ b/common/src/constants/composio.ts @@ -8,3 +8,11 @@ export const COMPOSIO_META_TOOL_NAMES = [ ] as const export type ComposioMetaToolName = (typeof COMPOSIO_META_TOOL_NAMES)[number] + +const COMPOSIO_META_TOOL_NAME_SET = new Set(COMPOSIO_META_TOOL_NAMES) + +export function isComposioMetaToolName( + toolName: string, +): toolName is ComposioMetaToolName { + return COMPOSIO_META_TOOL_NAME_SET.has(toolName) +} diff --git a/sdk/src/__tests__/composio.test.ts b/sdk/src/__tests__/composio.test.ts index c43305b40a..c5fb4b7dfd 100644 --- a/sdk/src/__tests__/composio.test.ts +++ b/sdk/src/__tests__/composio.test.ts @@ -3,10 +3,7 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' import { clientToolNames, toolParams } from '@codebuff/common/tools/list' -import { - executeComposioToolViaServer, - normalizeComposioInput, -} from '../composio' +import { executeComposioToolViaServer } from '../composio' describe('Composio SDK tools', () => { const originalFetch = globalThis.fetch @@ -64,7 +61,27 @@ describe('Composio SDK tools', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) - test('normalizes non-object Composio inputs for server execution', () => { - expect(normalizeComposioInput('gmail')).toEqual({ value: 'gmail' }) + test('returns a tool error when the server response is malformed', async () => { + globalThis.fetch = mock( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) as unknown as typeof fetch + + const output = await executeComposioToolViaServer({ + apiKey: 'codebuff-api-key', + toolName: 'COMPOSIO_SEARCH_TOOLS', + input: { + queries: ['find gmail tools'], + session: { generate_id: true }, + }, + }) + + expect(output).toEqual([ + { + type: 'json', + value: { + errorMessage: 'Invalid Composio execute response from server', + }, + }, + ]) }) }) diff --git a/sdk/src/composio.ts b/sdk/src/composio.ts index 4de641a139..a19d9da23f 100644 --- a/sdk/src/composio.ts +++ b/sdk/src/composio.ts @@ -1,21 +1,12 @@ import { WEBSITE_URL } from './constants' import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' -import type { JSONValue } from '@codebuff/common/types/json' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' type ComposioExecuteResponse = { output: ToolResultOutput[] } -function toJsonValue(value: unknown): JSONValue { - try { - return JSON.parse(JSON.stringify(value ?? null)) as JSONValue - } catch { - return String(value) as JSONValue - } -} - async function readErrorMessage(response: Response): Promise { try { const body = (await response.json()) as { @@ -62,6 +53,16 @@ export async function executeComposioToolViaServer(params: { } const body = (await response.json()) as ComposioExecuteResponse + if (!Array.isArray(body.output)) { + return [ + { + type: 'json', + value: { + errorMessage: 'Invalid Composio execute response from server', + }, + }, + ] + } return body.output } catch (error) { return [ @@ -74,11 +75,3 @@ export async function executeComposioToolViaServer(params: { ] } } - -export function normalizeComposioInput( - input: unknown, -): Record { - return input && typeof input === 'object' - ? (input as Record) - : { value: toJsonValue(input) } -} diff --git a/sdk/src/run.ts b/sdk/src/run.ts index d6d400e0a2..3b6d221ff3 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -14,7 +14,7 @@ import { } from '@codebuff/common/mcp/client' import { COMPOSIO_META_TOOL_NAMES, - type ComposioMetaToolName, + isComposioMetaToolName, } from '@codebuff/common/constants/composio' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' @@ -22,10 +22,7 @@ import { AgentOutputSchema } from '@codebuff/common/types/session-state' import { extractApiErrorDetails } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' -import { - executeComposioToolViaServer, - normalizeComposioInput, -} from './composio' +import { executeComposioToolViaServer } from './composio' import { getErrorStatusCode } from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' @@ -740,13 +737,11 @@ async function handleToolCall({ }, }, ] - } else if ( - COMPOSIO_META_TOOL_NAMES.includes(toolName as ComposioMetaToolName) - ) { + } else if (isComposioMetaToolName(toolName)) { result = await executeComposioToolViaServer({ apiKey, - toolName: toolName as ComposioMetaToolName, - input: normalizeComposioInput(input), + toolName, + input, }) } else { throw new Error( diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index 02f280e5d0..7e8448660d 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -169,6 +169,34 @@ describe('/api/v1/composio', () => { expect(executeTool).not.toHaveBeenCalled() }) + test('rejects unsupported Composio tool names before execution', async () => { + const executeTool = mock(async () => [ + { type: 'json' as const, value: { ok: true } }, + ]) + const req = new NextRequest('http://localhost/api/v1/composio/execute', { + method: 'POST', + headers: { Authorization: 'Bearer valid-key' }, + body: JSON.stringify({ + toolName: 'COMPOSIO_REMOTE_WORKBENCH', + input: {}, + }), + }) + + const response = await postComposioExecute({ + req, + getUserInfoFromApiKey, + db: mockDb, + logger, + loggerWithContext, + executeTool, + checkRateLimit: mock(() => ({ limited: false as const })), + isConfigured: () => true, + }) + + expect(response.status).toBe(400) + expect(executeTool).not.toHaveBeenCalled() + }) + test('rejects unauthenticated Composio requests', async () => { const req = new NextRequest('http://localhost/api/v1/composio/execute', { method: 'POST', diff --git a/web/src/app/api/v1/composio/execute/_post.ts b/web/src/app/api/v1/composio/execute/_post.ts index 714ef7376f..0d85407dd6 100644 --- a/web/src/app/api/v1/composio/execute/_post.ts +++ b/web/src/app/api/v1/composio/execute/_post.ts @@ -1,4 +1,5 @@ import { getErrorObject } from '@codebuff/common/util/error' +import { COMPOSIO_META_TOOL_NAMES } from '@codebuff/common/constants/composio' import { NextResponse } from 'next/server' import { z } from 'zod/v4' @@ -20,7 +21,7 @@ type CheckComposioRateLimitFn = typeof checkComposioRateLimit type IsComposioConfiguredFn = typeof isComposioConfigured const composioExecuteBodySchema = z.object({ - toolName: z.string().min(1), + toolName: z.enum(COMPOSIO_META_TOOL_NAMES), input: z.record(z.string(), z.unknown()).default({}), }) diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index 3399c72c11..c53e2327b2 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -4,10 +4,7 @@ import { existsSync, readFileSync } from 'fs' import { homedir } from 'os' import path from 'path' -import { - COMPOSIO_API_KEY_ENV_VAR, - COMPOSIO_META_TOOL_NAMES, -} from '@codebuff/common/constants/composio' +import { COMPOSIO_API_KEY_ENV_VAR } from '@codebuff/common/constants/composio' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' import * as schema from '@codebuff/internal/db/schema' @@ -18,9 +15,9 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { JSONValue } from '@codebuff/common/types/json' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' +import type { ComposioMetaToolName } from '@codebuff/common/constants/composio' const COMPOSIO_HOME_ENV_PATH = path.join(homedir(), 'codebuff', '.env.local') -const allowedToolNames = new Set(COMPOSIO_META_TOOL_NAMES) type ComposioSession = Awaited> type ComposioClient = Composio @@ -297,22 +294,11 @@ async function getSessionForUser(params: { export async function executeComposioTool(params: { db: CodebuffPgDatabase userId: string - toolName: string + toolName: ComposioMetaToolName input: Record logger: Logger apiKey?: string }): Promise { - if (!allowedToolNames.has(params.toolName)) { - return [ - { - type: 'json', - value: { - errorMessage: `Unsupported Composio tool: ${params.toolName}`, - }, - }, - ] - } - const apiKey = params.apiKey ?? getComposioApiKey() if (!apiKey) return null From 44f8cd60f0c83e8daf4641edb28447ffe0a4d8e9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 16:24:28 -0700 Subject: [PATCH 13/15] Fix Composio web test isolation --- .../api/v1/composio/__tests__/composio.test.ts | 17 +++++++++++++++++ web/src/server/__tests__/composio.test.ts | 17 +++++++++++++++++ web/src/test-stubs/bun-test.ts | 14 +++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index 7e8448660d..1fd0674529 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -18,7 +18,24 @@ import type { postComposioExecute as PostComposioExecute } from '../execute/_pos let postComposioExecute: typeof PostComposioExecute +function setEnvDefault(key: string, value: string) { + process.env[key] ??= value +} + beforeAll(async () => { + setEnvDefault('CI', 'true') + setEnvDefault('NEXT_PUBLIC_CB_ENVIRONMENT', 'test') + setEnvDefault('NEXT_PUBLIC_CODEBUFF_APP_URL', 'https://codebuff.test') + setEnvDefault('NEXT_PUBLIC_SUPPORT_EMAIL', 'support@codebuff.test') + setEnvDefault('NEXT_PUBLIC_POSTHOG_API_KEY', 'test-posthog-key') + setEnvDefault('NEXT_PUBLIC_POSTHOG_HOST_URL', 'https://posthog.test') + setEnvDefault('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', 'pk_test') + setEnvDefault( + 'NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL', + 'https://stripe.test/portal', + ) + setEnvDefault('NEXT_PUBLIC_WEB_PORT', '3000') + mock.module('server-only', () => ({})) ;({ postComposioExecute } = await import('../execute/_post')) }) diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index b4337cde85..56d28a3bb9 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -9,7 +9,24 @@ let createSession: ReturnType let useSession: ReturnType let execute: ReturnType +function setEnvDefault(key: string, value: string) { + process.env[key] ??= value +} + beforeAll(async () => { + setEnvDefault('CI', 'true') + setEnvDefault('NEXT_PUBLIC_CB_ENVIRONMENT', 'test') + setEnvDefault('NEXT_PUBLIC_CODEBUFF_APP_URL', 'https://codebuff.test') + setEnvDefault('NEXT_PUBLIC_SUPPORT_EMAIL', 'support@codebuff.test') + setEnvDefault('NEXT_PUBLIC_POSTHOG_API_KEY', 'test-posthog-key') + setEnvDefault('NEXT_PUBLIC_POSTHOG_HOST_URL', 'https://posthog.test') + setEnvDefault('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', 'pk_test') + setEnvDefault( + 'NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL', + 'https://stripe.test/portal', + ) + setEnvDefault('NEXT_PUBLIC_WEB_PORT', '3000') + mock.module('server-only', () => ({})) mock.module('@composio/core', () => ({ Composio: class { diff --git a/web/src/test-stubs/bun-test.ts b/web/src/test-stubs/bun-test.ts index 2c1d129de8..a085d6855f 100644 --- a/web/src/test-stubs/bun-test.ts +++ b/web/src/test-stubs/bun-test.ts @@ -1,5 +1,7 @@ import { + afterAll, afterEach, + beforeAll, beforeEach, describe, expect, @@ -29,4 +31,14 @@ mock.module = (moduleName, factory) => { jest.mock(moduleName, factory) } -export { afterEach, beforeEach, describe, expect, it, test, mock } +export { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + test, + mock, +} From 9b3e9064d08c01886c90bc1665148007d355ec63 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 17:02:22 -0700 Subject: [PATCH 14/15] Use lowercase Composio tool names --- common/src/constants/composio.ts | 24 +++++++-- common/src/tools/list.ts | 16 +++--- common/src/tools/params/tool/composio.ts | 52 +++++++++---------- .../agent-runtime/src/tools/handlers/list.ts | 8 +-- .../src/tools/handlers/tool/composio.ts | 16 +++--- sdk/src/__tests__/composio.test.ts | 6 +-- .../v1/composio/__tests__/composio.test.ts | 10 ++-- web/src/server/__tests__/composio.test.ts | 8 +-- web/src/server/composio.ts | 10 ++-- 9 files changed, 85 insertions(+), 65 deletions(-) diff --git a/common/src/constants/composio.ts b/common/src/constants/composio.ts index bd975d0361..c98c746252 100644 --- a/common/src/constants/composio.ts +++ b/common/src/constants/composio.ts @@ -1,14 +1,24 @@ export const COMPOSIO_API_KEY_ENV_VAR = 'COMPOSIO_API_KEY' export const COMPOSIO_META_TOOL_NAMES = [ - 'COMPOSIO_MANAGE_CONNECTIONS', - 'COMPOSIO_MULTI_EXECUTE_TOOL', - 'COMPOSIO_SEARCH_TOOLS', - 'COMPOSIO_GET_TOOL_SCHEMAS', + 'composio_manage_connections', + 'composio_multi_execute_tool', + 'composio_search_tools', + 'composio_get_tool_schemas', ] as const export type ComposioMetaToolName = (typeof COMPOSIO_META_TOOL_NAMES)[number] +export const COMPOSIO_META_TOOL_NAME_TO_UPSTREAM = { + composio_manage_connections: 'COMPOSIO_MANAGE_CONNECTIONS', + composio_multi_execute_tool: 'COMPOSIO_MULTI_EXECUTE_TOOL', + composio_search_tools: 'COMPOSIO_SEARCH_TOOLS', + composio_get_tool_schemas: 'COMPOSIO_GET_TOOL_SCHEMAS', +} as const satisfies Record + +export type ComposioUpstreamMetaToolName = + (typeof COMPOSIO_META_TOOL_NAME_TO_UPSTREAM)[ComposioMetaToolName] + const COMPOSIO_META_TOOL_NAME_SET = new Set(COMPOSIO_META_TOOL_NAMES) export function isComposioMetaToolName( @@ -16,3 +26,9 @@ export function isComposioMetaToolName( ): toolName is ComposioMetaToolName { return COMPOSIO_META_TOOL_NAME_SET.has(toolName) } + +export function getComposioUpstreamToolName( + toolName: ComposioMetaToolName, +): ComposioUpstreamMetaToolName { + return COMPOSIO_META_TOOL_NAME_TO_UPSTREAM[toolName] +} diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index dd75531ab2..fbf59f3682 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -154,20 +154,20 @@ export const clientToolCallSchema = z.discriminatedUnion('toolName', [ input: FileChangeSchema, }), z.object({ - toolName: z.literal('COMPOSIO_MANAGE_CONNECTIONS'), - input: toolParams.COMPOSIO_MANAGE_CONNECTIONS.inputSchema, + toolName: z.literal('composio_manage_connections'), + input: toolParams.composio_manage_connections.inputSchema, }), z.object({ - toolName: z.literal('COMPOSIO_MULTI_EXECUTE_TOOL'), - input: toolParams.COMPOSIO_MULTI_EXECUTE_TOOL.inputSchema, + toolName: z.literal('composio_multi_execute_tool'), + input: toolParams.composio_multi_execute_tool.inputSchema, }), z.object({ - toolName: z.literal('COMPOSIO_SEARCH_TOOLS'), - input: toolParams.COMPOSIO_SEARCH_TOOLS.inputSchema, + toolName: z.literal('composio_search_tools'), + input: toolParams.composio_search_tools.inputSchema, }), z.object({ - toolName: z.literal('COMPOSIO_GET_TOOL_SCHEMAS'), - input: toolParams.COMPOSIO_GET_TOOL_SCHEMAS.inputSchema, + toolName: z.literal('composio_get_tool_schemas'), + input: toolParams.composio_get_tool_schemas.inputSchema, }), ]) export const clientToolNames = clientToolCallSchema.def.options.map( diff --git a/common/src/tools/params/tool/composio.ts b/common/src/tools/params/tool/composio.ts index a6f32a6cea..a990d767f2 100644 --- a/common/src/tools/params/tool/composio.ts +++ b/common/src/tools/params/tool/composio.ts @@ -8,10 +8,10 @@ import type { $ToolParams } from '../../constants' const sessionIdParam = z .string() .optional() - .describe('Session ID returned by COMPOSIO_SEARCH_TOOLS, when available.') + .describe('Session ID returned by composio_search_tools, when available.') const composioMetaToolInputSchemas = { - COMPOSIO_SEARCH_TOOLS: z + composio_search_tools: z .object({ queries: z .array(z.unknown()) @@ -31,7 +31,7 @@ const composioMetaToolInputSchemas = { model: z.string().optional().describe('Client LLM model name.'), }) .catchall(z.unknown()), - COMPOSIO_GET_TOOL_SCHEMAS: z + composio_get_tool_schemas: z .object({ tool_slugs: z .array(z.string()) @@ -44,7 +44,7 @@ const composioMetaToolInputSchemas = { session_id: sessionIdParam, }) .catchall(z.unknown()), - COMPOSIO_MANAGE_CONNECTIONS: z + composio_manage_connections: z .object({ toolkits: z .array(z.string()) @@ -57,7 +57,7 @@ const composioMetaToolInputSchemas = { session_id: sessionIdParam, }) .catchall(z.unknown()), - COMPOSIO_MULTI_EXECUTE_TOOL: z + composio_multi_execute_tool: z .object({ tools: z .array(z.record(z.string(), z.unknown())) @@ -77,13 +77,13 @@ const composioMetaToolInputSchemas = { } const composioMetaToolDescriptions = { - COMPOSIO_SEARCH_TOOLS: + composio_search_tools: 'Discover relevant Composio tools across external apps. Use this first for requests involving services like Gmail, GitHub, Slack, Linear, Notion, Google Calendar, or Google Sheets.', - COMPOSIO_GET_TOOL_SCHEMAS: - 'Retrieve complete input schemas for specific Composio tool slugs returned by COMPOSIO_SEARCH_TOOLS.', - COMPOSIO_MANAGE_CONNECTIONS: + composio_get_tool_schemas: + 'Retrieve complete input schemas for specific Composio tool slugs returned by composio_search_tools.', + composio_manage_connections: 'Check or initiate user authentication for external app toolkits. Use when search/execution indicates a toolkit is not connected.', - COMPOSIO_MULTI_EXECUTE_TOOL: + composio_multi_execute_tool: 'Execute one or more discovered Composio app tools in the current workflow session. Do not use workbench offloading.', } @@ -98,32 +98,32 @@ const composioOutputSchema = jsonToolResultSchema( ) export const composioMetaToolParams = { - COMPOSIO_MANAGE_CONNECTIONS: { - toolName: 'COMPOSIO_MANAGE_CONNECTIONS', + composio_manage_connections: { + toolName: 'composio_manage_connections', endsAgentStep: true, - description: composioMetaToolDescriptions.COMPOSIO_MANAGE_CONNECTIONS, - inputSchema: composioMetaToolInputSchemas.COMPOSIO_MANAGE_CONNECTIONS, + description: composioMetaToolDescriptions.composio_manage_connections, + inputSchema: composioMetaToolInputSchemas.composio_manage_connections, outputSchema: composioOutputSchema, }, - COMPOSIO_MULTI_EXECUTE_TOOL: { - toolName: 'COMPOSIO_MULTI_EXECUTE_TOOL', + composio_multi_execute_tool: { + toolName: 'composio_multi_execute_tool', endsAgentStep: true, - description: composioMetaToolDescriptions.COMPOSIO_MULTI_EXECUTE_TOOL, - inputSchema: composioMetaToolInputSchemas.COMPOSIO_MULTI_EXECUTE_TOOL, + description: composioMetaToolDescriptions.composio_multi_execute_tool, + inputSchema: composioMetaToolInputSchemas.composio_multi_execute_tool, outputSchema: composioOutputSchema, }, - COMPOSIO_SEARCH_TOOLS: { - toolName: 'COMPOSIO_SEARCH_TOOLS', + composio_search_tools: { + toolName: 'composio_search_tools', endsAgentStep: true, - description: composioMetaToolDescriptions.COMPOSIO_SEARCH_TOOLS, - inputSchema: composioMetaToolInputSchemas.COMPOSIO_SEARCH_TOOLS, + description: composioMetaToolDescriptions.composio_search_tools, + inputSchema: composioMetaToolInputSchemas.composio_search_tools, outputSchema: composioOutputSchema, }, - COMPOSIO_GET_TOOL_SCHEMAS: { - toolName: 'COMPOSIO_GET_TOOL_SCHEMAS', + composio_get_tool_schemas: { + toolName: 'composio_get_tool_schemas', endsAgentStep: true, - description: composioMetaToolDescriptions.COMPOSIO_GET_TOOL_SCHEMAS, - inputSchema: composioMetaToolInputSchemas.COMPOSIO_GET_TOOL_SCHEMAS, + description: composioMetaToolDescriptions.composio_get_tool_schemas, + inputSchema: composioMetaToolInputSchemas.composio_get_tool_schemas, outputSchema: composioOutputSchema, }, } satisfies { diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index c0cc69859a..4d2c6ea836 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -61,10 +61,10 @@ export const codebuffToolHandlers: { ask_user: handleAskUser, browser_logs: handleBrowserLogs, code_search: handleCodeSearch, - COMPOSIO_MANAGE_CONNECTIONS: handleComposioManageConnections, - COMPOSIO_MULTI_EXECUTE_TOOL: handleComposioMultiExecute, - COMPOSIO_SEARCH_TOOLS: handleComposioSearchTools, - COMPOSIO_GET_TOOL_SCHEMAS: handleComposioGetToolSchemas, + composio_manage_connections: handleComposioManageConnections, + composio_multi_execute_tool: handleComposioMultiExecute, + composio_search_tools: handleComposioSearchTools, + composio_get_tool_schemas: handleComposioGetToolSchemas, create_plan: handleCreatePlan, end_turn: handleEndTurn, find_files: handleFindFiles, diff --git a/packages/agent-runtime/src/tools/handlers/tool/composio.ts b/packages/agent-runtime/src/tools/handlers/tool/composio.ts index e21fa453af..70f7ddcba5 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/composio.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/composio.ts @@ -27,11 +27,11 @@ function makeComposioHandler< } } -export const handleComposioManageConnections: CodebuffToolHandlerFunction<'COMPOSIO_MANAGE_CONNECTIONS'> = - makeComposioHandler<'COMPOSIO_MANAGE_CONNECTIONS'>() -export const handleComposioMultiExecute: CodebuffToolHandlerFunction<'COMPOSIO_MULTI_EXECUTE_TOOL'> = - makeComposioHandler<'COMPOSIO_MULTI_EXECUTE_TOOL'>() -export const handleComposioSearchTools: CodebuffToolHandlerFunction<'COMPOSIO_SEARCH_TOOLS'> = - makeComposioHandler<'COMPOSIO_SEARCH_TOOLS'>() -export const handleComposioGetToolSchemas: CodebuffToolHandlerFunction<'COMPOSIO_GET_TOOL_SCHEMAS'> = - makeComposioHandler<'COMPOSIO_GET_TOOL_SCHEMAS'>() +export const handleComposioManageConnections: CodebuffToolHandlerFunction<'composio_manage_connections'> = + makeComposioHandler<'composio_manage_connections'>() +export const handleComposioMultiExecute: CodebuffToolHandlerFunction<'composio_multi_execute_tool'> = + makeComposioHandler<'composio_multi_execute_tool'>() +export const handleComposioSearchTools: CodebuffToolHandlerFunction<'composio_search_tools'> = + makeComposioHandler<'composio_search_tools'>() +export const handleComposioGetToolSchemas: CodebuffToolHandlerFunction<'composio_get_tool_schemas'> = + makeComposioHandler<'composio_get_tool_schemas'>() diff --git a/sdk/src/__tests__/composio.test.ts b/sdk/src/__tests__/composio.test.ts index c5fb4b7dfd..b9447d479c 100644 --- a/sdk/src/__tests__/composio.test.ts +++ b/sdk/src/__tests__/composio.test.ts @@ -32,7 +32,7 @@ describe('Composio SDK tools', () => { 'Content-Type': 'application/json', }) expect(JSON.parse(String(init?.body))).toEqual({ - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['find gmail tools'], session: { generate_id: true }, @@ -50,7 +50,7 @@ describe('Composio SDK tools', () => { const output = await executeComposioToolViaServer({ apiKey: 'codebuff-api-key', - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['find gmail tools'], session: { generate_id: true }, @@ -68,7 +68,7 @@ describe('Composio SDK tools', () => { const output = await executeComposioToolViaServer({ apiKey: 'codebuff-api-key', - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['find gmail tools'], session: { generate_id: true }, diff --git a/web/src/app/api/v1/composio/__tests__/composio.test.ts b/web/src/app/api/v1/composio/__tests__/composio.test.ts index 1fd0674529..b29a4cb021 100644 --- a/web/src/app/api/v1/composio/__tests__/composio.test.ts +++ b/web/src/app/api/v1/composio/__tests__/composio.test.ts @@ -86,7 +86,7 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['find gmail tools'], session: { generate_id: true }, @@ -110,7 +110,7 @@ describe('/api/v1/composio', () => { db: mockDb, userId: 'user-123', logger, - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['find gmail tools'], session: { generate_id: true }, @@ -127,7 +127,7 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: {}, }), }) @@ -158,7 +158,7 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer valid-key' }, body: JSON.stringify({ - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: {}, }), }) @@ -243,7 +243,7 @@ describe('/api/v1/composio', () => { method: 'POST', headers: { Authorization: 'Bearer banned-key' }, body: JSON.stringify({ - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: {}, }), }) diff --git a/web/src/server/__tests__/composio.test.ts b/web/src/server/__tests__/composio.test.ts index 56d28a3bb9..356b678c79 100644 --- a/web/src/server/__tests__/composio.test.ts +++ b/web/src/server/__tests__/composio.test.ts @@ -109,7 +109,7 @@ describe('executeComposioTool', () => { userId: 'user-123', logger, apiKey: 'test-composio-api-key', - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['gmail'], session: { generate_id: true } }, }) @@ -138,7 +138,7 @@ describe('executeComposioTool', () => { userId: 'user-123', logger, apiKey: 'test-composio-api-key', - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['gmail'], session: { generate_id: true } }, }) @@ -166,7 +166,7 @@ describe('executeComposioTool', () => { userId: 'user-123', logger, apiKey: 'test-composio-api-key', - toolName: 'COMPOSIO_MULTI_EXECUTE_TOOL', + toolName: 'composio_multi_execute_tool', input: { tools: [{ slug: 'GMAIL_FETCH_EMAILS', arguments: {} }], sync_response_to_workbench: true, @@ -195,7 +195,7 @@ describe('executeComposioTool', () => { userId: 'user-123', logger, apiKey: 'test-composio-api-key', - toolName: 'COMPOSIO_SEARCH_TOOLS', + toolName: 'composio_search_tools', input: { queries: ['gmail'], session: { generate_id: true } }, }), ).rejects.toThrow('Composio unavailable') diff --git a/web/src/server/composio.ts b/web/src/server/composio.ts index c53e2327b2..af4c5994a2 100644 --- a/web/src/server/composio.ts +++ b/web/src/server/composio.ts @@ -4,7 +4,10 @@ import { existsSync, readFileSync } from 'fs' import { homedir } from 'os' import path from 'path' -import { COMPOSIO_API_KEY_ENV_VAR } from '@codebuff/common/constants/composio' +import { + COMPOSIO_API_KEY_ENV_VAR, + getComposioUpstreamToolName, +} from '@codebuff/common/constants/composio' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' import * as schema from '@codebuff/internal/db/schema' @@ -312,13 +315,14 @@ export async function executeComposioTool(params: { try { const input = - params.toolName === 'COMPOSIO_MULTI_EXECUTE_TOOL' + params.toolName === 'composio_multi_execute_tool' ? { ...params.input, sync_response_to_workbench: false, } : params.input - const result = await cached.session.execute(params.toolName, input) + const upstreamToolName = getComposioUpstreamToolName(params.toolName) + const result = await cached.session.execute(upstreamToolName, input) return [{ type: 'json', value: toJsonValue(result) }] } catch (error) { params.logger.warn( From 86ed83f97ff2f9cbbe695580b934b81a097ea374 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 24 May 2026 17:25:18 -0700 Subject: [PATCH 15/15] Gate base2 Composio tools --- agents/base2/base2.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 087de11d4c..077dcd78f6 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -18,6 +18,8 @@ import { type SecretAgentDefinition, } from '../types/secret-agent-definition' +const ENABLE_COMPOSIO_TOOLS = false + export function createBase2( mode: 'default' | 'free' | 'lite' | 'max' | 'fast', options?: { @@ -106,7 +108,7 @@ export function createBase2( 'set_output', 'list_directory', 'glob', - ...COMPOSIO_META_TOOL_NAMES, + ENABLE_COMPOSIO_TOOLS && COMPOSIO_META_TOOL_NAMES, ), spawnableAgents: buildArray( !isMax && 'file-picker', @@ -150,8 +152,8 @@ Current date: ${PLACEHOLDER.CURRENT_DATE}. } - **Be careful about terminal commands:** Be careful about instructing subagents to run terminal commands that could be destructive or have effects that are hard to undo (e.g. git push, git commit, running any scripts -- especially ones that could alter production environments (!), installing packages globally, etc). Don't run any of these effectful commands unless the user explicitly asks you to. - **Do what the user asks:** If the user asks you to do something, even running a risky terminal command, do it. -- **Don't use set_output:** The set_output tool is for spawned subagents to report results. Don't use it yourself. -- **External apps:** When Composio tools are available and the user asks to work with connected apps or services like Gmail, Google Calendar, GitHub, Slack, Linear, or Notion, use them to search for the right app tools, help the user connect their account, and execute the requested action. +- **Don't use set_output:** The set_output tool is for spawned subagents to report results. Don't use it yourself.${ENABLE_COMPOSIO_TOOLS ? ` +- **External apps:** When Composio tools are available and the user asks to work with connected apps or services like Gmail, Google Calendar, GitHub, Slack, Linear, or Notion, use them to search for the right app tools, help the user connect their account, and execute the requested action.` : ''} # Code Editing Mandates