diff --git a/extensions/dench-ai-gateway/composio-bridge.ts b/extensions/dench-ai-gateway/composio-bridge.ts index 173ace640522b..ffd693bfb558a 100644 --- a/extensions/dench-ai-gateway/composio-bridge.ts +++ b/extensions/dench-ai-gateway/composio-bridge.ts @@ -1,9 +1,5 @@ import type { AnyAgentTool } from "openclaw/plugin-sdk"; import { readDenchAuthProfileKey } from "../shared/dench-auth.js"; -import { - createComposioSearchContextSecret, - verifyComposioSearchContext, -} from "../shared/composio-search-context.js"; import { buildComposioMcpServerConfig } from "./config-patch.js"; type UnknownRecord = Record; @@ -22,33 +18,9 @@ const COMPOSIO_CALL_TOOL_PARAMETERS = { type: "object", additionalProperties: false, properties: { - app: { + execution_ref: { type: "string", - description: "Connected toolkit slug, for example gmail, slack, github, stripe, or google-calendar.", - }, - tool_name: { - type: "string", - description: "Exact integration tool name returned by composio_search_tools or composio_resolve_tool.", - }, - search_context_token: { - type: "string", - description: "Opaque token returned by composio_search_tools or composio_resolve_tool. Required to enforce search-before-call.", - }, - search_session_id: { - type: "string", - description: "Optional integration search session id returned by composio_search_tools. Required for gateway-backed official execution results.", - }, - account: { - type: "string", - description: "Optional account id or alias returned by composio_search_tools for official gateway tool-router execution.", - }, - connected_account_id: { - type: "string", - description: "Legacy fallback for local catalog execution when multiple accounts exist.", - }, - account_identity: { - type: "string", - description: "Legacy fallback for local catalog execution when multiple accounts exist.", + description: "Opaque gateway-issued execution ref returned by composio_search_tools. Required for execution.", }, arguments: { type: "object", @@ -57,7 +29,7 @@ const COMPOSIO_CALL_TOOL_PARAMETERS = { properties: {}, }, }, - required: ["app", "tool_name", "search_context_token"], + required: ["execution_ref"], } as const; function asRecord(value: unknown): UnknownRecord | undefined { @@ -66,10 +38,90 @@ function asRecord(value: unknown): UnknownRecord | undefined { : undefined; } +function postDebugLog( + hypothesisId: string, + location: string, + message: string, + data: Record, +): void { + if (process.env.NODE_ENV === "test") { + return; + } + // #region agent log + fetch("http://127.0.0.1:7651/ingest/93e0c293-34f1-4a69-8fce-870fc1b93fcb", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "822d38", + }, + body: JSON.stringify({ + sessionId: "822d38", + runId: "dench-ai-gateway", + hypothesisId, + location, + message, + data, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion +} + function readString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readGatewayError(value: unknown): string | undefined { + return readString(value) ?? readString(asRecord(value)?.message); +} + +function decodeExecutionRefPayload(executionRef: string | undefined): UnknownRecord | undefined { + const trimmed = executionRef?.trim(); + if (!trimmed) { + return undefined; + } + const [payloadSegment] = trimmed.split(".", 1); + if (!payloadSegment) { + return undefined; + } + try { + const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); + const decoded = Buffer.from(padded, "base64").toString("utf8"); + return asRecord(JSON.parse(decoded)); + } catch { + return undefined; + } +} + +function extractGatewayExecutionMetadata( + payload: UnknownRecord | undefined, + fallbacks: { + executionRef?: string; + } = {}, +) { + const decodedExecutionRef = decodeExecutionRefPayload(fallbacks.executionRef); + return { + mode: readString(payload?.execution_mode) ?? readString(decodedExecutionRef?.mode) ?? "gateway_tool_router", + toolName: readString(payload?.tool_slug) ?? readString(decodedExecutionRef?.tool_slug), + toolRouterSessionId: + readString(payload?.tool_router_session_id) ?? readString(decodedExecutionRef?.session_id), + toolkit: readString(payload?.toolkit) ?? readString(decodedExecutionRef?.toolkit), + account: + readString(payload?.account) + ?? readString(decodedExecutionRef?.account) + ?? readString(decodedExecutionRef?.connected_account_id), + logId: readString(payload?.log_id), + executionRefVersion: + readNumber(payload?.execution_ref_version) ?? readNumber(decodedExecutionRef?.version), + executionRef: fallbacks.executionRef, + }; +} + function jsonResult(payload: unknown, details?: Record) { return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], @@ -194,14 +246,14 @@ function classifyComposioExecutionFailure(value: unknown): string | undefined { if (!text) { return undefined; } - if (text.includes("session")) { - return "session_issue"; + if (text.includes("connect") || text.includes("no active connection")) { + return "connection_issue"; } if (text.includes("account") || text.includes("multi-account")) { return "account_issue"; } - if (text.includes("connect") || text.includes("no active connection")) { - return "connection_issue"; + if (text.includes("session")) { + return "session_issue"; } if ( text.includes("argument") @@ -444,6 +496,45 @@ function toAgentToolResult(toolName: string, result: ComposioToolCallResult) { }; } +function attachRecoveryMetadataToResult( + result: { + content?: Array<{ type: string; text?: string }>; + details?: Record; + }, + recovery: Record, + preserved: Record, +) { + const content = Array.isArray(result.content) ? result.content : []; + let nextContent = content; + const first = content[0]; + if (content.length > 0 && first?.type === "text" && typeof first.text === "string") { + try { + const parsed = JSON.parse(first.text) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + nextContent = [ + { + ...first, + text: JSON.stringify({ ...(parsed as Record), recovery }, null, 2), + }, + ...content.slice(1), + ]; + } + } catch { + nextContent = content; + } + } + + return { + ...result, + content: nextContent, + details: { + ...(result.details ?? {}), + ...preserved, + recovery, + }, + }; +} + async function executeComposioTool(params: { url: string; authorization?: string; @@ -541,11 +632,8 @@ async function executeComposioTool(params: { async function executeComposioToolRouter(params: { gatewayBaseUrl: string; authorization?: string; - sessionId: string; - toolName: string; + executionRef: string; input: Record; - account?: string; - app?: string; }) { try { const res = await fetch(`${params.gatewayBaseUrl}/v1/composio/tool-router/execute`, { @@ -556,10 +644,8 @@ async function executeComposioToolRouter(params: { ...(params.authorization ? { authorization: params.authorization } : {}), }, body: JSON.stringify({ - session_id: params.sessionId, - tool_slug: params.toolName, + execution_ref: params.executionRef, arguments: params.input, - ...(params.account ? { account: params.account } : {}), }), }); @@ -570,23 +656,50 @@ async function executeComposioToolRouter(params: { } catch { parsed = undefined; } - const upstreamError = readString(parsed?.error) ?? (text || undefined); + const executionMeta = extractGatewayExecutionMetadata(parsed, { + executionRef: params.executionRef, + }); + const recovery = asRecord(parsed?.recovery); + const upstreamError = readGatewayError(parsed?.error) ?? (text || undefined); const failureKind = classifyComposioExecutionFailure(upstreamError); if (!res.ok) { return jsonResult( { - error: `${DENCH_INTEGRATION_DISPLAY_NAME} tool ${params.toolName} failed (HTTP ${res.status}).`, + error: `${DENCH_INTEGRATION_DISPLAY_NAME} execution failed (HTTP ${res.status}).`, detail: parsed ?? (text || undefined), + execution: { + mode: executionMeta.mode, + ...(executionMeta.toolName ? { tool_name: executionMeta.toolName } : {}), + ...(executionMeta.toolRouterSessionId + ? { tool_router_session_id: executionMeta.toolRouterSessionId } + : {}), + ...(executionMeta.toolkit ? { toolkit: executionMeta.toolkit } : {}), + ...(executionMeta.account ? { account: executionMeta.account } : {}), + ...(executionMeta.executionRefVersion !== undefined + ? { execution_ref_version: executionMeta.executionRefVersion } + : {}), + ...(executionMeta.logId ? { log_id: executionMeta.logId } : {}), + execution_ref: params.executionRef, + }, + ...(recovery ? { recovery } : {}), ...(failureKind ? { failure_kind: failureKind } : {}), }, { composioBridge: true, - composioMode: "gateway_tool_router", - toolRouterSessionId: params.sessionId, - mcpTool: params.toolName, - ...(params.app ? { toolkit: params.app } : {}), - ...(params.account ? { account: params.account } : {}), + composioMode: executionMeta.mode, + ...(executionMeta.toolRouterSessionId + ? { toolRouterSessionId: executionMeta.toolRouterSessionId } + : {}), + ...(executionMeta.toolName ? { mcpTool: executionMeta.toolName } : {}), + ...(executionMeta.toolkit ? { toolkit: executionMeta.toolkit } : {}), + ...(executionMeta.account ? { account: executionMeta.account } : {}), + ...(executionMeta.logId ? { logId: executionMeta.logId } : {}), + ...(executionMeta.executionRefVersion !== undefined + ? { executionRefVersion: executionMeta.executionRefVersion } + : {}), + ...(recovery ? { recovery } : {}), + executionRef: params.executionRef, status: "error", ...(failureKind ? { failureKind } : {}), }, @@ -594,10 +707,43 @@ async function executeComposioToolRouter(params: { } const data = parsed?.data; - const error = readString(parsed?.error); + const error = readGatewayError(parsed?.error); const pagination = extractPaginationState(data); - const contentPayload = error ? { error, data } : (data ?? parsed ?? {}); + const basePayload = asRecord(data) + ? { ...data } + : data !== undefined + ? { data } + : (parsed ?? {}); const structuredFailureKind = classifyComposioExecutionFailure(error); + const contentPayload = { + ...(asRecord(basePayload) ?? { data: basePayload }), + ...(executionMeta.toolName ? { tool_slug: executionMeta.toolName } : {}), + ...(executionMeta.toolRouterSessionId + ? { tool_router_session_id: executionMeta.toolRouterSessionId } + : {}), + ...(executionMeta.toolkit ? { toolkit: executionMeta.toolkit } : {}), + ...(executionMeta.account ? { account: executionMeta.account } : {}), + ...(executionMeta.executionRefVersion !== undefined + ? { execution_ref_version: executionMeta.executionRefVersion } + : {}), + ...(recovery ? { recovery } : {}), + execution: { + mode: executionMeta.mode, + ...(executionMeta.toolName ? { tool_name: executionMeta.toolName } : {}), + ...(executionMeta.toolRouterSessionId + ? { tool_router_session_id: executionMeta.toolRouterSessionId } + : {}), + ...(executionMeta.toolkit ? { toolkit: executionMeta.toolkit } : {}), + ...(executionMeta.account ? { account: executionMeta.account } : {}), + ...(executionMeta.executionRefVersion !== undefined + ? { execution_ref_version: executionMeta.executionRefVersion } + : {}), + ...(executionMeta.logId ? { log_id: executionMeta.logId } : {}), + execution_ref: params.executionRef, + }, + ...(error ? { error } : {}), + ...(structuredFailureKind ? { failure_kind: structuredFailureKind } : {}), + }; return { content: [{ type: "text" as const, @@ -605,12 +751,19 @@ async function executeComposioToolRouter(params: { }], details: { composioBridge: true, - composioMode: "gateway_tool_router", - toolRouterSessionId: params.sessionId, - mcpTool: params.toolName, - ...(params.app ? { toolkit: params.app } : {}), - ...(params.account ? { account: params.account } : {}), - ...(parsed?.log_id ? { logId: parsed.log_id } : {}), + composioMode: executionMeta.mode, + ...(executionMeta.toolRouterSessionId + ? { toolRouterSessionId: executionMeta.toolRouterSessionId } + : {}), + ...(executionMeta.toolName ? { mcpTool: executionMeta.toolName } : {}), + ...(executionMeta.toolkit ? { toolkit: executionMeta.toolkit } : {}), + ...(executionMeta.account ? { account: executionMeta.account } : {}), + ...(executionMeta.logId ? { logId: executionMeta.logId } : {}), + ...(executionMeta.executionRefVersion !== undefined + ? { executionRefVersion: executionMeta.executionRefVersion } + : {}), + ...(recovery ? { recovery } : {}), + executionRef: params.executionRef, ...(data !== undefined ? { structuredContent: data } : {}), ...(pagination ? { pagination } : {}), ...(error ? { status: "error", error } : {}), @@ -623,17 +776,18 @@ async function executeComposioToolRouter(params: { ); return jsonResult( { - error: `${DENCH_INTEGRATION_DISPLAY_NAME} tool ${params.toolName} failed.`, + error: `${DENCH_INTEGRATION_DISPLAY_NAME} execution failed.`, detail: error instanceof Error ? error.message : String(error), + execution: { + mode: "gateway_tool_router", + execution_ref: params.executionRef, + }, ...(failureKind ? { failure_kind: failureKind } : {}), }, { composioBridge: true, composioMode: "gateway_tool_router", - toolRouterSessionId: params.sessionId, - mcpTool: params.toolName, - ...(params.app ? { toolkit: params.app } : {}), - ...(params.account ? { account: params.account } : {}), + executionRef: params.executionRef, status: "error", ...(failureKind ? { failureKind } : {}), }, @@ -647,152 +801,118 @@ function createRegisteredComposioTools(params: { authorization?: string; gatewayBaseUrl: string; }; - searchContextSecret: string; }): AnyAgentTool[] { return [ { name: COMPOSIO_CALL_TOOL_NAME, label: `${DENCH_INTEGRATIONS_DISPLAY_NAME} Call`, description: - `Execute an exact ${DENCH_INTEGRATION_DISPLAY_NAME.toLowerCase()} tool returned by composio_search_tools or composio_resolve_tool through the gateway-backed integration session.`, + `Execute an exact ${DENCH_INTEGRATION_DISPLAY_NAME.toLowerCase()} tool returned by composio_search_tools through the gateway-backed integration session.`, parameters: COMPOSIO_CALL_TOOL_PARAMETERS, execute: async (_toolCallId: string, input: Record) => { const payload = asRecord(input) ?? {}; - const requestedApp = readString(payload.app)?.trim(); - const toolName = readString(payload.tool_name)?.trim(); - const searchContextToken = readString(payload.search_context_token)?.trim(); - const searchSessionId = readString(payload.search_session_id)?.trim(); - const requestedAccount = readString(payload.account)?.trim(); - const connectedAccountId = readString(payload.connected_account_id)?.trim(); - const accountIdentity = readString(payload.account_identity)?.trim(); + const executionRef = readString(payload.execution_ref)?.trim(); const toolArgs = asRecord(payload.arguments) ?? {}; - if (!requestedApp || !toolName || !searchContextToken) { - return jsonResult({ - error: "The `app`, `tool_name`, and `search_context_token` fields are required for composio_call_tool.", - }); - } - - const searchContext = verifyComposioSearchContext( - searchContextToken, - params.searchContextSecret, - ); - if (!searchContext) { - return jsonResult({ - error: "This integration tool call is missing valid search context. Call composio_search_tools first and use the returned dispatcher_input.", - }); - } - - if ( - normalizeToolkitSlug(searchContext.app) !== normalizeToolkitSlug(requestedApp) - || searchContext.tool_name !== toolName - ) { - return jsonResult({ - error: "The requested integration tool does not match the verified search result. Re-run composio_search_tools and use the returned dispatcher_input unchanged.", - }); - } - - const expectedPrefix = toolkitSlugToToolPrefix(searchContext.app); - if (!toolName.toUpperCase().startsWith(expectedPrefix)) { - return jsonResult({ - error: `Tool ${toolName} does not match the verified ${searchContext.app} app.`, - expected_prefix: expectedPrefix, - }); - } - - if (searchContext.mode !== "gateway_tool_router") { + if (!executionRef) { return jsonResult({ - error: "This workspace now requires gateway-backed integration execution metadata. Re-run composio_search_tools and use the returned dispatcher_input from the live gateway result.", + error: "The `execution_ref` field is required for composio_call_tool.", }); } - if (!requestedApp || normalizeToolkitSlug(requestedApp) !== normalizeToolkitSlug(searchContext.app)) { - return jsonResult({ - error: "The requested app does not match the verified search result. Re-run composio_search_tools and reuse the returned dispatcher_input.", - }); + const toolRouterResult = await executeComposioToolRouter({ + gatewayBaseUrl: params.serverConfig.gatewayBaseUrl, + authorization: params.serverConfig.authorization, + executionRef, + input: toolArgs, + }); + const toolRouterDetails = asRecord(toolRouterResult.details); + const failureKind = readString(toolRouterDetails?.failureKind); + const toolkit = readString(toolRouterDetails?.toolkit); + const toolName = readString(toolRouterDetails?.mcpTool); + const requestedAccount = readString(toolRouterDetails?.account); + const shouldAttemptDirectFallback = + failureKind === "connection_issue" || failureKind === "account_issue"; + if (!shouldAttemptDirectFallback || !toolkit || !toolName) { + return toolRouterResult; } - if (!searchContext.session_id && !searchSessionId) { - return jsonResult({ - error: "The selected integration search result is missing a search session id. Re-run composio_search_tools and use the returned dispatcher_input.", - }); - } - if (searchSessionId && searchContext.session_id && searchSessionId !== searchContext.session_id) { - return jsonResult({ - error: "The supplied search_session_id does not match the verified search result. Re-run composio_search_tools and reuse the returned dispatcher_input.", - }); - } - const tokenBoundAccount = readString(searchContext.account)?.trim(); - const accountSelectionRequired = searchContext.account_required === true; - if ( - requestedAccount - && tokenBoundAccount - && normalizeToolkitSlug(requestedAccount) !== normalizeToolkitSlug(tokenBoundAccount) - ) { - return jsonResult({ - error: "The supplied `account` does not match the verified search result. Re-run composio_search_tools and reuse the returned dispatcher_input unchanged.", - }); - } - const effectiveAccount = requestedAccount ?? tokenBoundAccount; - if (accountSelectionRequired && !effectiveAccount) { - return jsonResult({ - error: "This integration search result requires an explicit account selection before execution. Re-run composio_search_tools, choose the account, and use the returned dispatcher_input unchanged.", - }); - } - if (!effectiveAccount && (connectedAccountId || accountIdentity)) { - return jsonResult({ - error: "Use the canonical `account` field returned by composio_search_tools for gateway-backed integration execution. Do not supply legacy account identifiers.", - }); + const activeConnections = await fetchGatewayActiveConnectionsForToolkit({ + gatewayBaseUrl: params.serverConfig.gatewayBaseUrl, + authorization: params.serverConfig.authorization, + app: toolkit, + }); + const fallbackSelection = resolveGatewayMcpFallbackSelection({ + activeConnections: activeConnections ?? [], + requestedAccount, + }); + postDebugLog( + "H14", + "extensions/dench-ai-gateway/composio-bridge.ts:798", + "evaluated direct MCP fallback after connection issue", + { + toolkit, + toolName, + failureKind, + activeConnectionCount: fallbackSelection.activeConnectionCount, + canFallback: fallbackSelection.canFallback, + requestedAccountPresent: Boolean(requestedAccount?.trim()), + connectedAccountIdPresent: Boolean(fallbackSelection.connectedAccountId), + }, + ); + if (!fallbackSelection.canFallback || !fallbackSelection.connectedAccountId) { + return toolRouterResult; } - const toolRouterResult = await executeComposioToolRouter({ - gatewayBaseUrl: params.serverConfig.gatewayBaseUrl, + const directResult = await executeComposioTool({ + url: params.serverConfig.url, authorization: params.serverConfig.authorization, - sessionId: searchContext.session_id ?? searchSessionId ?? "", toolName, input: toolArgs, - account: effectiveAccount, - app: requestedApp, + connectedAccountId: fallbackSelection.connectedAccountId, + app: toolkit, + accountIdentity: fallbackSelection.accountIdentity, }); - const toolRouterDetails = asRecord(toolRouterResult.details); - const failureKind = readString(toolRouterDetails?.failureKind ?? toolRouterDetails?.failure_kind); - const shouldTryMcpFallback = toolRouterDetails?.status === "error" - && (failureKind === "session_issue" || failureKind === "connection_issue" || failureKind === "account_issue"); - if (shouldTryMcpFallback) { - const activeConnections = await fetchGatewayActiveConnectionsForToolkit({ - gatewayBaseUrl: params.serverConfig.gatewayBaseUrl, - authorization: params.serverConfig.authorization, - app: requestedApp, - }); - const fallbackSelection = resolveGatewayMcpFallbackSelection({ - activeConnections: activeConnections ?? [], - requestedAccount: effectiveAccount, - }); - if (fallbackSelection.canFallback) { - const mcpResult = await executeComposioTool({ - url: params.serverConfig.url, - authorization: params.serverConfig.authorization, - toolName, - input: toolArgs, - connectedAccountId: fallbackSelection.connectedAccountId, - app: requestedApp, - accountIdentity: fallbackSelection.accountIdentity, - }); - const mcpDetails = asRecord(mcpResult.details); - return { - ...mcpResult, - details: { - ...(mcpDetails ?? {}), - composioBridge: true, - composioMode: "gateway_mcp_fallback", - fallbackFrom: "gateway_tool_router", - toolRouterSessionId: searchContext.session_id ?? searchSessionId ?? "", - ...(failureKind ? { failureKind } : {}), - }, - }; - } + const directDetails = asRecord(directResult.details); + const directFailed = readString(directDetails?.status) === "error"; + postDebugLog( + "H14", + "extensions/dench-ai-gateway/composio-bridge.ts:824", + "direct MCP fallback completed", + { + toolkit, + toolName, + failureKind, + directFailed, + connectedAccountIdPresent: true, + }, + ); + if (directFailed) { + return toolRouterResult; } - return toolRouterResult; + + return attachRecoveryMetadataToResult( + directResult, + { + recovered: true, + recovered_via: "direct_mcp_single_active_account", + retried_with_account: fallbackSelection.connectedAccountId, + }, + { + composioBridge: true, + composioMode: "gateway_tool_router", + executionRef, + ...(toolRouterDetails?.toolRouterSessionId + ? { toolRouterSessionId: toolRouterDetails.toolRouterSessionId } + : {}), + ...(toolRouterDetails?.executionRefVersion !== undefined + ? { executionRefVersion: toolRouterDetails.executionRefVersion } + : {}), + mcpTool: toolName, + toolkit, + ...(requestedAccount ? { account: requestedAccount } : {}), + }, + ); }, } as AnyAgentTool, ]; @@ -802,26 +922,40 @@ export function registerCuratedComposioBridge(api: any, fallbackGatewayUrl: stri const workspaceDir = resolveWorkspaceDir(api); const serverConfig = resolveComposioServerConfig(api, fallbackGatewayUrl); if (!workspaceDir || !serverConfig?.url) { + postDebugLog( + "H6", + "extensions/dench-ai-gateway/composio-bridge.ts:739", + "composio bridge registration skipped", + { + workspaceDirPresent: Boolean(workspaceDir), + serverUrlPresent: Boolean(serverConfig?.url), + }, + ); return; } - const searchContextSecret = createComposioSearchContextSecret({ - workspaceDir, - gatewayUrl: resolveGatewayBaseUrl(serverConfig.url, fallbackGatewayUrl), - apiKey: resolveConfiguredApiKey(api) ?? null, - }); const tools = createRegisteredComposioTools({ serverConfig: { ...serverConfig, gatewayBaseUrl: resolveGatewayBaseUrl(serverConfig.url, fallbackGatewayUrl), }, - searchContextSecret, }); for (const tool of tools) { api.registerTool(tool); } + postDebugLog( + "H6", + "extensions/dench-ai-gateway/composio-bridge.ts:749", + "composio bridge tools registered", + { + workspaceDirPresent: true, + serverUrl: serverConfig.url, + gatewayBaseUrl: resolveGatewayBaseUrl(serverConfig.url, fallbackGatewayUrl), + toolNames: tools.map((tool) => tool.name), + }, + ); api.logger?.info?.( - `[dench-ai-gateway] registered ${tools.length} managed ${DENCH_INTEGRATIONS_DISPLAY_NAME} bridge tool using gateway-backed search context`, + `[dench-ai-gateway] registered ${tools.length} managed ${DENCH_INTEGRATIONS_DISPLAY_NAME} bridge tool using gateway-issued execution refs`, ); } diff --git a/extensions/dench-ai-gateway/config-patch.ts b/extensions/dench-ai-gateway/config-patch.ts index a460e793cff67..fb7b1b82631e0 100644 --- a/extensions/dench-ai-gateway/config-patch.ts +++ b/extensions/dench-ai-gateway/config-patch.ts @@ -22,7 +22,6 @@ export type ComposioMcpServerConfig = { const DENCH_COMPOSIO_WRAPPER_TOOLS = [ "composio_search_tools", - "composio_resolve_tool", "composio_call_tool", ] as const; diff --git a/extensions/dench-ai-gateway/index.test.ts b/extensions/dench-ai-gateway/index.test.ts index eeca8031ec673..a2daba700fdda 100644 --- a/extensions/dench-ai-gateway/index.test.ts +++ b/extensions/dench-ai-gateway/index.test.ts @@ -2,10 +2,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createComposioSearchContextSecret, - signComposioSearchContext, -} from "../shared/composio-search-context.js"; import register from "./index.js"; function writeAuthProfiles(stateDir: string, key: string): void { @@ -22,33 +18,6 @@ function writeAuthProfiles(stateDir: string, key: string): void { ); } -function buildSearchContextToken(params: { - workspaceDir: string; - gatewayUrl: string; - apiKey: string; - app: string; - toolName: string; - mode: "gateway_tool_router" | "local_catalog_mcp"; - sessionId?: string; - account?: string; - accountRequired?: boolean; -}) { - return signComposioSearchContext({ - version: 1, - mode: params.mode, - app: params.app, - tool_name: params.toolName, - ...(params.sessionId ? { session_id: params.sessionId } : {}), - ...(params.account ? { account: params.account } : {}), - ...(params.accountRequired ? { account_required: true } : {}), - issued_at: "2026-04-06T00:00:00.000Z", - }, createComposioSearchContextSecret({ - workspaceDir: params.workspaceDir, - gatewayUrl: params.gatewayUrl, - apiKey: params.apiKey, - })); -} - describe("dench-ai-gateway composio bridge", () => { const originalFetch = globalThis.fetch; const originalStateDir = process.env.OPENCLAW_STATE_DIR; @@ -84,7 +53,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "gmail", @@ -166,8 +135,7 @@ describe("dench-ai-gateway composio bridge", () => { const payload = JSON.parse(String(init?.body ?? "{}")); expect(url).toBe("https://gateway.example.com/v1/composio/tool-router/execute"); expect(payload).toEqual({ - session_id: "trs_gmail_1", - tool_slug: "GMAIL_FETCH_EMAILS", + execution_ref: "exec_gmail_1", arguments: { label_ids: ["INBOX"], max_results: 10, @@ -181,6 +149,10 @@ describe("dench-ai-gateway composio bridge", () => { }, error: null, log_id: "log_gmail_1", + tool_slug: "GMAIL_FETCH_EMAILS", + tool_router_session_id: "trs_gmail_1", + toolkit: "gmail", + execution_ref_version: 1, }), { status: 200, @@ -248,20 +220,8 @@ describe("dench-ai-gateway composio bridge", () => { expect(tools.map((tool) => tool.name)).toEqual(["composio_call_tool"]); expect(api.config.mcp).toBeUndefined(); - const searchContextToken = buildSearchContextToken({ - workspaceDir, - gatewayUrl: "https://gateway.example.com", - apiKey: "dc-key", - app: "gmail", - toolName: "GMAIL_FETCH_EMAILS", - mode: "gateway_tool_router", - sessionId: "trs_gmail_1", - }); const result = await tools[0].execute("call-1", { - app: "gmail", - tool_name: "GMAIL_FETCH_EMAILS", - search_context_token: searchContextToken, - search_session_id: "trs_gmail_1", + execution_ref: "exec_gmail_1", arguments: { label_ids: ["INBOX"], max_results: 10, @@ -275,6 +235,8 @@ describe("dench-ai-gateway composio bridge", () => { toolRouterSessionId: "trs_gmail_1", mcpTool: "GMAIL_FETCH_EMAILS", toolkit: "gmail", + executionRef: "exec_gmail_1", + executionRefVersion: 1, }); expect(result.content[0]?.text).toContain('"subject": "Hello"'); }); @@ -290,7 +252,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "gmail", @@ -372,15 +334,9 @@ describe("dench-ai-gateway composio bridge", () => { expect(tools[0].parameters).toMatchObject({ type: "object", additionalProperties: false, - required: ["app", "tool_name", "search_context_token"], + required: ["execution_ref"], properties: { - app: { - type: "string", - }, - tool_name: { - type: "string", - }, - search_context_token: { + execution_ref: { type: "string", }, arguments: { @@ -402,7 +358,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "stripe", @@ -435,13 +391,11 @@ describe("dench-ai-gateway composio bridge", () => { expect(url).toBe("https://gateway.example.com/v1/composio/tool-router/execute"); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({ - session_id: "trs_123", - tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", + execution_ref: "exec_stripe_123", arguments: { limit: 100, starting_after: "sub_prev", }, - account: "acct_primary", }); return new Response( @@ -453,6 +407,11 @@ describe("dench-ai-gateway composio bridge", () => { }, error: null, log_id: "log_123", + tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", + tool_router_session_id: "trs_123", + toolkit: "stripe", + account: "acct_primary", + execution_ref_version: 1, }), { status: 200, @@ -484,21 +443,8 @@ describe("dench-ai-gateway composio bridge", () => { register(api); - const searchContextToken = buildSearchContextToken({ - workspaceDir, - gatewayUrl: "https://gateway.example.com", - apiKey: "dc-key", - app: "stripe", - toolName: "STRIPE_LIST_SUBSCRIPTIONS", - mode: "gateway_tool_router", - sessionId: "trs_123", - }); const result = await tools[0].execute("call-1", { - app: "stripe", - tool_name: "STRIPE_LIST_SUBSCRIPTIONS", - search_context_token: searchContextToken, - search_session_id: "trs_123", - account: "acct_primary", + execution_ref: "exec_stripe_123", arguments: { limit: 100, starting_after: "sub_prev", @@ -513,6 +459,8 @@ describe("dench-ai-gateway composio bridge", () => { mcpTool: "STRIPE_LIST_SUBSCRIPTIONS", toolkit: "stripe", account: "acct_primary", + executionRef: "exec_stripe_123", + executionRefVersion: 1, pagination: { has_more: true, next_cursor: "sub_next", @@ -521,7 +469,462 @@ describe("dench-ai-gateway composio bridge", () => { expect(result.content[0]?.text).toContain('"sub_123"'); }); - it("uses the account bound in the search context when the tool call omits it", async () => { + it("surfaces gateway recovery metadata from an auto-healed execution", async () => { + stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + writeAuthProfiles(stateDir, "dc-key"); + + workspaceDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-")); + writeFileSync( + path.join(workspaceDir, "composio-tool-index.json"), + JSON.stringify( + { + generated_at: "2026-04-02T00:00:00.000Z", + managed_tools: ["composio_search_tools", "composio_call_tool"], + connected_apps: [], + }, + null, + 2, + ), + "utf-8", + ); + + const tools: any[] = []; + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + data: { + items: [{ id: "sub_recovered_1" }], + }, + error: null, + log_id: "log_recovered_1", + tool_slug: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + tool_router_session_id: "trs_youtube_1", + toolkit: "youtube", + account: "ca_youtube_1", + execution_ref_version: 1, + recovery: { + recovered: true, + recovered_via: "auto_bind_single_active_account", + retried_with_account: "ca_youtube_1", + refreshed_execution_ref: "exec_youtube_refreshed", + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + }) as typeof fetch; + + const api: any = { + config: { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { + entries: { + "dench-ai-gateway": { + config: { enabled: true, gatewayUrl: "https://gateway.example.com" }, + }, + }, + }, + }, + registerProvider() {}, + registerTool(tool: any) { + tools.push(tool); + }, + registerService() {}, + logger: { info: vi.fn() }, + }; + + register(api); + + const result = await tools[0].execute("call-1", { + execution_ref: "exec_youtube_old", + arguments: { + maxResults: 50, + part: "snippet,contentDetails", + }, + }); + + expect(result.details).toMatchObject({ + composioBridge: true, + composioMode: "gateway_tool_router", + toolRouterSessionId: "trs_youtube_1", + mcpTool: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + toolkit: "youtube", + account: "ca_youtube_1", + executionRef: "exec_youtube_old", + executionRefVersion: 1, + recovery: { + recovered: true, + recovered_via: "auto_bind_single_active_account", + retried_with_account: "ca_youtube_1", + refreshed_execution_ref: "exec_youtube_refreshed", + }, + }); + expect(result.content[0]?.text).toContain('"recovered_via": "auto_bind_single_active_account"'); + expect(result.content[0]?.text).toContain('"refreshed_execution_ref": "exec_youtube_refreshed"'); + }); + + it("falls back to direct MCP execution when a single active connection exists globally", async () => { + stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + writeAuthProfiles(stateDir, "dc-key"); + + workspaceDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-")); + writeFileSync( + path.join(workspaceDir, "composio-tool-index.json"), + JSON.stringify( + { + generated_at: "2026-04-02T00:00:00.000Z", + managed_tools: ["composio_search_tools", "composio_call_tool"], + connected_apps: [], + }, + null, + 2, + ), + "utf-8", + ); + + const tools: any[] = []; + const executionRef = `${Buffer.from(JSON.stringify({ + version: 1, + mode: "gateway_tool_router", + session_id: "trs_youtube_1", + tool_slug: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + toolkit: "youtube", + })).toString("base64url")}.sig`; + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/composio/tool-router/execute")) { + return new Response( + JSON.stringify({ + error: { + message: + "No active connection found for toolkit(s) 'youtube' in this session. To fix this, call COMPOSIO_MANAGE_CONNECTIONS with toolkits=['youtube'] to establish a connection, then retry this tool call.", + }, + }), + { + status: 400, + headers: { + "content-type": "application/json", + }, + }, + ); + } + if (url.endsWith("/v1/composio/connections")) { + return new Response( + JSON.stringify([ + { + id: "ca_youtube_1", + toolkit_slug: "youtube", + status: "ACTIVE", + }, + ]), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + if (url.includes("/v1/composio/mcp")) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + structuredContent: { + items: [{ id: "sub_direct_1" }], + }, + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof fetch; + + const api: any = { + config: { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { + entries: { + "dench-ai-gateway": { + config: { enabled: true, gatewayUrl: "https://gateway.example.com" }, + }, + }, + }, + }, + registerProvider() {}, + registerTool(tool: any) { + tools.push(tool); + }, + registerService() {}, + logger: { info: vi.fn() }, + }; + + register(api); + + const result = await tools[0].execute("call-1", { + execution_ref: executionRef, + arguments: { + maxResults: 1, + }, + }); + + expect(result.details).toMatchObject({ + composioBridge: true, + composioMode: "gateway_tool_router", + toolRouterSessionId: "trs_youtube_1", + mcpTool: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + toolkit: "youtube", + connectedAccountId: "ca_youtube_1", + executionRef, + executionRefVersion: 1, + recovery: { + recovered: true, + recovered_via: "direct_mcp_single_active_account", + retried_with_account: "ca_youtube_1", + }, + structuredContent: { + items: [{ id: "sub_direct_1" }], + }, + }); + expect(result.content[0]?.text).toContain('"recovered_via": "direct_mcp_single_active_account"'); + expect(result.content[0]?.text).toContain('"sub_direct_1"'); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(3); + }); + + it("falls back to direct MCP execution for account-issue tool-router failures", async () => { + stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + writeAuthProfiles(stateDir, "dc-key"); + + workspaceDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-")); + writeFileSync( + path.join(workspaceDir, "composio-tool-index.json"), + JSON.stringify( + { + generated_at: "2026-04-02T00:00:00.000Z", + managed_tools: ["composio_search_tools", "composio_call_tool"], + connected_apps: [], + }, + null, + 2, + ), + "utf-8", + ); + + const tools: any[] = []; + const executionRef = `${Buffer.from(JSON.stringify({ + version: 1, + mode: "gateway_tool_router", + session_id: "trs_youtube_1", + tool_slug: "YOUTUBE_LIST_USER_PLAYLISTS", + toolkit: "youtube", + account: "ca_youtube_1", + })).toString("base64url")}.sig`; + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/composio/tool-router/execute")) { + return new Response( + JSON.stringify({ + error: { + message: + "The 'account' parameter is not supported for this project. Multi-account selection is not enabled.", + }, + }), + { + status: 400, + headers: { + "content-type": "application/json", + }, + }, + ); + } + if (url.endsWith("/v1/composio/connections")) { + return new Response( + JSON.stringify([ + { + id: "ca_youtube_1", + toolkit_slug: "youtube", + status: "ACTIVE", + }, + ]), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + if (url.includes("/v1/composio/mcp")) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + structuredContent: { + items: [{ id: "playlist_direct_1" }], + }, + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof fetch; + + const api: any = { + config: { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { + entries: { + "dench-ai-gateway": { + config: { enabled: true, gatewayUrl: "https://gateway.example.com" }, + }, + }, + }, + }, + registerProvider() {}, + registerTool(tool: any) { + tools.push(tool); + }, + registerService() {}, + logger: { info: vi.fn() }, + }; + + register(api); + + const result = await tools[0].execute("call-1", { + execution_ref: executionRef, + arguments: { + maxResults: 1, + }, + }); + + expect(result.details).toMatchObject({ + composioBridge: true, + composioMode: "gateway_tool_router", + toolRouterSessionId: "trs_youtube_1", + mcpTool: "YOUTUBE_LIST_USER_PLAYLISTS", + toolkit: "youtube", + account: "ca_youtube_1", + connectedAccountId: "ca_youtube_1", + executionRef, + executionRefVersion: 1, + recovery: { + recovered: true, + recovered_via: "direct_mcp_single_active_account", + retried_with_account: "ca_youtube_1", + }, + structuredContent: { + items: [{ id: "playlist_direct_1" }], + }, + }); + expect(result.content[0]?.text).toContain('"recovered_via": "direct_mcp_single_active_account"'); + expect(result.content[0]?.text).toContain('"playlist_direct_1"'); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(3); + }); + + it("classifies no-active-connection session failures as connection issues", async () => { + stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + writeAuthProfiles(stateDir, "dc-key"); + + workspaceDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-")); + writeFileSync( + path.join(workspaceDir, "composio-tool-index.json"), + JSON.stringify( + { + generated_at: "2026-04-02T00:00:00.000Z", + managed_tools: ["composio_search_tools", "composio_call_tool"], + connected_apps: [], + }, + null, + 2, + ), + "utf-8", + ); + + const tools: any[] = []; + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + error: { + message: + "No active connection found for toolkit(s) 'youtube' in this session. To fix this, call COMPOSIO_MANAGE_CONNECTIONS with toolkits=['youtube'] to establish a connection, then retry this tool call.", + }, + tool_slug: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + tool_router_session_id: "trs_youtube_1", + toolkit: "youtube", + execution_ref_version: 1, + }), + { + status: 400, + headers: { + "content-type": "application/json", + }, + }, + ); + }) as typeof fetch; + + const api: any = { + config: { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { + entries: { + "dench-ai-gateway": { + config: { enabled: true, gatewayUrl: "https://gateway.example.com" }, + }, + }, + }, + }, + registerProvider() {}, + registerTool(tool: any) { + tools.push(tool); + }, + registerService() {}, + logger: { info: vi.fn() }, + }; + + register(api); + + const result = await tools[0].execute("call-1", { + execution_ref: "exec_youtube_old", + arguments: {}, + }); + + expect(result.details).toMatchObject({ + composioBridge: true, + composioMode: "gateway_tool_router", + toolRouterSessionId: "trs_youtube_1", + mcpTool: "YOUTUBE_LIST_USER_SUBSCRIPTIONS", + toolkit: "youtube", + executionRef: "exec_youtube_old", + executionRefVersion: 1, + status: "error", + failureKind: "connection_issue", + }); + expect(result.content[0]?.text).toContain('"failure_kind": "connection_issue"'); + }); + + it("surfaces account metadata returned by a gateway-issued execution ref", async () => { stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); process.env.OPENCLAW_STATE_DIR = stateDir; writeAuthProfiles(stateDir, "dc-key"); @@ -532,7 +935,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "stripe", @@ -575,12 +978,10 @@ describe("dench-ai-gateway composio bridge", () => { const payload = JSON.parse(String(init?.body ?? "{}")); expect(url).toBe("https://gateway.example.com/v1/composio/tool-router/execute"); expect(payload).toEqual({ - session_id: "trs_456", - tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", + execution_ref: "exec_stripe_primary", arguments: { limit: 25, }, - account: "acct_primary", }); return new Response( @@ -591,6 +992,11 @@ describe("dench-ai-gateway composio bridge", () => { }, error: null, log_id: "log_stripe_primary", + tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", + tool_router_session_id: "trs_456", + toolkit: "stripe", + account: "acct_primary", + execution_ref_version: 1, }), { status: 200, @@ -622,21 +1028,8 @@ describe("dench-ai-gateway composio bridge", () => { register(api); - const searchContextToken = buildSearchContextToken({ - workspaceDir, - gatewayUrl: "https://gateway.example.com", - apiKey: "dc-key", - app: "stripe", - toolName: "STRIPE_LIST_SUBSCRIPTIONS", - mode: "gateway_tool_router", - sessionId: "trs_456", - account: "acct_primary", - }); const result = await tools[0].execute("call-1", { - app: "stripe", - tool_name: "STRIPE_LIST_SUBSCRIPTIONS", - search_context_token: searchContextToken, - search_session_id: "trs_456", + execution_ref: "exec_stripe_primary", arguments: { limit: 25, }, @@ -650,11 +1043,13 @@ describe("dench-ai-gateway composio bridge", () => { mcpTool: "STRIPE_LIST_SUBSCRIPTIONS", toolkit: "stripe", account: "acct_primary", + executionRef: "exec_stripe_primary", + executionRefVersion: 1, }); expect(result.content[0]?.text).toContain('"sub_primary"'); }); - it("rejects gateway execution when the verified search context still requires account selection", async () => { + it("requires execution_ref for gateway-backed execution", async () => { stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); process.env.OPENCLAW_STATE_DIR = stateDir; writeAuthProfiles(stateDir, "dc-key"); @@ -665,7 +1060,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "stripe", @@ -726,29 +1121,15 @@ describe("dench-ai-gateway composio bridge", () => { register(api); - const searchContextToken = buildSearchContextToken({ - workspaceDir, - gatewayUrl: "https://gateway.example.com", - apiKey: "dc-key", - app: "stripe", - toolName: "STRIPE_LIST_SUBSCRIPTIONS", - mode: "gateway_tool_router", - sessionId: "trs_789", - accountRequired: true, - }); const result = await tools[0].execute("call-1", { - app: "stripe", - tool_name: "STRIPE_LIST_SUBSCRIPTIONS", - search_context_token: searchContextToken, - search_session_id: "trs_789", arguments: {}, }); expect(globalThis.fetch).not.toHaveBeenCalled(); - expect(result.content[0]?.text).toContain("requires an explicit account selection"); + expect(result.content[0]?.text).toContain("execution_ref"); }); - it("rejects legacy local-catalog search context and asks for a fresh gateway search", async () => { + it("surfaces structured gateway account-selection errors directly", async () => { stateDir = mkdtempSync(path.join(os.tmpdir(), "dench-ai-gateway-state-")); process.env.OPENCLAW_STATE_DIR = stateDir; writeAuthProfiles(stateDir, "dc-key"); @@ -759,7 +1140,7 @@ describe("dench-ai-gateway composio bridge", () => { JSON.stringify( { generated_at: "2026-04-02T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "stripe", @@ -799,6 +1180,28 @@ describe("dench-ai-gateway composio bridge", () => { ); const tools: any[] = []; + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + error: { + message: + 'Multiple active connections are available for toolkit "stripe". Re-run the search and choose the desired account before executing STRIPE_LIST_SUBSCRIPTIONS.', + type: "invalid_request_error", + code: "composio_client_error", + }, + tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", + tool_router_session_id: "trs_789", + toolkit: "stripe", + execution_ref_version: 1, + }), + { + status: 400, + headers: { + "content-type": "application/json", + }, + }, + ); + }) as typeof fetch; const api: any = { config: { agents: { defaults: { workspace: workspaceDir } }, @@ -820,20 +1223,20 @@ describe("dench-ai-gateway composio bridge", () => { register(api); - const searchContextToken = buildSearchContextToken({ - workspaceDir, - gatewayUrl: "https://gateway.example.com", - apiKey: "dc-key", - app: "stripe", - toolName: "STRIPE_LIST_SUBSCRIPTIONS", - mode: "local_catalog_mcp", - }); const result = await tools[0].execute("call-1", { - app: "stripe", - tool_name: "STRIPE_LIST_SUBSCRIPTIONS", - search_context_token: searchContextToken, + execution_ref: "exec_stripe_789", arguments: {}, }); - expect(result.content[0]?.text).toContain("requires gateway-backed integration execution metadata"); + expect(result.content[0]?.text).toContain("Multiple active connections are available for toolkit"); + expect(result.details).toMatchObject({ + composioBridge: true, + composioMode: "gateway_tool_router", + toolRouterSessionId: "trs_789", + mcpTool: "STRIPE_LIST_SUBSCRIPTIONS", + toolkit: "stripe", + executionRef: "exec_stripe_789", + executionRefVersion: 1, + status: "error", + }); }); }); diff --git a/extensions/dench-identity/index.test.ts b/extensions/dench-identity/index.test.ts index 899d48c94f422..2efe3b8292256 100644 --- a/extensions/dench-identity/index.test.ts +++ b/extensions/dench-identity/index.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import os from "node:os"; -import { - createComposioSearchContextSecret, - verifyComposioSearchContext, -} from "../shared/composio-search-context.ts"; import { buildIdentityPrompt, resolveWorkspaceDir } from "./index.ts"; import register from "./index.ts"; import path from "node:path"; @@ -22,12 +18,34 @@ async function executeTool(tool: { execute: (toolCallId: string, input: Record) { process.env.DENCH_API_KEY = "dench-test-key"; process.env.DENCH_GATEWAY_URL = "https://gateway.example.com"; + const toolSchemas = responsePayload.tool_schemas as Record> | undefined; + const normalizedPayload = toolSchemas + ? { + ...responsePayload, + tool_schemas: Object.fromEntries( + Object.entries(toolSchemas).map(([toolSlug, schema]) => [ + toolSlug, + { + ...schema, + execution_ref: + typeof schema.execution_ref === "string" + ? schema.execution_ref + : `exec_${toolSlug.toLowerCase()}`, + execution_ref_version: + typeof schema.execution_ref_version === "number" + ? schema.execution_ref_version + : 1, + }, + ]), + ), + } + : responsePayload; globalThis.fetch = vi.fn(async (input, init) => { const url = typeof input === "string" ? input : input.url; expect(url).toBe("https://gateway.example.com/v1/composio/tool-router/search"); expect(init?.method).toBe("POST"); return new Response( - JSON.stringify(responsePayload), + JSON.stringify(normalizedPayload), { status: 200, headers: { @@ -73,7 +91,6 @@ describe("buildIdentityPrompt", () => { expect(prompt).not.toContain("Composio MCP"); expect(prompt).toContain("Never"); expect(prompt).toContain("composio_search_tools"); - expect(prompt).toContain("composio_resolve_tool"); expect(prompt).toContain("composio_call_tool"); }); @@ -84,7 +101,7 @@ describe("buildIdentityPrompt", () => { expect(prompt).toContain("action_link_markdown"); expect(prompt).toContain("MUST end the assistant reply with that exact markdown link"); expect(prompt).toContain("dench://composio/connect"); - expect(prompt).toContain("dench://composio/reconnect"); + expect(prompt).toContain("Only suggest a reconnect link"); expect(prompt).toContain("connect_required"); }); @@ -299,7 +316,7 @@ describe("register", () => { expect(result).toBeUndefined(); }); - it("registers the Composio search and resolver tools when the managed skill exists", () => { + it("registers the Composio search tool when the managed skill exists", () => { const tmp = path.join( os.tmpdir(), `dench-identity-register-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -322,14 +339,11 @@ describe("register", () => { expect(api.registerTool).toHaveBeenCalledWith( expect.objectContaining({ name: "composio_search_tools" }), ); - expect(api.registerTool).toHaveBeenCalledWith( - expect.objectContaining({ name: "composio_resolve_tool" }), - ); rmSync(tmp, { recursive: true, force: true }); }); - it("resolves recent GitHub PR requests through recipe-backed tools outside the direct tool slice", async () => { + it("returns a recommended GitHub PR tool through ranked search", async () => { const tmp = path.join( os.tmpdir(), `dench-identity-resolver-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -384,16 +398,16 @@ describe("register", () => { register(api as any); - const resolver = getRegisteredTool(api as any, "composio_resolve_tool"); - const result = await executeTool(resolver, { + const searchTool = getRegisteredTool(api as any, "composio_search_tools"); + const result = await executeTool(searchTool, { app: "github", - intent: "check my recent PRs", + query: "check my recent PRs", }); const payload = JSON.parse(result.content[0].text); - expect(payload.tool).toBe("GITHUB_FIND_PULL_REQUESTS"); - expect(payload.directly_callable).toBe(true); - expect(payload.dispatcher_tool).toBe("composio_call_tool"); + expect(payload.recommended_result.tool).toBe("GITHUB_FIND_PULL_REQUESTS"); + expect(payload.recommended_result.dispatcher_tool).toBe("composio_call_tool"); + expect(payload.recommended_result.dispatcher_input.execution_ref).toEqual(expect.any(String)); rmSync(tmp, { recursive: true, force: true }); }); @@ -478,7 +492,7 @@ describe("register", () => { path.join(tmp, "composio-tool-index.json"), JSON.stringify({ generated_at: "2026-04-03T00:00:00.000Z", - managed_tools: ["composio_search_tools", "composio_resolve_tool", "composio_call_tool"], + managed_tools: ["composio_search_tools", "composio_call_tool"], connected_apps: [ { toolkit_slug: "stripe", @@ -555,6 +569,8 @@ describe("register", () => { tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", description: "List subscriptions.", hasFullSchema: true, + execution_ref: "exec_stripe_trs_123", + execution_ref_version: 1, input_schema: { type: "object", properties: { @@ -610,8 +626,9 @@ describe("register", () => { expect(payload.search_source).toBe("gateway_tool_router"); expect(payload.search_session_id).toBe("trs_123"); expect(payload.tool_schemas.STRIPE_LIST_SUBSCRIPTIONS.input_schema.properties.starting_after).toBeTruthy(); - expect(payload.recommended_result.dispatcher_input.search_session_id).toBe("trs_123"); - expect(payload.recommended_result.dispatcher_input.search_context_token).toEqual(expect.any(String)); + expect(payload.recommended_result.dispatcher_input.execution_ref).toBe("exec_stripe_trs_123"); + expect(payload.recommended_result.account_selection_required).toBe(false); + expect(payload.recommended_result.selected_account.account).toBe("acct_primary"); expect(payload.recommended_result.recommended_plan_steps).toContain("Continue while has_more is true."); expect(payload.recommended_result.pagination_input_hints).toContain("starting_after"); @@ -687,21 +704,21 @@ describe("register", () => { register(api as any); - const resolver = getRegisteredTool(api as any, "composio_resolve_tool"); - const result = await executeTool(resolver, { + const searchTool = getRegisteredTool(api as any, "composio_search_tools"); + const result = await executeTool(searchTool, { app: "stripe", - intent: "list subscriptions", + query: "list subscriptions", }); const payload = JSON.parse(result.content[0].text); - expect(payload.account_selection_required).toBe(true); - expect(payload.account_candidates).toHaveLength(2); + expect(payload.recommended_result.account_selection_required).toBe(true); + expect(payload.recommended_result.account_candidates).toHaveLength(2); expect(payload.instruction).toContain("which connected Stripe account"); rmSync(tmp, { recursive: true, force: true }); }); - it("forwards the selected account to gateway search and binds it into the signed dispatcher context", async () => { + it("forwards the selected account to gateway search while keeping dispatcher input opaque", async () => { const tmp = path.join( os.tmpdir(), `dench-identity-selected-account-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -764,6 +781,8 @@ describe("register", () => { tool_slug: "STRIPE_LIST_SUBSCRIPTIONS", description: "List subscriptions.", hasFullSchema: true, + execution_ref: "exec_stripe_selected", + execution_ref_version: 1, input_schema: { type: "object", properties: {} }, }, }, @@ -797,19 +816,10 @@ describe("register", () => { }); const payload = JSON.parse(result.content[0].text); const dispatcherInput = payload.recommended_result.dispatcher_input; - const verifiedContext = verifyComposioSearchContext( - dispatcherInput.search_context_token, - createComposioSearchContextSecret({ - workspaceDir: tmp, - gatewayUrl: "https://gateway.example.com", - apiKey: "dench-test-key", - }), - ); expect(payload.recommended_result.account_selection_required).toBe(false); expect(payload.recommended_result.selected_account.account).toBe("acct_prod"); - expect(dispatcherInput.account).toBe("acct_prod"); - expect(verifiedContext?.account).toBe("acct_prod"); + expect(dispatcherInput.execution_ref).toBe("exec_stripe_selected"); rmSync(tmp, { recursive: true, force: true }); }); @@ -923,10 +933,10 @@ describe("register", () => { register(api as any); - const resolver = getRegisteredTool(api as any, "composio_resolve_tool"); - const result = await executeTool(resolver, { + const searchTool = getRegisteredTool(api as any, "composio_search_tools"); + const result = await executeTool(searchTool, { app: "slack", - intent: "check my slack", + query: "check my slack", }); const payload = JSON.parse(result.content[0].text); diff --git a/extensions/dench-identity/index.ts b/extensions/dench-identity/index.ts index 57d7c0446da09..c915ec3ee8c95 100644 --- a/extensions/dench-identity/index.ts +++ b/extensions/dench-identity/index.ts @@ -284,6 +284,35 @@ function asRecord(value: unknown): UnknownRecord | undefined { : undefined; } +function postDebugLog( + hypothesisId: string, + location: string, + message: string, + data: Record, +): void { + if (process.env.NODE_ENV === "test") { + return; + } + // #region agent log + fetch("http://127.0.0.1:7651/ingest/93e0c293-34f1-4a69-8fce-870fc1b93fcb", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "822d38", + }, + body: JSON.stringify({ + sessionId: "822d38", + runId: "dench-identity", + hypothesisId, + location, + message, + data, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion +} + function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } @@ -1319,6 +1348,10 @@ function chooseSearchAccountCandidate( } } + if (candidates.length === 1) { + return candidates[0] ?? null; + } + return null; } @@ -1336,33 +1369,11 @@ function loadLocalResolverCandidate( } function buildDispatcherInput(params: { - appSlug: string; - toolName: string; - secret: string; - mode: "gateway_tool_router"; - sessionId?: string; - selectedAccount?: ComposioSearchPresentationResult["selected_account"]; - accountSelectionRequired?: boolean; + executionRef?: string; }) { - const token = signComposioSearchContext({ - version: 1, - mode: params.mode, - app: params.appSlug, - tool_name: params.toolName, - ...(params.sessionId ? { session_id: params.sessionId } : {}), - ...(params.selectedAccount?.account ? { account: params.selectedAccount.account } : {}), - ...(params.accountSelectionRequired ? { account_required: true } : {}), - issued_at: new Date().toISOString(), - }, params.secret); - - const dispatcherInput = { - app: params.appSlug, - tool_name: params.toolName, - search_context_token: token, - ...(params.sessionId ? { search_session_id: params.sessionId } : {}), - ...(params.selectedAccount?.account ? { account: params.selectedAccount.account } : {}), - }; - return dispatcherInput; + return params.executionRef + ? { execution_ref: params.executionRef } + : {}; } function findGatewayToolkitStatus( @@ -1501,6 +1512,10 @@ function buildGatewayPresentationResults(params: { toolkitName, schema, }); + const executionRef = readString(schema.execution_ref); + if (!executionRef) { + return []; + } const accountCandidates = buildGatewayAccountCandidates({ status, localApp: undefined, @@ -1534,13 +1549,7 @@ function buildGatewayPresentationResults(params: { selected_account: selectedAccount, account_selection_required: accountSelectionRequired, dispatcher_input: buildDispatcherInput({ - appSlug: toolkitSlug, - toolName, - secret: params.searchSecret, - mode: "gateway_tool_router", - sessionId: sessionId ?? undefined, - selectedAccount, - accountSelectionRequired, + executionRef, }), execution_guidance: readString(queryResult.execution_guidance) ?? null, recommended_plan_steps: readStringArray(queryResult.recommended_plan_steps), @@ -1580,8 +1589,9 @@ async function runGatewayComposioToolSearch(params: { sessionId?: string; searchSecret: string; }): Promise { + const normalizedRequestedApp = normalizeResolverApp(params.requestedApp); const knownFields = [ - params.requestedApp ? `toolkit:${normalizeResolverApp(params.requestedApp)}` : null, + normalizedRequestedApp ? `toolkit:${normalizedRequestedApp}` : null, ].filter((value): value is string => Boolean(value)); const payload = await postComposioGatewayJson({ @@ -1608,9 +1618,10 @@ async function runGatewayComposioToolSearch(params: { const gatewayError = readString(payload.error) ?? readString(asRecord(payload.error)?.message); const searchStatuses = asRecordArray(payload.toolkit_connection_statuses); - const shouldProbeLiveConnections = searchStatuses.some((status) => - readBoolean(status.has_active_connection) === false - ); + const shouldProbeLiveConnections = Boolean(normalizedRequestedApp) + || searchStatuses.some((status) => + readBoolean(status.has_active_connection) === false + ); const liveStatuses = shouldProbeLiveConnections ? await fetchGatewayLiveToolkitStatuses({ api: params.api }) : null; @@ -1679,12 +1690,7 @@ function buildSearchPresentationResults(params: { selected_account: selectedAccount, account_selection_required: accountSelectionRequired, dispatcher_input: buildDispatcherInput({ - appSlug: app.toolkit_slug, - toolName: result.tool.name, - secret: params.searchSecret, - mode: "gateway_tool_router", - selectedAccount, - accountSelectionRequired, + executionRef: undefined, }), execution_guidance: null, recommended_plan_steps: [], @@ -1803,6 +1809,19 @@ function createComposioSearchTool(api: OpenClawPluginApi): AnyAgentTool { const topK = Number.isFinite(rawTopK) ? Math.max(1, Math.min(Math.trunc(rawTopK), 10)) : 5; const normalizedRequestedApp = normalizeResolverApp(requestedApp); const searchSecret = resolveComposioSearchSecret(api, workspaceDir); + postDebugLog( + "H7", + "extensions/dench-identity/index.ts:1778", + "composio search tool invoked", + { + workspaceDirPresent: workspaceDir.trim().length > 0, + query, + requestedApp: normalizedRequestedApp ?? null, + requestedAccountPresent: Boolean(requestedAccount?.trim()), + sessionIdPresent: Boolean(sessionId?.trim()), + topK, + }, + ); const gatewaySearch = await runGatewayComposioToolSearch({ api, @@ -1825,6 +1844,24 @@ function createComposioSearchTool(api: OpenClawPluginApi): AnyAgentTool { const requestedStatus = normalizedRequestedApp ? findGatewayToolkitStatus(gatewaySearch.toolkit_connection_statuses ?? [], normalizedRequestedApp) : null; + postDebugLog( + "H7", + "extensions/dench-identity/index.ts:1796", + "composio search tool completed", + { + query, + requestedApp: normalizedRequestedApp ?? null, + searchAvailable: true, + resultCount: gatewaySearch.results.length, + topConfidence: gatewaySearch.top_confidence, + searchSource: gatewaySearch.search_source, + searchSessionId: gatewaySearch.search_session_id ?? null, + hasGatewayError: Boolean(gatewaySearch.error), + requestedStatusHasActiveConnection: + requestedStatus ? readBoolean(requestedStatus.has_active_connection) : null, + nextStepsCount: gatewaySearch.next_steps_guidance.length, + }, + ); if (gatewaySearch.results.length > 0) { const results = gatewaySearch.results.map((result) => buildSearchResultPayload(result)); @@ -1963,19 +2000,14 @@ function createComposioResolveTool(api: OpenClawPluginApi): AnyAgentTool { } } - const reconnectTarget = normalizedRequestedApp ?? undefined; - const reconnectLink = reconnectTarget ? buildComposioActionLink("reconnect", reconnectTarget) : null; return jsonResult({ error: normalizedRequestedApp ? `No ${DENCH_INTEGRATION_DISPLAY_NAME.toLowerCase()} tools matched this ${normalizedRequestedApp} request.` : `No ${DENCH_INTEGRATION_DISPLAY_NAME.toLowerCase()} tools matched this request.`, - availability: reconnectTarget ? "reconnect_recommended" : "unknown", - instruction: reconnectLink - ? `The live ${DENCH_INTEGRATIONS_DISPLAY_NAME} search did not return an executable ${humanizeResolverApp(reconnectTarget)} tool. Explain that briefly and end the assistant reply with this exact markdown link: ${reconnectLink}` - : `Ask a brief clarifying question or call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` again with a narrower query.`, + availability: "unknown", + instruction: `Ask a brief clarifying question or call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` again with a narrower query.`, ...(gatewaySearch.error ? { gateway_error: gatewaySearch.error } : {}), ...(gatewaySearch.search_session_id ? { search_session_id: gatewaySearch.search_session_id } : {}), - ...(reconnectTarget ? buildResolverActionDetails("reconnect", reconnectTarget) : {}), }); } @@ -2004,7 +2036,7 @@ function createComposioResolveTool(api: OpenClawPluginApi): AnyAgentTool { account_selection_required: true, account_candidates: top.account_candidates, availability: "account_selection_required", - instruction: `Ask the user which connected ${top.app.toolkit_name} account to use before calling \`${COMPOSIO_CALL_TOOL_NAME}\`. Once they choose, call \`${COMPOSIO_RESOLVE_TOOL_NAME}\` again with the \`account\` field set to that label or identity.`, + instruction: `Ask the user which connected ${top.app.toolkit_name} account to use before calling \`${COMPOSIO_CALL_TOOL_NAME}\`. Once they choose, call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` again with the \`account\` field set to that label or identity and use the returned \`dispatcher_input\`.`, }); } @@ -2032,18 +2064,17 @@ function buildComposioDefaultGuidance(composioAppsSkillPath: string): string { "", `- If the user mentions ${DENCH_INTEGRATIONS_DISPLAY_NAME}, a connected app, rube, map, MCP, or says an app is already connected, use the integration tools first.`, `- **When the user asks about ANY third-party app or service** (e.g. Slack, HubSpot, Salesforce, Jira, Asana, Discord, Airtable, Notion, Linear, Gmail, GitHub, Google Calendar, Stripe, Zendesk, Trello, etc.), call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` first to verify whether it is connected, inspect the official ranked tools, and read the full returned schemas before answering. This applies to ALL apps, not just the ones listed here.`, - "- Trust the official integration search payload exposed through the gateway. Use the returned `input_schema`, `recommended_plan_steps`, `known_pitfalls`, `toolkit_connection_statuses`, and `search_session_id` as the source of truth.", - `- After searching, execute the chosen tool with \`${COMPOSIO_CALL_TOOL_NAME}\` using the returned \`dispatcher_input\` (especially \`search_context_token\` and any \`search_session_id\`). Do not assume connected-app tools are registered as direct top-level tools in the session.`, + "- Trust the official integration search payload exposed through the gateway. Use the returned `input_schema`, `recommended_plan_steps`, `known_pitfalls`, `toolkit_connection_statuses`, and `dispatcher_input.execution_ref` as the source of truth.", + `- After searching, execute the chosen tool with \`${COMPOSIO_CALL_TOOL_NAME}\` using the returned \`dispatcher_input\` unchanged. Do not assume connected-app tools are registered as direct top-level tools in the session.`, `- If search returns \`account_selection_required\`, ask the user which connected account to use before calling \`${COMPOSIO_CALL_TOOL_NAME}\`.`, - `- Use \`${COMPOSIO_RESOLVE_TOOL_NAME}\` only when you specifically want a single best-match compatibility wrapper instead of the full ranked search results.`, "- Review `recommended_plan_steps`, `known_pitfalls`, enums, defaults, required fields, and any pagination hints from `composio_search_tools` before executing the tool.", `- Load and follow \`${composioAppsSkillPath}\` for high-level workflow hints, but let the live integration schema decide the actual argument names and types.`, "- Do not rely on `composio-tool-index.json`, `composio-tool-catalog.json`, or `composio-mcp-status.json` as runtime inputs. They are not the source of truth.", `- Never use \`gog\`, shell CLIs, curl, or raw gateway HTTP for Gmail/Calendar/Drive/Slack/GitHub/Notion/Linear when ${DENCH_INTEGRATIONS_DISPLAY_NAME} is connected or the user mentions the connected-app layer/rube/map/MCP.`, - "- **When the integration search or resolve response returns `action_link_markdown`, you MUST end the assistant reply with that exact markdown link.** Do not omit it. Do not rephrase it as plain text. The link renders as a clickable button in chat.", + "- **When the integration search response returns `action_link_markdown`, you MUST end the assistant reply with that exact markdown link.** Do not omit it. Do not rephrase it as plain text. The link renders as a clickable button in chat.", "- Missing first-time connection example: `[Connect Slack](dench://composio/connect?toolkit=slack&name=Slack)`.", - "- Stale or unusable connection example: `[Reconnect Slack](dench://composio/reconnect?toolkit=slack&name=Slack)`.", - "- If the resolver returns an error with `availability: \"connect_required\"`, briefly explain the app is not connected and end with the connect link. Do NOT suggest navigating to Integrations manually.", + "- Only suggest a reconnect link when the live integration response explicitly indicates the connection is stale or unusable. Do not infer reconnect just because search returned no executable tools.", + "- If the search response returns `availability: \"connect_required\"`, briefly explain the app is not connected and end with the connect link. Do NOT suggest navigating to Integrations manually.", "- If the integration search succeeds, do not stop because of a separate health warning or stale workspace cache. The live gateway-backed search result is the authority.", "- If an integration tool call fails because of argument shape, fix the arguments and retry once before considering any fallback.", "- When the user implicitly asks for the full dataset, keep paginating until the tool response no longer advertises more pages.", @@ -2161,7 +2192,6 @@ For multi-session projects, write a session handoff summary to \`${workspaceDir} - For connected apps (Gmail, Slack, GitHub, etc.), use the **${DENCH_INTEGRATIONS_DISPLAY_NAME}** tools directly. Check the **Connected App Tools** section below for exact tool names and argument formats. - **When the user mentions ANY third-party app or service**, always call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` before answering to verify availability, inspect the ranked tool matches, and read the returned full schemas — this applies to all apps (HubSpot, Salesforce, Slack, Gmail, etc.), not just a fixed list. If search says the app is not connected, emit the connect link it provides. - If the exact integration tool name is unclear, call \`${COMPOSIO_SEARCH_TOOLS_NAME}\` before guessing or browsing the curated integration tools for this workspace, then use the returned \`dispatcher_input\` unchanged except for the final \`arguments\`. -- Use \`${COMPOSIO_RESOLVE_TOOL_NAME}\` only when a single best-match compatibility result is explicitly more convenient than the ranked search output. - **Never** use curl or raw HTTP to call gateway integration endpoints — always use the integration wrapper tools. - **Never** use \`gog\` for Gmail/Calendar/Drive when ${DENCH_INTEGRATIONS_DISPLAY_NAME} is connected or the user mentions the connected-app layer/rube/map/MCP. \`gog\` is a fallback only when the user explicitly asks for it or the integration layer is unavailable. @@ -2185,13 +2215,37 @@ function shouldRegisterComposioResolver(workspaceDir: string): boolean { export default function register(api: any) { const config = api?.config?.plugins?.entries?.["dench-identity"]?.config; if (config?.enabled === false) { + postDebugLog( + "H5", + "extensions/dench-identity/index.ts:2156", + "dench identity plugin disabled", + { enabled: false }, + ); return; } const workspaceDir = resolveWorkspaceDir(api); - if (workspaceDir && typeof api.registerTool === "function" && shouldRegisterComposioResolver(workspaceDir)) { + const shouldRegisterComposio = workspaceDir + ? shouldRegisterComposioResolver(workspaceDir) + : false; + const canRegisterComposio = + Boolean(workspaceDir) + && typeof api.registerTool === "function" + && shouldRegisterComposio; + postDebugLog( + "H5", + "extensions/dench-identity/index.ts:2160", + "dench identity registration state", + { + workspaceDirPresent: Boolean(workspaceDir), + registerToolAvailable: typeof api.registerTool === "function", + shouldRegisterComposio, + searchToolWillRegister: canRegisterComposio, + resolveToolRegistered: false, + }, + ); + if (canRegisterComposio) { api.registerTool(createComposioSearchTool(api)); - api.registerTool(createComposioResolveTool(api)); } api.on( @@ -2201,8 +2255,22 @@ export default function register(api: any) { if (!workspaceDir) { return; } + const prompt = buildIdentityPrompt(workspaceDir); + postDebugLog( + "H6", + "extensions/dench-identity/index.ts:2172", + "identity prompt built", + { + workspaceDirPresent: true, + promptChars: prompt.length, + includesSearchTool: prompt.includes(COMPOSIO_SEARCH_TOOLS_NAME), + includesCallTool: prompt.includes(COMPOSIO_CALL_TOOL_NAME), + includesResolveTool: prompt.includes(COMPOSIO_RESOLVE_TOOL_NAME), + includesIntegrationsHeading: prompt.includes("Connected App Tools"), + }, + ); return { - prependSystemContext: buildIdentityPrompt(workspaceDir), + prependSystemContext: prompt, }; }, { priority: 100 }, diff --git a/skills/composio-apps/SKILL.md b/skills/composio-apps/SKILL.md index e5a874dcd5adb..e31b2db03cc21 100644 --- a/skills/composio-apps/SKILL.md +++ b/skills/composio-apps/SKILL.md @@ -5,7 +5,7 @@ description: Connected app tool recipes for Dench Integrations (Gmail, Slack, Gi # Dench Integrations connected apps -Use the **Dench Integrations tools** only. In DenchClaw, always search first with `composio_search_tools`, inspect the returned full schemas plus plan/pitfall guidance, then execute the chosen tool via `composio_call_tool`. `composio_resolve_tool` still exists as a compatibility wrapper when you want a single best-match result, but ranked search is the default path. Some sessions may still expose direct tool names like `GMAIL_FETCH_EMAILS`, but do not rely on that as the default path. +Use the **Dench Integrations tools** only. In DenchClaw, always search first with `composio_search_tools`, inspect the returned full schemas plus plan/pitfall guidance, then execute the chosen tool via `composio_call_tool`. Ranked search is the only supported discovery path. Some sessions may still expose direct tool names like `GMAIL_FETCH_EMAILS`, but do not rely on that as the default path. Do **not** use: - `gog` @@ -16,19 +16,18 @@ Do **not** use: If the user mentions Dench Integrations, the connected-app layer, rube, map, MCP, or says an app is already connected, this is the only allowed integration path. If the integration wrapper tools are unavailable in the current session, stop and report repair guidance instead of bypassing them. -Do not rely on workspace cache files like `composio-tool-index.json`, `composio-tool-catalog.json`, or `composio-mcp-status.json` as runtime truth. `composio_search_tools` is the source of truth because it returns official integration search results, full `input_schema`, connection/account status, `recommended_plan_steps`, `known_pitfalls`, and a reusable `search_session_id` when available. +Do not rely on workspace cache files like `composio-tool-index.json`, `composio-tool-catalog.json`, or `composio-mcp-status.json` as runtime truth. `composio_search_tools` is the source of truth because it returns official integration search results, full `input_schema`, connection/account status, `recommended_plan_steps`, `known_pitfalls`, and a gateway-issued `execution_ref` inside `dispatcher_input`. ## General rules - Tool names are **uppercase** with underscores (e.g. `GMAIL_FETCH_EMAILS`). -- Execute searched or resolved tools through `composio_call_tool` with the returned `app`, `tool_name`, `search_context_token`, optional `search_session_id`, optional selected `account`, and the final `arguments` object. -- Do not invent or alter `dispatcher_input`; copy it from `composio_search_tools` or `composio_resolve_tool` and only add the final `arguments`. +- Execute searched tools through `composio_call_tool` with the returned `execution_ref` and the final `arguments` object. +- Do not invent or alter `dispatcher_input`; copy it from `composio_search_tools` unchanged and only add the final `arguments`. - Pass **JSON-shaped** arguments as the tool schema requires: arrays are arrays, not comma-separated strings. - Read the returned `input_schema` before filling arguments. Use exact field names and types from that schema. - Treat the live schema as authoritative over any recipe table below. Pay attention to `required`, property `type`, nested objects/arrays, enums, defaults, and pagination fields. - If a call fails on argument shape, fix the types and retry once before escalating. -- If `composio_search_tools` or `composio_resolve_tool` says account selection is required, ask the user which connected account to use before calling `composio_call_tool`. -- Do not pass `account` unless the search flow actually returned or required it for the chosen live result. +- If `composio_search_tools` says account selection is required, ask the user which connected account to use before calling `composio_call_tool`. - If the returned search result includes pagination fields such as `starting_after`, `next_cursor`, `page`, or `page_token`, keep paginating until complete when the user asked for the full dataset. - Never fall back to `gog`, curl, or raw gateway HTTP for a connected app task unless the user explicitly asks for that non-integration path. @@ -103,4 +102,4 @@ Do not rely on workspace cache files like `composio-tool-index.json`, `composio- ## Subagent handoff -When delegating, include: which app, the exact tool name, and the argument object you intend (copy shapes from the live tool schema, `composio_search_tools`, or `composio_resolve_tool`). +When delegating, include: which app, the exact tool name, the `execution_ref`, and the argument object you intend (copy shapes from the live tool schema or `composio_search_tools`). diff --git a/src/cli/dench-cloud.test.ts b/src/cli/dench-cloud.test.ts index 8688641873d87..2c1bcb447ae46 100644 --- a/src/cli/dench-cloud.test.ts +++ b/src/cli/dench-cloud.test.ts @@ -158,7 +158,6 @@ describe("dench-cloud helpers", () => { }); expect(patch.tools.alsoAllow).toEqual([ "composio_search_tools", - "composio_resolve_tool", "composio_call_tool", ]); }); diff --git a/src/cli/dench-cloud.ts b/src/cli/dench-cloud.ts index 5bc466d153599..d2e5b41933ef2 100644 --- a/src/cli/dench-cloud.ts +++ b/src/cli/dench-cloud.ts @@ -129,7 +129,6 @@ export function buildDenchGatewayCatalogUrl(gatewayUrl: string | undefined): str export const RECOMMENDED_DENCH_CLOUD_MODEL_ID = "claude-opus-4.6"; export const DENCH_COMPOSIO_WRAPPER_TOOLS = [ "composio_search_tools", - "composio_resolve_tool", "composio_call_tool", ] as const;