diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 24fd80608ce..7e1d2cc8054 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -19,6 +19,10 @@ When the user asks you to create a block: Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs. +When block work changes tool execution, same-process work must use a registered +`InternalToolConfig.operation`. Never add a Sim `/api/...` self-hop or the retired +`directExecution` property. + - Do NOT invent block outputs for undocumented tool responses - Do NOT describe unknown JSON shapes as if they were confirmed - Do NOT wire fields into the block just because they seem likely to exist diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index b8e69d488bb..78455ef54e0 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -68,7 +68,7 @@ Choose the tool boundary before writing the declaration: - Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint. Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare -`request.internal`, or add an API route merely to reuse code, normalize files, or authorize +`request.internal`, add the retired `directExecution` property, or add an API route merely to reuse code, normalize files, or authorize resources. A real external/browser route and an in-process tool may share the same operation, but neither calls the other. Follow the full transport and handler rules in the `add-tools` skill. @@ -171,7 +171,7 @@ Hard rules: - Never substitute secret plaintext into source or serialize plaintext provenance. - Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Project proven +- Never attach private provenance to an external URL. Project proven model-visible external fields with `request.modelInput`; otherwise preserve ordinary request semantics. Use a registered in-process operation when encrypted provenance must cross the boundary. @@ -607,7 +607,7 @@ If creating V2 versions (API-aligned outputs): - [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute external HTTP(S) `ToolConfig.request` - [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or - has an HTTP fallback for an in-process operation + `directExecution`, or has an HTTP fallback for an in-process operation - [ ] All params have correct visibility - [ ] All nullable fields use `?? null` - [ ] All optional outputs have `optional: true` diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 0b7a5cc1f6c..985073b2b5d 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -54,7 +54,7 @@ Every tool must use exactly one of these configurations: HTTP(S) provider endpoint. Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare -`request.internal`, import a route module, or create an API route merely to normalize files, +`request.internal`, add the retired `directExecution` property, import a route module, or create an API route merely to normalize files, authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but the route and the tool must call the same operation directly. A true cross-process/capability boundary uses an explicit server client and is not disguised as a tool self-hop. @@ -524,6 +524,7 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet` HTTP(S) `ToolConfig.request` - [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares `request.internal` +- [ ] No tool declares `directExecution`; in-process work uses a registered operation - [ ] All params have explicit `required: true` or `required: false` - [ ] All params have appropriate `visibility` - [ ] All nullable response fields use `?? null` diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index f3e776c848f..2bdfbc8e29f 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -508,7 +508,8 @@ Two rules the checks enforce: Webhook and polling routes are legitimate external ingress boundaries. They must not call this Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation or authorized application use case and call it directly from the trigger handler and any other -server adapter. HTTP is reserved for an actual cross-process/capability boundary. +server adapter. HTTP is reserved for an actual cross-process/capability boundary. Tool work uses a +registered `InternalToolConfig.operation`; the retired `directExecution` property must not return. ### Trigger Definition - [ ] Created `utils.ts` with options, instructions, extra fields, and output builders diff --git a/.agents/skills/tool-registry-boundary/SKILL.md b/.agents/skills/tool-registry-boundary/SKILL.md index 6e1caaf0a64..ca22f8c856d 100644 --- a/.agents/skills/tool-registry-boundary/SKILL.md +++ b/.agents/skills/tool-registry-boundary/SKILL.md @@ -11,7 +11,12 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec > Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**. -`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules. +`@/tools/registry` is a ~9,000-line barrel importing every tool. External `ToolConfig` entries mix +plain data (`params`, `outputs`, `name`) with request/response closures, while +`InternalToolConfig` entries contain semantic input projection and load their server implementation +through `lib/internal/tool-operations/registry.server.ts`. Request closures can still reach SDK +clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it +costs ~4,700 additional modules. `getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in. @@ -95,4 +100,5 @@ The canvas route reached the registry through **four** redundant edges — `prov Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph. -If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths. +If it genuinely executes — builds an external request, transforms a response, or dispatches a +registered internal operation — use `getTool`, and keep that file off client-reachable paths. diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index abf1740647d..a009e51d1ac 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -159,8 +159,9 @@ search, extraction, or "AI-powered" marketing terminology. - [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection and scope, strip private metadata, and persist, import, or propagate it at the owning boundary -- [ ] Private provenance is never attached to external URLs or `directExecution`; proven - model-visible external fields use projection, while other external inputs remain unchanged +- [ ] Private provenance is never attached to external URLs; registered in-process operations + preserve it through `operation.modelInput` / `operation.secretProvenance`, while proven + model-visible external fields use request projection and other external inputs remain unchanged - [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance - [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results; only execution-scoped, activated Sim provenance is projected at shared model/log boundaries diff --git a/apps/sim/app/api/tools/netsuite/objects/route.test.ts b/apps/sim/app/api/tools/netsuite/objects/route.test.ts index a709cf52da0..9d6d9658262 100644 --- a/apps/sim/app/api/tools/netsuite/objects/route.test.ts +++ b/apps/sim/app/api/tools/netsuite/objects/route.test.ts @@ -31,11 +31,11 @@ vi.mock('@/lib/oauth/credential-service', () => ({ resolveCredentialAccessToken: mockResolveCredentialAccessToken, resolveOAuthAccountId: mockResolveOAuthAccountId, })) -vi.mock('@/tools/netsuite/get_async_status', () => ({ - netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus }, +vi.mock('@/lib/internal/netsuite/operations/get-async-status', () => ({ + executeNetsuiteGetAsyncStatusOperation: mockGetAsyncStatus, })) -vi.mock('@/tools/netsuite/list_record_types', () => ({ - netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes }, +vi.mock('@/lib/internal/netsuite/operations/list-record-types', () => ({ + executeNetsuiteListRecordTypesOperation: mockListRecordTypes, })) import { POST } from '@/app/api/tools/netsuite/objects/route' diff --git a/apps/sim/app/api/tools/netsuite/objects/route.ts b/apps/sim/app/api/tools/netsuite/objects/route.ts index b6183e13a28..e7a2baba959 100644 --- a/apps/sim/app/api/tools/netsuite/objects/route.ts +++ b/apps/sim/app/api/tools/netsuite/objects/route.ts @@ -12,9 +12,9 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status' +import { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types' import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' -import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status' -import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types' import type { NetSuiteAuthParams } from '@/tools/netsuite/types' import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils' import type { ToolResponse } from '@/tools/types' @@ -180,14 +180,13 @@ async function executeDiscoveryTool( throwIfAborted(signal) switch (body.kind) { case 'record_types': { - const execute = netsuiteListRecordTypesTool.directExecution - if (!execute) throw new Error('NetSuite record-type tool is not executable') - return execute(auth, signal) + return executeNetsuiteListRecordTypesOperation(auth, signal) } case 'async_tasks': { - const execute = netsuiteGetAsyncStatusTool.directExecution - if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable') - return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal) + return executeNetsuiteGetAsyncStatusOperation( + { ...auth, jobId: body.jobId, view: 'tasks' }, + signal + ) } } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index ea37f3a2bad..7041a54fc7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -416,12 +416,12 @@ export function PreviewWorkflow({ // Check for direct error on the subflow block itself (e.g., loop resolution errors) // before falling back to children-derived status - const directExecution = blockExecutionMap.get(blockId) + const blockExecution = blockExecutionMap.get(blockId) const subflowExecutionStatus: ExecutionStatus | undefined = - directExecution?.status === 'error' + blockExecution?.status === 'error' ? 'error' : (getSubflowExecutionStatus(blockId) ?? - (directExecution ? (directExecution.status as ExecutionStatus) : undefined)) + (blockExecution ? (blockExecution.status as ExecutionStatus) : undefined)) nodeArray.push({ id: blockId, diff --git a/apps/sim/lib/internal/bitbucket/execute-tool.ts b/apps/sim/lib/internal/bitbucket/execute-tool.ts new file mode 100644 index 00000000000..70e516b90c6 --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/execute-tool.ts @@ -0,0 +1,35 @@ +import { + executeBitbucketGetFileOperation, + executeBitbucketGetPipelineStepLogOperation, + executeBitbucketGetPullRequestDiffOperation, + executeBitbucketGetPullRequestDiffstatOperation, +} from '@/lib/internal/bitbucket/operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeBitbucketTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'bitbucket_get_file': + return executeToolOperationImplementation(executeBitbucketGetFileOperation, request) + case 'bitbucket_get_pipeline_step_log': + return executeToolOperationImplementation( + executeBitbucketGetPipelineStepLogOperation, + request + ) + case 'bitbucket_get_pull_request_diff': + return executeToolOperationImplementation( + executeBitbucketGetPullRequestDiffOperation, + request + ) + case 'bitbucket_get_pull_request_diffstat': + return executeToolOperationImplementation( + executeBitbucketGetPullRequestDiffstatOperation, + request + ) + default: + return Response.json( + { success: false, error: `Unsupported bitbucket tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/bitbucket/operations/get-file.ts b/apps/sim/lib/internal/bitbucket/operations/get-file.ts new file mode 100644 index 00000000000..20f25cfc9d3 --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/operations/get-file.ts @@ -0,0 +1,65 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { fileUrl } from '@/tools/bitbucket/get_file' +import type { BitbucketGetFileParams } from '@/tools/bitbucket/types' +import { + assertBitbucketResponseOk, + BITBUCKET_RAW_TRANSFER_MAX_BYTES, + bitbucketHeaders, + bitbucketHeadRange, + bitbucketJson, + bitbucketMaxCharacters, + bitbucketRawHead, + normalizeBitbucketFileMetadata, +} from '@/tools/bitbucket/utils' + +export const executeBitbucketGetFileOperation: InternalToolOperationImplementation< + BitbucketGetFileParams +> = async (params, signal) => { + bitbucketMaxCharacters(params.maxCharacters) + const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server') + const metadataResponse = await secureBitbucketRead( + fileUrl(params, true), + bitbucketHeaders(params.accessToken), + 256 * 1024, + { stripAuthOnRedirect: true, signal } + ) + await assertBitbucketResponseOk(metadataResponse) + const metadata = normalizeBitbucketFileMetadata(await bitbucketJson(metadataResponse)) + if (metadata.isBinary === true) { + return { + success: true, + output: { + content: null, + binary: true, + truncated: metadata.size === null ? null : metadata.size > 0, + returnedBytes: 0, + fullBytes: metadata.size, + contentType: null, + }, + } + } + + const rawResponse = await secureBitbucketRead( + fileUrl(params), + bitbucketHeaders(params.accessToken, { + json: false, + range: bitbucketHeadRange(params.maxCharacters), + }), + BITBUCKET_RAW_TRANSFER_MAX_BYTES, + { stripAuthOnRedirect: true, signal } + ) + await assertBitbucketResponseOk(rawResponse) + const raw = await bitbucketRawHead(rawResponse, params.maxCharacters, metadata.isBinary) + const fullBytes = raw.fullBytes ?? metadata.size + return { + success: true, + output: { + ...raw, + truncated: + raw.binary === true && raw.truncated === null && fullBytes !== null + ? fullBytes > 0 + : raw.truncated, + fullBytes, + }, + } +} diff --git a/apps/sim/lib/internal/bitbucket/operations/get-pipeline-step-log.ts b/apps/sim/lib/internal/bitbucket/operations/get-pipeline-step-log.ts new file mode 100644 index 00000000000..9b85d80cd69 --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/operations/get-pipeline-step-log.ts @@ -0,0 +1,40 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { + BITBUCKET_RANGE_NOT_SATISFIABLE, + EMPTY_CONTENT_RANGE_PATTERN, + stepLogUrl, +} from '@/tools/bitbucket/get_pipeline_step_log' +import type { BitbucketGetPipelineStepLogParams } from '@/tools/bitbucket/types' +import { + assertBitbucketResponseOk, + BITBUCKET_LOG_TRANSFER_MAX_BYTES, + bitbucketHeaders, + bitbucketMaxCharacters, + bitbucketRawTail, + bitbucketTailRange, +} from '@/tools/bitbucket/utils' + +export const executeBitbucketGetPipelineStepLogOperation: InternalToolOperationImplementation< + BitbucketGetPipelineStepLogParams +> = async (params, signal) => { + bitbucketMaxCharacters(params.maxCharacters, true) + const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server') + const response = await secureBitbucketRead( + stepLogUrl(params), + bitbucketHeaders(params.accessToken, { + json: false, + range: bitbucketTailRange(params.maxCharacters), + }), + BITBUCKET_LOG_TRANSFER_MAX_BYTES, + { stripAuthOnRedirect: true, signal } + ) + if ( + response.status === BITBUCKET_RANGE_NOT_SATISFIABLE && + EMPTY_CONTENT_RANGE_PATTERN.test(response.headers.get('content-range') ?? '') + ) { + await response.body?.cancel() + return { success: true, output: { log: '', truncated: false, totalBytes: 0 } } + } + await assertBitbucketResponseOk(response) + return { success: true, output: await bitbucketRawTail(response, params.maxCharacters) } +} diff --git a/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diff.ts b/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diff.ts new file mode 100644 index 00000000000..d6d48cdbe0a --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diff.ts @@ -0,0 +1,34 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { pullRequestDiffUrl, transformDiff } from '@/tools/bitbucket/get_pull_request_diff' +import type { BitbucketGetPullRequestDiffParams } from '@/tools/bitbucket/types' +import { + assertBitbucketResponseOk, + BITBUCKET_RAW_TRANSFER_MAX_BYTES, + bitbucketHeaders, + bitbucketHeadRange, + bitbucketRepositoryPathQuery, +} from '@/tools/bitbucket/utils' + +export const executeBitbucketGetPullRequestDiffOperation: InternalToolOperationImplementation< + BitbucketGetPullRequestDiffParams +> = async (params, signal) => { + const { secureBitbucketPullRequestRedirect } = await import('@/tools/bitbucket/utils.server') + const headers = bitbucketHeaders(params.accessToken, { + json: false, + range: bitbucketHeadRange(params.maxCharacters), + }) + const response = await secureBitbucketPullRequestRedirect( + pullRequestDiffUrl(params), + params.workspaceSlug, + params.repoSlug, + 'diff', + headers, + BITBUCKET_RAW_TRANSFER_MAX_BYTES, + { + signal, + targetQuery: { path: bitbucketRepositoryPathQuery(params.path), binary: 'false' }, + } + ) + await assertBitbucketResponseOk(response) + return transformDiff(response, params.maxCharacters) +} diff --git a/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diffstat.ts b/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diffstat.ts new file mode 100644 index 00000000000..768460ddee3 --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/operations/get-pull-request-diffstat.ts @@ -0,0 +1,69 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { + decodedPathname, + pullRequestDiffstatUrl, +} from '@/tools/bitbucket/get_pull_request_diffstat' +import type { BitbucketPaginatedPullRequestParams } from '@/tools/bitbucket/types' +import { + assertBitbucketResponseOk, + bitbucketHeaders, + bitbucketJson, + bitbucketPageLength, + normalizeBitbucketDiffstat, + normalizeBitbucketPage, + validateBitbucketPullRequestRedirect, +} from '@/tools/bitbucket/utils' + +export const executeBitbucketGetPullRequestDiffstatOperation: InternalToolOperationImplementation< + BitbucketPaginatedPullRequestParams +> = async (params, signal) => { + const { + resolveBitbucketPullRequestRedirect, + secureBitbucketPullRequestRedirect, + secureBitbucketRead, + } = await import('@/tools/bitbucket/utils.server') + const initialUrl = pullRequestDiffstatUrl(params) + const headers = bitbucketHeaders(params.accessToken) + let response: Response + if (params.nextUrl !== undefined) { + const continuation = validateBitbucketPullRequestRedirect( + params.nextUrl, + params.workspaceSlug, + params.repoSlug, + 'diffstat' + ) + const resolvedTarget = await resolveBitbucketPullRequestRedirect( + initialUrl, + params.workspaceSlug, + params.repoSlug, + 'diffstat', + headers, + { signal } + ) + if (decodedPathname(continuation) !== decodedPathname(resolvedTarget)) { + throw new Error('nextUrl does not belong to this Bitbucket pull request diffstat') + } + response = await secureBitbucketRead(continuation, headers, 2 * 1024 * 1024, { + maxRedirects: 0, + signal, + }) + } else { + response = await secureBitbucketPullRequestRedirect( + initialUrl, + params.workspaceSlug, + params.repoSlug, + 'diffstat', + headers, + 2 * 1024 * 1024, + { + signal, + targetQuery: { pagelen: String(bitbucketPageLength(params.pageLen)) }, + } + ) + } + await assertBitbucketResponseOk(response) + return { + success: true, + output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat), + } +} diff --git a/apps/sim/lib/internal/bitbucket/operations/index.ts b/apps/sim/lib/internal/bitbucket/operations/index.ts new file mode 100644 index 00000000000..3472cdadbf8 --- /dev/null +++ b/apps/sim/lib/internal/bitbucket/operations/index.ts @@ -0,0 +1,4 @@ +export { executeBitbucketGetFileOperation } from '@/lib/internal/bitbucket/operations/get-file' +export { executeBitbucketGetPipelineStepLogOperation } from '@/lib/internal/bitbucket/operations/get-pipeline-step-log' +export { executeBitbucketGetPullRequestDiffOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diff' +export { executeBitbucketGetPullRequestDiffstatOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diffstat' diff --git a/apps/sim/lib/internal/browser-use/execute-tool.ts b/apps/sim/lib/internal/browser-use/execute-tool.ts new file mode 100644 index 00000000000..9b099759841 --- /dev/null +++ b/apps/sim/lib/internal/browser-use/execute-tool.ts @@ -0,0 +1,15 @@ +import { executeRunTaskOperation } from '@/lib/internal/browser-use/operations/run-task' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeBrowserUseTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'browser_use_run_task': + return executeToolOperationImplementation(executeRunTaskOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported browser-use tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/browser-use/operations/run-task.test.ts b/apps/sim/lib/internal/browser-use/operations/run-task.test.ts new file mode 100644 index 00000000000..5974ffd4ad8 --- /dev/null +++ b/apps/sim/lib/internal/browser-use/operations/run-task.test.ts @@ -0,0 +1,286 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeRunTaskOperation } from '@/lib/internal/browser-use/operations/run-task' + +const mockFetch = vi.fn() + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('executeRunTaskOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('validates provider payloads while preserving the documented task output', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'session-1' })) + .mockResolvedValueOnce( + jsonResponse({ + status: 'finished', + sessionId: 'session-1', + output: { result: 'complete' }, + steps: [ + { + number: 1, + memory: 'Opened the page', + evaluationPreviousGoal: 'Succeeded', + nextGoal: 'Finish', + url: 'https://example.com', + actions: ['{"click":{"index":1}}'], + providerField: 'preserved', + }, + ], + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + liveUrl: 'https://live.browser-use.com/session-1', + publicShareUrl: 'https://browser-use.com/share/session-1', + }) + ) + + const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }) + + expect(result).toEqual({ + success: true, + output: { + id: 'task-1', + success: true, + output: { result: 'complete' }, + steps: [ + { + number: 1, + memory: 'Opened the page', + evaluationPreviousGoal: 'Succeeded', + nextGoal: 'Finish', + url: 'https://example.com', + actions: ['{"click":{"index":1}}'], + providerField: 'preserved', + }, + ], + liveUrl: 'https://live.browser-use.com/session-1', + shareUrl: 'https://browser-use.com/share/session-1', + sessionId: 'session-1', + }, + error: undefined, + }) + expect(mockFetch).toHaveBeenCalledTimes(3) + for (const [, request] of mockFetch.mock.calls) { + expect(request).toEqual( + expect.objectContaining({ + redirect: 'error', + headers: expect.objectContaining({ 'X-Browser-Use-API-Key': 'api-key' }), + }) + ) + } + }) + + it('uses the created profile session to fetch the live URL when task status omits it', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'profile-session' })) + .mockResolvedValueOnce(jsonResponse({ id: 'task-1' })) + .mockResolvedValueOnce(jsonResponse({ status: 'finished', output: 'done' })) + .mockResolvedValueOnce( + jsonResponse({ + liveUrl: 'https://live.browser-use.com/profile-session', + publicShareUrl: 'https://browser-use.com/share/profile-session', + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + const result = await executeRunTaskOperation({ + task: 'Open the page', + apiKey: 'api-key', + profile_id: 'profile-1', + }) + + expect(result.output).toMatchObject({ + sessionId: 'profile-session', + liveUrl: 'https://live.browser-use.com/profile-session', + shareUrl: 'https://browser-use.com/share/profile-session', + }) + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'https://api.browser-use.com/api/v2/sessions/profile-session', + expect.objectContaining({ method: 'GET' }) + ) + }) + + it('returns an actionable error for a terminal failed task', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'task-1' })) + .mockResolvedValueOnce( + jsonResponse({ status: 'failed', output: 'Navigation could not reach the target' }) + ) + + const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }) + + expect(result).toMatchObject({ + success: false, + error: 'BrowserUse task failed: Navigation could not reach the target', + output: { + success: false, + output: 'Navigation could not reach the target', + }, + }) + }) + + it('rejects a malformed successful create-task response', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 'session-1' })) + + await expect( + executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }) + ).resolves.toEqual({ + success: false, + output: { + id: '', + success: false, + output: null, + steps: [], + liveUrl: null, + shareUrl: null, + sessionId: null, + }, + error: 'BrowserUse returned an invalid create-task response', + }) + }) + + it('normalizes non-Error provider failures', async () => { + mockFetch.mockRejectedValueOnce('provider unavailable') + + await expect( + executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }) + ).resolves.toEqual({ + success: false, + output: { + id: '', + success: false, + output: null, + steps: [], + liveUrl: null, + shareUrl: null, + sessionId: null, + }, + error: 'Error creating task: provider unavailable', + }) + }) + + it.each([ + ['an HTTP error', new Response('rejected', { status: 400, statusText: 'Bad Request' })], + ['a schema-invalid success', jsonResponse({ sessionId: 'session-1' })], + ])('stops a profile session when task creation returns %s', async (_case, taskResponse) => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'profile-session' })) + .mockResolvedValueOnce(taskResponse) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + const result = await executeRunTaskOperation({ + task: 'Open the page', + apiKey: 'api-key', + profile_id: 'profile-1', + }) + + expect(result.success).toBe(false) + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://api.browser-use.com/api/v2/sessions/profile-session', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ action: 'stop' }), + redirect: 'error', + signal: expect.any(AbortSignal), + }) + ) + }) + + it('propagates cancellation while still stopping a created profile session', async () => { + const controller = new AbortController() + const abortError = new DOMException('cancelled', 'AbortError') + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'profile-session' })) + .mockImplementationOnce(async (_input, request) => { + expect(request?.signal).toBe(controller.signal) + controller.abort(abortError) + throw abortError + }) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect( + executeRunTaskOperation( + { task: 'Open the page', apiKey: 'api-key', profile_id: 'profile-1' }, + controller.signal + ) + ).rejects.toBe(abortError) + + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://api.browser-use.com/api/v2/sessions/profile-session', + expect.objectContaining({ + method: 'PATCH', + redirect: 'error', + signal: expect.any(AbortSignal), + }) + ) + expect(mockFetch.mock.calls[2]?.[1]?.signal).not.toBe(controller.signal) + }) + + it('stops an automatically created task session when polling is cancelled', async () => { + const controller = new AbortController() + const abortError = new DOMException('cancelled', 'AbortError') + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'task-session' })) + .mockImplementationOnce(async () => { + controller.abort(abortError) + throw abortError + }) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect( + executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }, controller.signal) + ).rejects.toBe(abortError) + + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://api.browser-use.com/api/v2/sessions/task-session', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ action: 'stop' }), + signal: expect.any(AbortSignal), + }) + ) + }) + + it('stops an automatically created task session when polling times out', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(1_000_000_000_000_000) + mockFetch + .mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'task-session' })) + .mockResolvedValueOnce(jsonResponse({ status: 'running', sessionId: 'task-session' })) + .mockResolvedValueOnce( + jsonResponse({ shareUrl: 'https://browser-use.com/share/task-session' }) + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }) + now.mockRestore() + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('Task did not complete within the maximum polling time'), + }) + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + 'https://api.browser-use.com/api/v2/sessions/task-session', + expect.objectContaining({ method: 'PATCH', signal: expect.any(AbortSignal) }) + ) + }) +}) diff --git a/apps/sim/lib/internal/browser-use/operations/run-task.ts b/apps/sim/lib/internal/browser-use/operations/run-task.ts new file mode 100644 index 00000000000..4b9bf78b10a --- /dev/null +++ b/apps/sim/lib/internal/browser-use/operations/run-task.ts @@ -0,0 +1,580 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { z } from 'zod' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { + BrowserUseRunTaskParams, + BrowserUseRunTaskResponse, + BrowserUseTaskStep, +} from '@/tools/browser_use/types' + +const logger = createLogger('BrowserUseTool') + +const POLL_INTERVAL_MS = 5000 +const MAX_POLL_TIME_MS = getMaxExecutionTimeout() +const MAX_CONSECUTIVE_ERRORS = 3 +const API_BASE = 'https://api.browser-use.com/api/v2' + +const createSessionResponseSchema = z.object({ + id: z.string().min(1), +}) + +const sessionDetailsResponseSchema = z.object({ + liveUrl: z.string().nullable().optional(), + publicShareUrl: z.string().nullable().optional(), +}) + +const taskStepSchema: z.ZodType = z + .object({ + number: z.number(), + memory: z.string(), + evaluationPreviousGoal: z.string(), + nextGoal: z.string(), + url: z.string(), + screenshotUrl: z.string().nullable().optional(), + actions: z.array(z.string()), + duration: z.number().nullable().optional(), + }) + .passthrough() + +const taskStatusResponseSchema = z.object({ + status: z.string(), + sessionId: z.string().nullable().optional(), + output: z.unknown().optional(), + steps: z.array(taskStepSchema).optional(), +}) +const SESSION_CLEANUP_TIMEOUT_MS = 10_000 + +const createTaskResponseSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().nullable().optional(), +}) + +const shareResponseSchema = z.object({ + shareUrl: z.string().nullable().optional(), +}) + +interface BrowserUseTaskRequest { + task: string + sessionId?: string + llm?: string + startUrl?: string + maxSteps?: number + structuredOutput?: string + flashMode?: boolean + thinking?: boolean + vision?: boolean | 'auto' + systemPromptExtension?: string + highlightElements?: boolean + allowedDomains?: string[] + secrets?: Record + metadata?: Record +} + +interface BrowserUseFetchOptions { + method?: 'GET' | 'POST' | 'PATCH' + body?: unknown + signal?: AbortSignal +} + +async function fetchBrowserUse( + path: string, + apiKey: string, + options: BrowserUseFetchOptions = {} +): Promise { + options.signal?.throwIfAborted() + const hasBody = options.body !== undefined + const response = await fetch(`${API_BASE}${path}`, { + method: options.method ?? 'GET', + headers: { + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + 'X-Browser-Use-API-Key': apiKey, + }, + ...(hasBody ? { body: JSON.stringify(options.body) } : {}), + redirect: 'error', + signal: options.signal, + }) + options.signal?.throwIfAborted() + return response +} + +async function waitForNextPoll(signal?: AbortSignal): Promise { + if (!signal) { + await sleep(POLL_INTERVAL_MS) + return + } + signal.throwIfAborted() + + let abortHandler: (() => void) | undefined + const aborted = new Promise((_, reject) => { + abortHandler = () => + reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')) + signal.addEventListener('abort', abortHandler, { once: true }) + }) + + try { + await Promise.race([sleep(POLL_INTERVAL_MS), aborted]) + } finally { + if (abortHandler) signal.removeEventListener('abort', abortHandler) + } +} + +async function createSessionWithProfile( + profileId: string, + apiKey: string, + signal?: AbortSignal +): Promise<{ sessionId: string } | { error: string }> { + try { + const response = await fetchBrowserUse('/sessions', apiKey, { + method: 'POST', + body: { profileId: profileId.trim() }, + signal, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`Failed to create session with profile: ${errorText}`) + return { error: `Failed to create session with profile: ${response.statusText}` } + } + + const parsed = createSessionResponseSchema.safeParse(await response.json()) + signal?.throwIfAborted() + if (!parsed.success) { + logger.error('BrowserUse returned an invalid create-session response') + return { error: 'BrowserUse returned an invalid create-session response' } + } + const data = parsed.data + logger.info(`Created session ${data.id} with profile ${profileId}`) + return { sessionId: data.id } + } catch (error: unknown) { + signal?.throwIfAborted() + logger.error('Error creating session with profile:', error) + return { error: `Error creating session: ${getErrorMessage(error, 'Unknown error')}` } + } +} + +async function stopSession(sessionId: string, apiKey: string): Promise { + try { + const response = await fetchBrowserUse(`/sessions/${encodeURIComponent(sessionId)}`, apiKey, { + method: 'PATCH', + body: { action: 'stop' }, + signal: AbortSignal.timeout(SESSION_CLEANUP_TIMEOUT_MS), + }) + + if (response.ok) { + logger.info(`Stopped session ${sessionId}`) + } else { + logger.warn(`Failed to stop session ${sessionId}: ${response.statusText}`) + } + } catch (error: unknown) { + logger.warn(`Error stopping session ${sessionId}:`, error) + } +} + +async function fetchSessionLiveUrl( + sessionId: string, + apiKey: string, + signal?: AbortSignal +): Promise<{ liveUrl: string | null; publicShareUrl: string | null }> { + try { + const response = await fetchBrowserUse(`/sessions/${encodeURIComponent(sessionId)}`, apiKey, { + signal, + }) + if (!response.ok) { + return { liveUrl: null, publicShareUrl: null } + } + const parsed = sessionDetailsResponseSchema.safeParse(await response.json()) + signal?.throwIfAborted() + if (!parsed.success) { + logger.warn(`BrowserUse returned an invalid session response for ${sessionId}`) + return { liveUrl: null, publicShareUrl: null } + } + const data = parsed.data + return { + liveUrl: data.liveUrl ?? null, + publicShareUrl: data.publicShareUrl ?? null, + } + } catch (error: unknown) { + signal?.throwIfAborted() + logger.warn(`Error fetching session ${sessionId}:`, error) + return { liveUrl: null, publicShareUrl: null } + } +} + +function normalizeSecrets( + variables: BrowserUseRunTaskParams['variables'] +): Record { + const secrets: Record = {} + if (!variables) return secrets + + if (Array.isArray(variables)) { + for (const row of variables) { + const cells = + typeof row.cells === 'object' && row.cells !== null + ? (row.cells as Record) + : undefined + const key = cells?.Key ?? row.Key + const value = cells?.Value ?? row.Value + if (key && value !== undefined) { + secrets[String(key)] = value + } + } + } else if (typeof variables === 'object') { + for (const [k, v] of Object.entries(variables)) { + if (typeof v === 'string') secrets[k] = v + } + } + return secrets +} + +function parseAllowedDomains(input?: string | string[]): string[] | undefined { + if (!input) return undefined + const arr = Array.isArray(input) + ? input + : input + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return arr.length > 0 ? arr : undefined +} + +function buildRequestBody( + params: BrowserUseRunTaskParams, + sessionId?: string +): BrowserUseTaskRequest { + const body: BrowserUseTaskRequest = { task: params.task } + + if (sessionId) body.sessionId = sessionId + if (params.model) body.llm = params.model + if (params.startUrl?.trim()) body.startUrl = params.startUrl.trim() + if (typeof params.maxSteps === 'number' && params.maxSteps > 0) body.maxSteps = params.maxSteps + if (params.structuredOutput) body.structuredOutput = params.structuredOutput + if (typeof params.flashMode === 'boolean') body.flashMode = params.flashMode + if (typeof params.thinking === 'boolean') body.thinking = params.thinking + if (typeof params.vision === 'boolean' || params.vision === 'auto') body.vision = params.vision + if (params.systemPromptExtension) body.systemPromptExtension = params.systemPromptExtension + if (typeof params.highlightElements === 'boolean') + body.highlightElements = params.highlightElements + + const allowedDomains = parseAllowedDomains(params.allowedDomains) + if (allowedDomains) body.allowedDomains = allowedDomains + + const secrets = normalizeSecrets(params.variables) + if (Object.keys(secrets).length > 0) body.secrets = secrets + + if ( + params.metadata && + typeof params.metadata === 'object' && + Object.keys(params.metadata).length > 0 + ) + body.metadata = params.metadata + + return body +} + +async function fetchTaskStatus( + taskId: string, + apiKey: string, + signal?: AbortSignal +): Promise< + { ok: true; data: z.infer } | { ok: false; error: string } +> { + try { + const response = await fetchBrowserUse(`/tasks/${encodeURIComponent(taskId)}`, apiKey, { + signal, + }) + + if (!response.ok) { + return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` } + } + + const parsed = taskStatusResponseSchema.safeParse(await response.json()) + signal?.throwIfAborted() + if (!parsed.success) { + return { ok: false, error: 'BrowserUse returned an invalid task-status response' } + } + return { ok: true, data: parsed.data } + } catch (error: unknown) { + signal?.throwIfAborted() + return { ok: false, error: getErrorMessage(error, 'Network error') } + } +} + +interface PollResult { + success: boolean + taskEnded: boolean + output: unknown + steps: BrowserUseTaskStep[] + sessionId: string | null + liveUrl: string | null + publicShareUrl: string | null + error?: string +} + +interface PollOptions { + initialSessionId: string | null + signal?: AbortSignal + onSessionId: (sessionId: string) => void +} + +async function pollForCompletion( + taskId: string, + apiKey: string, + options: PollOptions +): Promise { + const { initialSessionId, signal, onSessionId } = options + let consecutiveErrors = 0 + let sessionId = initialSessionId + let liveUrl: string | null = null + let publicShareUrl: string | null = null + const startTime = Date.now() + + while (Date.now() - startTime < MAX_POLL_TIME_MS) { + signal?.throwIfAborted() + const result = await fetchTaskStatus(taskId, apiKey, signal) + + if (!result.ok) { + consecutiveErrors++ + logger.warn( + `Error polling task ${taskId} (attempt ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${result.error}` + ) + + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + return { + success: false, + taskEnded: false, + output: null, + steps: [], + sessionId, + liveUrl, + publicShareUrl, + error: `Failed to poll task status after ${MAX_CONSECUTIVE_ERRORS} attempts: ${result.error}`, + } + } + + await waitForNextPoll(signal) + continue + } + + consecutiveErrors = 0 + const taskData = result.data + if (taskData.sessionId) { + sessionId = taskData.sessionId + onSessionId(taskData.sessionId) + } + const status = taskData.status + + logger.info(`BrowserUse task ${taskId} status: ${status}`) + + if (sessionId && !liveUrl) { + const session = await fetchSessionLiveUrl(sessionId, apiKey, signal) + if (session.liveUrl) { + liveUrl = session.liveUrl + logger.info(`BrowserUse live URL: ${liveUrl}`) + } + if (session.publicShareUrl) publicShareUrl = session.publicShareUrl + } + + if (['finished', 'failed', 'stopped'].includes(status)) { + const output = taskData.output ?? null + return { + success: status === 'finished', + taskEnded: true, + output, + steps: taskData.steps ?? [], + sessionId, + liveUrl, + publicShareUrl, + error: + status === 'finished' + ? undefined + : typeof output === 'string' && output.trim() + ? `BrowserUse task ${status}: ${output.trim()}` + : `BrowserUse task ${status}`, + } + } + + await waitForNextPoll(signal) + } + + const finalResult = await fetchTaskStatus(taskId, apiKey, signal) + if (finalResult.ok && ['finished', 'failed', 'stopped'].includes(finalResult.data.status)) { + const status = finalResult.data.status + const output = finalResult.data.output ?? null + const finalSessionId = finalResult.data.sessionId ?? sessionId + if (finalSessionId) onSessionId(finalSessionId) + return { + success: status === 'finished', + taskEnded: true, + output, + steps: finalResult.data.steps ?? [], + sessionId: finalSessionId, + liveUrl, + publicShareUrl, + error: + status === 'finished' + ? undefined + : typeof output === 'string' && output.trim() + ? `BrowserUse task ${status}: ${output.trim()}` + : `BrowserUse task ${status}`, + } + } + + return { + success: false, + taskEnded: false, + output: null, + steps: [], + sessionId, + liveUrl, + publicShareUrl, + error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`, + } +} + +async function createShareUrl( + sessionId: string, + apiKey: string, + signal?: AbortSignal +): Promise { + try { + const response = await fetchBrowserUse( + `/sessions/${encodeURIComponent(sessionId)}/public-share`, + apiKey, + { + method: 'POST', + signal, + } + ) + + if (!response.ok) { + logger.warn(`Failed to create share URL for session ${sessionId}: ${response.statusText}`) + return null + } + + const parsed = shareResponseSchema.safeParse(await response.json()) + signal?.throwIfAborted() + if (!parsed.success) { + logger.warn(`BrowserUse returned an invalid share response for session ${sessionId}`) + return null + } + return parsed.data.shareUrl ?? null + } catch (error: unknown) { + signal?.throwIfAborted() + logger.warn(`Error creating share URL for session ${sessionId}:`, error) + return null + } +} + +function emptyOutput(): BrowserUseRunTaskResponse['output'] { + return { + id: '', + success: false, + output: null, + steps: [], + liveUrl: null, + shareUrl: null, + sessionId: null, + } +} + +export const executeRunTaskOperation: InternalToolOperationImplementation< + BrowserUseRunTaskParams +> = async ( + params: BrowserUseRunTaskParams, + signal?: AbortSignal +): Promise => { + let profileSessionId: string | undefined + let taskSessionId: string | null = null + let taskEnded = false + + if (params.profile_id) { + logger.info(`Creating session with profile ID: ${params.profile_id}`) + const sessionResult = await createSessionWithProfile(params.profile_id, params.apiKey, signal) + if ('error' in sessionResult) { + return { success: false, output: emptyOutput(), error: sessionResult.error } + } + profileSessionId = sessionResult.sessionId + } + + try { + const requestBody = buildRequestBody(params, profileSessionId) + logger.info('Creating BrowserUse task', { hasSession: !!profileSessionId }) + const response = await fetchBrowserUse('/tasks', params.apiKey, { + method: 'POST', + body: requestBody, + signal, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`Failed to create task: ${errorText}`) + return { + success: false, + output: emptyOutput(), + error: `Failed to create task: ${response.statusText}`, + } + } + + const parsed = createTaskResponseSchema.safeParse(await response.json()) + signal?.throwIfAborted() + if (!parsed.success) { + logger.error('BrowserUse returned an invalid create-task response') + return { + success: false, + output: emptyOutput(), + error: 'BrowserUse returned an invalid create-task response', + } + } + const data = parsed.data + const taskId = data.id + const initialSessionId = profileSessionId ?? data.sessionId ?? null + taskSessionId = initialSessionId + logger.info(`Created BrowserUse task ${taskId}`, { sessionId: initialSessionId }) + + const result = await pollForCompletion(taskId, params.apiKey, { + initialSessionId, + signal, + onSessionId: (discoveredSessionId) => { + taskSessionId = discoveredSessionId + }, + }) + taskEnded = result.taskEnded + + const finalSessionId = result.sessionId ?? initialSessionId + const shareUrl = + result.publicShareUrl ?? + (finalSessionId ? await createShareUrl(finalSessionId, params.apiKey, signal) : null) + + return { + success: result.success && !result.error, + output: { + id: taskId, + success: result.success, + output: result.output, + steps: result.steps, + liveUrl: result.liveUrl, + shareUrl, + sessionId: finalSessionId, + }, + error: result.error, + } + } catch (error: unknown) { + signal?.throwIfAborted() + logger.error('Error creating BrowserUse task:', error) + return { + success: false, + output: emptyOutput(), + error: `Error creating task: ${getErrorMessage(error, 'Unknown error')}`, + } + } finally { + const sessionsToStop = new Set() + if (profileSessionId) sessionsToStop.add(profileSessionId) + if (!taskEnded && taskSessionId) sessionsToStop.add(taskSessionId) + for (const sessionId of sessionsToStop) { + await stopSession(sessionId, params.apiKey) + } + } +} diff --git a/apps/sim/lib/internal/cbinsights/execute-tool.ts b/apps/sim/lib/internal/cbinsights/execute-tool.ts new file mode 100644 index 00000000000..7ef6efb7953 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/execute-tool.ts @@ -0,0 +1,131 @@ +import { + executeCbinsightsChatOperation, + executeCbinsightsGetCommercialMaturityHistoryOperation, + executeCbinsightsGetExitProbabilityHistoryOperation, + executeCbinsightsGetMosaicHistoryOperation, + executeCbinsightsGetOrgBusinessRelationshipsOperation, + executeCbinsightsGetOrgFundingsOperation, + executeCbinsightsGetOrgFundingWindowOperation, + executeCbinsightsGetOrgInvestmentsOperation, + executeCbinsightsGetOrgManagementAndBoardOperation, + executeCbinsightsGetOrgOutlookOperation, + executeCbinsightsGetOrgPortfolioExitsOperation, + executeCbinsightsGetOrgRevenueOperation, + executeCbinsightsGetScoutingReportOperation, + executeCbinsightsGetStrategyMapOperation, + executeCbinsightsListBusinessRelationshipsOperation, + executeCbinsightsListFundingsOperation, + executeCbinsightsListFundingWindowOperation, + executeCbinsightsListInvestmentsOperation, + executeCbinsightsListManagementAndBoardOperation, + executeCbinsightsListOutlookOperation, + executeCbinsightsListPortfolioExitsOperation, + executeCbinsightsListRevenueOperation, + executeCbinsightsLookupOrganizationsOperation, + executeCbinsightsRagOperation, + executeCbinsightsSearchFirmographicsOperation, +} from '@/lib/internal/cbinsights/operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeCbinsightsTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'cbinsights_chat': + return executeToolOperationImplementation(executeCbinsightsChatOperation, request) + case 'cbinsights_get_commercial_maturity_history': + return executeToolOperationImplementation( + executeCbinsightsGetCommercialMaturityHistoryOperation, + request + ) + case 'cbinsights_get_exit_probability_history': + return executeToolOperationImplementation( + executeCbinsightsGetExitProbabilityHistoryOperation, + request + ) + case 'cbinsights_get_mosaic_history': + return executeToolOperationImplementation(executeCbinsightsGetMosaicHistoryOperation, request) + case 'cbinsights_get_org_business_relationships': + return executeToolOperationImplementation( + executeCbinsightsGetOrgBusinessRelationshipsOperation, + request + ) + case 'cbinsights_get_org_funding_window': + return executeToolOperationImplementation( + executeCbinsightsGetOrgFundingWindowOperation, + request + ) + case 'cbinsights_get_org_fundings': + return executeToolOperationImplementation(executeCbinsightsGetOrgFundingsOperation, request) + case 'cbinsights_get_org_investments': + return executeToolOperationImplementation( + executeCbinsightsGetOrgInvestmentsOperation, + request + ) + case 'cbinsights_get_org_management_and_board': + return executeToolOperationImplementation( + executeCbinsightsGetOrgManagementAndBoardOperation, + request + ) + case 'cbinsights_get_org_outlook': + return executeToolOperationImplementation(executeCbinsightsGetOrgOutlookOperation, request) + case 'cbinsights_get_org_portfolio_exits': + return executeToolOperationImplementation( + executeCbinsightsGetOrgPortfolioExitsOperation, + request + ) + case 'cbinsights_get_org_revenue': + return executeToolOperationImplementation(executeCbinsightsGetOrgRevenueOperation, request) + case 'cbinsights_get_scouting_report': + return executeToolOperationImplementation( + executeCbinsightsGetScoutingReportOperation, + request + ) + case 'cbinsights_get_strategy_map': + return executeToolOperationImplementation(executeCbinsightsGetStrategyMapOperation, request) + case 'cbinsights_list_business_relationships': + return executeToolOperationImplementation( + executeCbinsightsListBusinessRelationshipsOperation, + request + ) + case 'cbinsights_list_funding_window': + return executeToolOperationImplementation( + executeCbinsightsListFundingWindowOperation, + request + ) + case 'cbinsights_list_fundings': + return executeToolOperationImplementation(executeCbinsightsListFundingsOperation, request) + case 'cbinsights_list_investments': + return executeToolOperationImplementation(executeCbinsightsListInvestmentsOperation, request) + case 'cbinsights_list_management_and_board': + return executeToolOperationImplementation( + executeCbinsightsListManagementAndBoardOperation, + request + ) + case 'cbinsights_list_outlook': + return executeToolOperationImplementation(executeCbinsightsListOutlookOperation, request) + case 'cbinsights_list_portfolio_exits': + return executeToolOperationImplementation( + executeCbinsightsListPortfolioExitsOperation, + request + ) + case 'cbinsights_list_revenue': + return executeToolOperationImplementation(executeCbinsightsListRevenueOperation, request) + case 'cbinsights_lookup_organizations': + return executeToolOperationImplementation( + executeCbinsightsLookupOrganizationsOperation, + request + ) + case 'cbinsights_rag': + return executeToolOperationImplementation(executeCbinsightsRagOperation, request) + case 'cbinsights_search_firmographics': + return executeToolOperationImplementation( + executeCbinsightsSearchFirmographicsOperation, + request + ) + default: + return Response.json( + { success: false, error: `Unsupported cbinsights tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/cbinsights/operations/chat.ts b/apps/sim/lib/internal/cbinsights/operations/chat.ts new file mode 100644 index 00000000000..f05b575b8fd --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/chat.ts @@ -0,0 +1,40 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsChatParams } from '@/tools/cbinsights/chat' +import { + asArray, + asString, + asStringArray, + cbInsightsRequest, + compactBody, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsChatOperation: InternalToolOperationImplementation< + CbInsightsChatParams +> = async (params, signal) => { + const message = params.message?.trim() + if (!message) throw new Error('CB Insights "message" is required') + + return cbInsightsRequest<{ + chatID?: unknown + title?: unknown + message?: unknown + sources?: unknown + relatedContent?: unknown + suggestions?: unknown + }>( + params, + { + path: '/v2/chatcbi', + body: compactBody({ message, chatID: params.chatId?.trim() }), + }, + (data) => ({ + chatId: asString(data.chatID), + title: asString(data.title), + message: asString(data.message), + sources: asArray(data.sources), + relatedContent: asArray(data.relatedContent), + suggestions: asStringArray(data.suggestions), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-commercial-maturity-history.ts b/apps/sim/lib/internal/cbinsights/operations/get-commercial-maturity-history.ts new file mode 100644 index 00000000000..53dc76ab188 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-commercial-maturity-history.ts @@ -0,0 +1,27 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsCommercialMaturityHistoryParams } from '@/tools/cbinsights/get_commercial_maturity_history' +import { + asArray, + cbInsightsRequest, + compactBody, + parseOptionalStringParam, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetCommercialMaturityHistoryOperation: InternalToolOperationImplementation< + CbInsightsCommercialMaturityHistoryParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ commercialMaturityHistory?: unknown }>( + params, + { + path: `/v2/organizations/${orgId}/commercialmaturityhistory`, + body: compactBody({ + startDate: parseOptionalStringParam(params.startDate, 'startDate'), + endDate: parseOptionalStringParam(params.endDate, 'endDate'), + }), + }, + (data) => ({ commercialMaturityHistory: asArray(data.commercialMaturityHistory) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts b/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts new file mode 100644 index 00000000000..b98fd85fd09 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts @@ -0,0 +1,31 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsExitProbabilityHistoryParams } from '@/tools/cbinsights/get_exit_probability_history' +import { + asArray, + asString, + cbInsightsRequest, + compactBody, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetExitProbabilityHistoryOperation: InternalToolOperationImplementation< + CbInsightsExitProbabilityHistoryParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ ipo?: unknown; mna?: unknown; incompleteRoundType?: unknown }>( + params, + { + path: `/v2/organizations/${orgId}/exitprobabilityhistory`, + body: compactBody({ + startDate: params.startDate?.trim(), + endDate: params.endDate?.trim(), + }), + }, + (data) => ({ + ipo: asArray(data.ipo), + mna: asArray(data.mna), + incompleteRoundType: asString(data.incompleteRoundType), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-mosaic-history.ts b/apps/sim/lib/internal/cbinsights/operations/get-mosaic-history.ts new file mode 100644 index 00000000000..7ae034284cf --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-mosaic-history.ts @@ -0,0 +1,30 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsMosaicHistoryParams } from '@/tools/cbinsights/get_mosaic_history' +import { asArray, cbInsightsRequest, compactBody, requireOrgId } from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetMosaicHistoryOperation: InternalToolOperationImplementation< + CbInsightsMosaicHistoryParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + overall?: unknown + management?: unknown + market?: unknown + momentum?: unknown + money?: unknown + }>( + params, + { + path: `/v2/organizations/${orgId}/mosaichistory`, + body: compactBody({ startDate: params.startDate?.trim() }), + }, + (data) => ({ + overall: asArray(data.overall), + management: asArray(data.management), + market: asArray(data.market), + momentum: asArray(data.momentum), + money: asArray(data.money), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-business-relationships.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-business-relationships.ts new file mode 100644 index 00000000000..92aa2635921 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-business-relationships.ts @@ -0,0 +1,15 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgBusinessRelationshipsOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ businessRelationships?: unknown }>( + params, + { path: `/v2/organizations/${orgId}/businessrelationships` }, + (data) => ({ businessRelationships: asArray(data.businessRelationships) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts new file mode 100644 index 00000000000..138d7f863e6 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts @@ -0,0 +1,33 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { + asNumber, + asRecord, + asString, + cbInsightsRequest, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + windowStart?: unknown + windowEnd?: unknown + cohortNextRoundRate?: unknown + cohortCriteria?: unknown + latestFunding?: unknown + }>( + params, + { path: `/v2/organizations/${orgId}/fundingwindow` }, + (data) => ({ + windowStart: asString(data.windowStart), + windowEnd: asString(data.windowEnd), + cohortNextRoundRate: asNumber(data.cohortNextRoundRate), + cohortCriteria: asRecord(data.cohortCriteria), + latestFunding: asRecord(data.latestFunding), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-fundings.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-fundings.ts new file mode 100644 index 00000000000..f0a97d42c3c --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-fundings.ts @@ -0,0 +1,38 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgFundingsParams } from '@/tools/cbinsights/get_org_fundings' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgFundingsOperation: InternalToolOperationImplementation< + CbInsightsOrgFundingsParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + fundings?: unknown + capTableHistory?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: `/v2/organizations/${orgId}/financialtransactions/fundings`, + body: compactBody({ + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ + fundings: asArray(data.fundings), + capTableHistory: asArray(data.capTableHistory), + ...pageInfo(data), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-investments.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-investments.ts new file mode 100644 index 00000000000..a2aea867ff2 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-investments.ts @@ -0,0 +1,33 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgInvestmentsParams } from '@/tools/cbinsights/get_org_investments' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgInvestmentsOperation: InternalToolOperationImplementation< + CbInsightsOrgInvestmentsParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + investments?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: `/v2/organizations/${orgId}/financialtransactions/investments`, + body: compactBody({ + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ investments: asArray(data.investments), ...pageInfo(data) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts new file mode 100644 index 00000000000..cfb7dc0dd54 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts @@ -0,0 +1,28 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgManagementParams } from '@/tools/cbinsights/get_org_management_and_board' +import { + asArray, + asNumber, + cbInsightsRequest, + compactBody, + parseIdListParam, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgManagementAndBoardOperation: InternalToolOperationImplementation< + CbInsightsOrgManagementParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ people?: unknown; mosaicManagement?: unknown }>( + params, + { + path: `/v2/organizations/${orgId}/managementandboard`, + body: compactBody({ titleIds: parseIdListParam(params.titleIds, 'titleIds') }), + }, + (data) => ({ + people: asArray(data.people), + mosaicManagement: asNumber(data.mosaicManagement), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-outlook.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-outlook.ts new file mode 100644 index 00000000000..2d3d8a54e49 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-outlook.ts @@ -0,0 +1,23 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { asRecord, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgOutlookOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + mosaicScore?: unknown + commercialMaturity?: unknown + exitProbability?: unknown + }>( + params, + { path: `/v2/organizations/${orgId}/outlook` }, + (data) => ({ + mosaicScore: asRecord(data.mosaicScore), + commercialMaturity: asRecord(data.commercialMaturity), + exitProbability: asRecord(data.exitProbability), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-portfolio-exits.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-portfolio-exits.ts new file mode 100644 index 00000000000..0fdee1630a4 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-portfolio-exits.ts @@ -0,0 +1,15 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgPortfolioExitsOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ portfolioExits?: unknown }>( + params, + { path: `/v2/organizations/${orgId}/financialtransactions/portfolioexits` }, + (data) => ({ portfolioExits: asArray(data.portfolioExits) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts new file mode 100644 index 00000000000..e7760d221ef --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts @@ -0,0 +1,31 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { + asArray, + asNumber, + asString, + cbInsightsRequest, + requireOrgId, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetOrgRevenueOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + orgId?: unknown + orgName?: unknown + orgUrl?: unknown + revenue?: unknown + }>( + params, + { path: `/v2/organizations/${orgId}/revenuebyyear` }, + (data) => ({ + orgId: asNumber(data.orgId), + orgName: asString(data.orgName), + orgUrl: asString(data.orgUrl), + revenue: asArray(data.revenue), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts b/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts new file mode 100644 index 00000000000..6c2b40dcf6b --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts @@ -0,0 +1,32 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { + asRecord, + asString, + cbInsightsRequest, + requireOrgId, + SCOUTING_REPORT_TIMEOUT_MS, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetScoutingReportOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ + orgInfo?: unknown + reportMarkdown?: unknown + reportJson?: unknown + }>( + params, + { + path: `/v2/organizations/${orgId}/scoutingreport`, + timeoutMs: SCOUTING_REPORT_TIMEOUT_MS, + }, + (data) => ({ + orgInfo: asRecord(data.orgInfo), + reportMarkdown: asString(data.reportMarkdown), + reportJson: asString(data.reportJson), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts b/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts new file mode 100644 index 00000000000..474b859573a --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts @@ -0,0 +1,19 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' +import { asArray, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' + +export const executeCbinsightsGetStrategyMapOperation: InternalToolOperationImplementation< + CbInsightsOrgParams +> = async (params, signal) => { + const orgId = requireOrgId(params.orgId) + return cbInsightsRequest<{ orgName?: unknown; logoUrl?: unknown; categories?: unknown }>( + params, + { path: `/v2/organizations/${orgId}/strategymap` }, + (data) => ({ + orgName: asString(data.orgName), + logoUrl: asString(data.logoUrl), + categories: asArray(data.categories), + }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/index.ts b/apps/sim/lib/internal/cbinsights/operations/index.ts new file mode 100644 index 00000000000..9ac70d4aa68 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/index.ts @@ -0,0 +1,25 @@ +export { executeCbinsightsChatOperation } from '@/lib/internal/cbinsights/operations/chat' +export { executeCbinsightsGetCommercialMaturityHistoryOperation } from '@/lib/internal/cbinsights/operations/get-commercial-maturity-history' +export { executeCbinsightsGetExitProbabilityHistoryOperation } from '@/lib/internal/cbinsights/operations/get-exit-probability-history' +export { executeCbinsightsGetMosaicHistoryOperation } from '@/lib/internal/cbinsights/operations/get-mosaic-history' +export { executeCbinsightsGetOrgBusinessRelationshipsOperation } from '@/lib/internal/cbinsights/operations/get-org-business-relationships' +export { executeCbinsightsGetOrgFundingWindowOperation } from '@/lib/internal/cbinsights/operations/get-org-funding-window' +export { executeCbinsightsGetOrgFundingsOperation } from '@/lib/internal/cbinsights/operations/get-org-fundings' +export { executeCbinsightsGetOrgInvestmentsOperation } from '@/lib/internal/cbinsights/operations/get-org-investments' +export { executeCbinsightsGetOrgManagementAndBoardOperation } from '@/lib/internal/cbinsights/operations/get-org-management-and-board' +export { executeCbinsightsGetOrgOutlookOperation } from '@/lib/internal/cbinsights/operations/get-org-outlook' +export { executeCbinsightsGetOrgPortfolioExitsOperation } from '@/lib/internal/cbinsights/operations/get-org-portfolio-exits' +export { executeCbinsightsGetOrgRevenueOperation } from '@/lib/internal/cbinsights/operations/get-org-revenue' +export { executeCbinsightsGetScoutingReportOperation } from '@/lib/internal/cbinsights/operations/get-scouting-report' +export { executeCbinsightsGetStrategyMapOperation } from '@/lib/internal/cbinsights/operations/get-strategy-map' +export { executeCbinsightsListBusinessRelationshipsOperation } from '@/lib/internal/cbinsights/operations/list-business-relationships' +export { executeCbinsightsListFundingWindowOperation } from '@/lib/internal/cbinsights/operations/list-funding-window' +export { executeCbinsightsListFundingsOperation } from '@/lib/internal/cbinsights/operations/list-fundings' +export { executeCbinsightsListInvestmentsOperation } from '@/lib/internal/cbinsights/operations/list-investments' +export { executeCbinsightsListManagementAndBoardOperation } from '@/lib/internal/cbinsights/operations/list-management-and-board' +export { executeCbinsightsListOutlookOperation } from '@/lib/internal/cbinsights/operations/list-outlook' +export { executeCbinsightsListPortfolioExitsOperation } from '@/lib/internal/cbinsights/operations/list-portfolio-exits' +export { executeCbinsightsListRevenueOperation } from '@/lib/internal/cbinsights/operations/list-revenue' +export { executeCbinsightsLookupOrganizationsOperation } from '@/lib/internal/cbinsights/operations/lookup-organizations' +export { executeCbinsightsRagOperation } from '@/lib/internal/cbinsights/operations/rag' +export { executeCbinsightsSearchFirmographicsOperation } from '@/lib/internal/cbinsights/operations/search-firmographics' diff --git a/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts b/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts new file mode 100644 index 00000000000..c779ac6d697 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts @@ -0,0 +1,25 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListBusinessRelationshipsParams } from '@/tools/cbinsights/list_business_relationships' +import { + asArray, + asString, + cbInsightsRequest, + compactBody, + requireOrgIds, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsListBusinessRelationshipsOperation: InternalToolOperationImplementation< + CbInsightsListBusinessRelationshipsParams +> = async (params, signal) => + cbInsightsRequest<{ orgs?: unknown; nextPageToken?: unknown }>( + params, + { + path: '/v2/businessrelationships', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ orgs: asArray(data.orgs), nextPageToken: asString(data.nextPageToken) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-funding-window.ts b/apps/sim/lib/internal/cbinsights/operations/list-funding-window.ts new file mode 100644 index 00000000000..159220a1ff3 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-funding-window.ts @@ -0,0 +1,18 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListFundingWindowParams } from '@/tools/cbinsights/list_funding_window' +import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' + +export const executeCbinsightsListFundingWindowOperation: InternalToolOperationImplementation< + CbInsightsListFundingWindowParams +> = async (params, signal) => + cbInsightsRequest<{ orgs?: unknown }>( + params, + { + path: '/v2/outlook/fundingwindow', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + }), + }, + (data) => ({ orgs: asArray(data.orgs) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-fundings.ts b/apps/sim/lib/internal/cbinsights/operations/list-fundings.ts new file mode 100644 index 00000000000..4e4bde42bb9 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-fundings.ts @@ -0,0 +1,32 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListFundingsParams } from '@/tools/cbinsights/list_fundings' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + requireOrgIds, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsListFundingsOperation: InternalToolOperationImplementation< + CbInsightsListFundingsParams +> = async (params, signal) => + cbInsightsRequest<{ + orgs?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: '/v2/financialtransactions/fundings', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-investments.ts b/apps/sim/lib/internal/cbinsights/operations/list-investments.ts new file mode 100644 index 00000000000..254a8971c40 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-investments.ts @@ -0,0 +1,32 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListInvestmentsParams } from '@/tools/cbinsights/list_investments' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + requireOrgIds, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsListInvestmentsOperation: InternalToolOperationImplementation< + CbInsightsListInvestmentsParams +> = async (params, signal) => + cbInsightsRequest<{ + orgs?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: '/v2/financialtransactions/investments', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-management-and-board.ts b/apps/sim/lib/internal/cbinsights/operations/list-management-and-board.ts new file mode 100644 index 00000000000..b27ab5138cd --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-management-and-board.ts @@ -0,0 +1,25 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListManagementAndBoardParams } from '@/tools/cbinsights/list_management_and_board' +import { + asArray, + cbInsightsRequest, + compactBody, + parseIdListParam, + requireOrgIds, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsListManagementAndBoardOperation: InternalToolOperationImplementation< + CbInsightsListManagementAndBoardParams +> = async (params, signal) => + cbInsightsRequest<{ orgs?: unknown }>( + params, + { + path: '/v2/managementandboard', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + titleIds: parseIdListParam(params.titleIds, 'titleIds'), + }), + }, + (data) => ({ orgs: asArray(data.orgs) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-outlook.ts b/apps/sim/lib/internal/cbinsights/operations/list-outlook.ts new file mode 100644 index 00000000000..548a0908495 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-outlook.ts @@ -0,0 +1,18 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListOutlookParams } from '@/tools/cbinsights/list_outlook' +import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' + +export const executeCbinsightsListOutlookOperation: InternalToolOperationImplementation< + CbInsightsListOutlookParams +> = async (params, signal) => + cbInsightsRequest<{ orgs?: unknown }>( + params, + { + path: '/v2/outlook', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + }), + }, + (data) => ({ orgs: asArray(data.orgs) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-portfolio-exits.ts b/apps/sim/lib/internal/cbinsights/operations/list-portfolio-exits.ts new file mode 100644 index 00000000000..07416ca6054 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-portfolio-exits.ts @@ -0,0 +1,32 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListPortfolioExitsParams } from '@/tools/cbinsights/list_portfolio_exits' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + requireOrgIds, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsListPortfolioExitsOperation: InternalToolOperationImplementation< + CbInsightsListPortfolioExitsParams +> = async (params, signal) => + cbInsightsRequest<{ + orgs?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: '/v2/financialtransactions/portfolioexits', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/list-revenue.ts b/apps/sim/lib/internal/cbinsights/operations/list-revenue.ts new file mode 100644 index 00000000000..86a9f4e0630 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/list-revenue.ts @@ -0,0 +1,18 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsListRevenueParams } from '@/tools/cbinsights/list_revenue' +import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' + +export const executeCbinsightsListRevenueOperation: InternalToolOperationImplementation< + CbInsightsListRevenueParams +> = async (params, signal) => + cbInsightsRequest<{ orgs?: unknown }>( + params, + { + path: '/v2/revenuebyyear', + body: compactBody({ + orgIds: requireOrgIds(params.orgIds), + }), + }, + (data) => ({ orgs: asArray(data.orgs) }), + signal + ) diff --git a/apps/sim/lib/internal/cbinsights/operations/lookup-organizations.ts b/apps/sim/lib/internal/cbinsights/operations/lookup-organizations.ts new file mode 100644 index 00000000000..1eef6801bc3 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/lookup-organizations.ts @@ -0,0 +1,48 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsLookupOrganizationsParams } from '@/tools/cbinsights/lookup_organizations' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + parseStringListParam, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsLookupOrganizationsOperation: InternalToolOperationImplementation< + CbInsightsLookupOrganizationsParams +> = async (params, signal) => { + const names = parseStringListParam(params.names, 'names') + const urls = parseStringListParam(params.urls, 'urls') + const profileUrl = params.profileUrl?.trim() + + if (!names && !urls && !profileUrl) { + throw new Error('CB Insights lookup requires at least one of "names", "urls", or "profileUrl"') + } + if (profileUrl && (names || urls)) { + throw new Error( + 'CB Insights rejects "profileUrl" combined with "names" or "urls" — pass only one' + ) + } + + return cbInsightsRequest<{ + orgs?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { + path: '/v2/organizations', + body: compactBody({ + names, + urls, + profileUrl, + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + }, + (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/rag.ts b/apps/sim/lib/internal/cbinsights/operations/rag.ts new file mode 100644 index 00000000000..4ee1bceb9fd --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/rag.ts @@ -0,0 +1,20 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsRagParams } from '@/tools/cbinsights/rag' +import { asString, asStringArray, cbInsightsRequest } from '@/tools/cbinsights/utils' + +export const executeCbinsightsRagOperation: InternalToolOperationImplementation< + CbInsightsRagParams +> = async (params, signal) => { + const message = params.message?.trim() + if (!message) throw new Error('CB Insights "message" is required') + if (message.length > 10_000) { + throw new Error('CB Insights "message" must be under 10,000 characters') + } + + return cbInsightsRequest<{ data?: unknown; guidance?: unknown }>( + params, + { path: '/v2/cbirag', body: { message } }, + (data) => ({ data: asString(data.data), guidance: asStringArray(data.guidance) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cbinsights/operations/search-firmographics.ts b/apps/sim/lib/internal/cbinsights/operations/search-firmographics.ts new file mode 100644 index 00000000000..3010d349517 --- /dev/null +++ b/apps/sim/lib/internal/cbinsights/operations/search-firmographics.ts @@ -0,0 +1,111 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { CbInsightsFirmographicsParams } from '@/tools/cbinsights/search_firmographics' +import { sortDirection } from '@/tools/cbinsights/search_firmographics' +import { + asArray, + cbInsightsRequest, + clampLimit, + compactBody, + pageInfo, + parseBooleanParam, + parseIdListParam, + parseIntegerParam, + parseNumberParam, + parseOptionalOrgIds, + parseStringListParam, +} from '@/tools/cbinsights/utils' + +export const executeCbinsightsSearchFirmographicsOperation: InternalToolOperationImplementation< + CbInsightsFirmographicsParams +> = async (params, signal) => { + const filters = compactBody({ + keyword: params.keyword?.trim(), + orgIds: parseOptionalOrgIds(params.orgIds), + orgNames: parseStringListParam(params.orgNames, 'orgNames'), + urls: parseStringListParam(params.urls, 'urls'), + tickers: parseStringListParam(params.tickers, 'tickers'), + marketIds: parseIdListParam(params.marketIds, 'marketIds'), + marketNames: parseStringListParam(params.marketNames, 'marketNames'), + industryIds: parseIdListParam(params.industryIds, 'industryIds'), + sectorIds: parseIdListParam(params.sectorIds, 'sectorIds'), + subindustryIds: parseIdListParam(params.subindustryIds, 'subindustryIds'), + businessModelIds: parseIdListParam(params.businessModelIds, 'businessModelIds'), + technologyIds: parseIdListParam(params.technologyIds, 'technologyIds'), + collectionIds: parseIdListParam(params.collectionIds, 'collectionIds'), + countryIds: parseIdListParam(params.countryIds, 'countryIds'), + stateProvinceIds: parseIdListParam(params.stateProvinceIds, 'stateProvinceIds'), + cityIds: parseIdListParam(params.cityIds, 'cityIds'), + continentIds: parseIdListParam(params.continentIds, 'continentIds'), + regionIds: parseIdListParam(params.regionIds, 'regionIds'), + orgStatusIds: parseIdListParam(params.orgStatusIds, 'orgStatusIds'), + investorOrgIds: parseIdListParam(params.investorOrgIds, 'investorOrgIds'), + investorTypeIds: parseIdListParam(params.investorTypeIds, 'investorTypeIds'), + fundingInvestorTypeIds: parseIdListParam( + params.fundingInvestorTypeIds, + 'fundingInvestorTypeIds' + ), + lastFundingRoundIds: parseIdListParam(params.lastFundingRoundIds, 'lastFundingRoundIds'), + lastFundingRoundCategoryIds: parseIdListParam( + params.lastFundingRoundCategoryIds, + 'lastFundingRoundCategoryIds' + ), + minCurrentHeadcount: parseIntegerParam(params.minCurrentHeadcount, 'minCurrentHeadcount'), + maxCurrentHeadcount: parseIntegerParam(params.maxCurrentHeadcount, 'maxCurrentHeadcount'), + minTotalFundingInMillions: parseNumberParam( + params.minTotalFundingInMillions, + 'minTotalFundingInMillions' + ), + maxTotalFundingInMillions: parseNumberParam( + params.maxTotalFundingInMillions, + 'maxTotalFundingInMillions' + ), + minValuationInMillions: parseNumberParam( + params.minValuationInMillions, + 'minValuationInMillions' + ), + maxValuationInMillions: parseNumberParam( + params.maxValuationInMillions, + 'maxValuationInMillions' + ), + minLastFundingDate: params.minLastFundingDate?.trim(), + maxLastFundingDate: params.maxLastFundingDate?.trim(), + vcBacked: parseBooleanParam(params.vcBacked, 'vcBacked'), + }) + + /* + * The guard has to measure the *filters* alone. Folding limit, the page + * token, or the sort into the same object would let a request carrying only + * paging past it — which is an unfiltered search over the whole database, + * and it still spends credits. + */ + if (Object.keys(filters).length === 0) { + throw new Error('CB Insights firmographics search requires at least one search parameter') + } + + const body: Record = { + ...filters, + ...compactBody({ + limit: clampLimit(params.limit), + nextPageToken: params.nextPageToken?.trim(), + }), + } + + /* The API takes one sort object; the block exposes it as two plain fields + so neither has to be typed as JSON. */ + const sortField = params.sortField?.trim() + if (sortField) { + body.sort = { field: sortField, direction: sortDirection(params.sortDirection) } + } + + return cbInsightsRequest<{ + orgs?: unknown + nextPageToken?: unknown + totalHits?: unknown + totalHitsRelation?: unknown + }>( + params, + { path: '/v2/firmographics', body }, + (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), + signal + ) +} diff --git a/apps/sim/lib/internal/cloudflare/execute-tool.ts b/apps/sim/lib/internal/cloudflare/execute-tool.ts new file mode 100644 index 00000000000..a1b5e317aff --- /dev/null +++ b/apps/sim/lib/internal/cloudflare/execute-tool.ts @@ -0,0 +1,15 @@ +import { executeGetZoneSettingsOperation } from '@/lib/internal/cloudflare/operations/get-zone-settings' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeCloudflareTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'cloudflare_get_zone_settings': + return executeToolOperationImplementation(executeGetZoneSettingsOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported cloudflare tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/cloudflare/operations/get-zone-settings.ts b/apps/sim/lib/internal/cloudflare/operations/get-zone-settings.ts new file mode 100644 index 00000000000..5f4860882ed --- /dev/null +++ b/apps/sim/lib/internal/cloudflare/operations/get-zone-settings.ts @@ -0,0 +1,71 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { mapZoneSetting, zoneSettingUrl } from '@/tools/cloudflare/get_zone_settings' +import type { + CloudflareEnvelope, + CloudflareGetZoneSettingsParams, + CloudflareRawZoneSetting, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + MAX_ZONE_SETTING_IDS, + requestedZoneSettingIds, +} from '@/tools/cloudflare/utils' + +export const executeGetZoneSettingsOperation: InternalToolOperationImplementation< + CloudflareGetZoneSettingsParams +> = async (params, signal) => { + const settingIds = requestedZoneSettingIds(params.settingIds) + if (settingIds.length > MAX_ZONE_SETTING_IDS) { + return { + success: false, + output: { settings: [], unreadable: [] }, + error: `Too many settings requested: ${settingIds.length}. Cloudflare reads one setting per request, so at most ${MAX_ZONE_SETTING_IDS} can be read in a single call.`, + } + } + + const zoneId = params.zoneId.trim() + const headers = cloudflareHeaders(params.apiKey) + + const reads = await Promise.all( + settingIds.map(async (settingId) => { + try { + const response = await fetch(zoneSettingUrl(zoneId, settingId), { + method: 'GET', + headers, + signal, + }) + const data = (await response.json()) as CloudflareEnvelope + if (!data.success) { + return { + settingId, + error: cloudflareErrorMessage(data, `Failed to read zone setting ${settingId}`), + } + } + return { settingId, setting: mapZoneSetting(settingId, data.result) } + } catch (error) { + signal?.throwIfAborted() + return { + settingId, + error: getErrorMessage(error, `Failed to read zone setting ${settingId}`), + } + } + }) + ) + + const settings = reads.flatMap((read) => (read.setting ? [read.setting] : [])) + const unreadable = reads.flatMap((read) => + read.error ? [{ id: read.settingId, error: read.error }] : [] + ) + + if (settings.length === 0) { + return { + success: false, + output: { settings, unreadable }, + error: unreadable[0]?.error ?? 'Failed to get zone settings', + } + } + + return { success: true, output: { settings, unreadable } } +} diff --git a/apps/sim/lib/internal/datadog/execute-tool.ts b/apps/sim/lib/internal/datadog/execute-tool.ts new file mode 100644 index 00000000000..217e7979165 --- /dev/null +++ b/apps/sim/lib/internal/datadog/execute-tool.ts @@ -0,0 +1,15 @@ +import { executeUpdateSloOperation } from '@/lib/internal/datadog/operations/update-slo' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeDatadogTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'datadog_update_slo': + return executeToolOperationImplementation(executeUpdateSloOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported datadog tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/datadog/operations/update-slo.ts b/apps/sim/lib/internal/datadog/operations/update-slo.ts new file mode 100644 index 00000000000..074fb4c3f5e --- /dev/null +++ b/apps/sim/lib/internal/datadog/operations/update-slo.ts @@ -0,0 +1,57 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { UpdateSloParams } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + datadogPathSegment, + mergeSloUpdatePayload, +} from '@/tools/datadog/utils' + +export const executeUpdateSloOperation: InternalToolOperationImplementation< + UpdateSloParams +> = async (params, signal) => { + const url = datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`) + const headers = datadogHeaders(params) + + const existingResponse = await fetch(url, { method: 'GET', headers, signal }) + if (!existingResponse.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: `Could not load SLO ${params.sloId} before updating it: ${await datadogErrorMessage(existingResponse)}`, + } + } + + const existing = await existingResponse.json() + const stored = existing.data + if (!stored || typeof stored !== 'object') { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: `Datadog returned no SLO for id ${params.sloId}`, + } + } + + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(mergeSloUpdatePayload(stored, params)), + signal, + }) + + if (!response.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { slo: data.data?.[0] ?? { id: '', name: '', type: '' } }, + } +} diff --git a/apps/sim/lib/internal/github/execute-tool.test.ts b/apps/sim/lib/internal/github/execute-tool.test.ts index 9d27a42ef9c..39c49a00db0 100644 --- a/apps/sim/lib/internal/github/execute-tool.test.ts +++ b/apps/sim/lib/internal/github/execute-tool.test.ts @@ -4,9 +4,15 @@ import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ getGitHubLatestCommit: vi.fn() })) +const mocks = vi.hoisted(() => ({ + executeGitHubCommentOperation: vi.fn(), + executeGitHubCommentV2Operation: vi.fn(), + getGitHubLatestCommit: vi.fn(), +})) vi.mock('@/lib/internal/github/operations', () => ({ + executeGitHubCommentOperation: mocks.executeGitHubCommentOperation, + executeGitHubCommentV2Operation: mocks.executeGitHubCommentV2Operation, getGitHubLatestCommit: mocks.getGitHubLatestCommit, })) @@ -16,9 +22,36 @@ import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/t describe('executeGitHubTool', () => { beforeEach(() => { vi.clearAllMocks() + mocks.executeGitHubCommentOperation.mockResolvedValue({ success: true, output: {} }) + mocks.executeGitHubCommentV2Operation.mockResolvedValue({ success: true, output: {} }) mocks.getGitHubLatestCommit.mockResolvedValue({ success: true, output: {} }) }) + it.each([ + ['github_comment', mocks.executeGitHubCommentOperation], + ['github_comment_v2', mocks.executeGitHubCommentV2Operation], + ])('dispatches %s to its typed operation', async (toolId, operation) => { + const controller = new AbortController() + const input = { + owner: 'simstudioai', + repo: 'sim', + pullNumber: 7, + body: 'Looks good', + apiKey: 'token', + } + const request: InternalToolOperationCall = { + toolId, + input, + headers: new Headers(), + context: createExecutionContext(), + requestId: 'request-1', + signal: controller.signal, + } + + expect((await executeGitHubTool(request)).status).toBe(200) + expect(operation).toHaveBeenCalledWith(input, controller.signal, request.context) + }) + it.each(['github_latest_commit', 'github_latest_commit_v2'])( 'dispatches %s to the same typed operation', async (toolId) => { diff --git a/apps/sim/lib/internal/github/execute-tool.ts b/apps/sim/lib/internal/github/execute-tool.ts index 0b185b8679b..5043273f8a4 100644 --- a/apps/sim/lib/internal/github/execute-tool.ts +++ b/apps/sim/lib/internal/github/execute-tool.ts @@ -2,11 +2,14 @@ import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { GitHubOperationError } from '@/lib/internal/github/errors' -import { getGitHubLatestCommit } from '@/lib/internal/github/operations' +import { + executeGitHubCommentOperation, + executeGitHubCommentV2Operation, + getGitHubLatestCommit, +} from '@/lib/internal/github/operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -const TOOL_IDS = new Set(['github_latest_commit', 'github_latest_commit_v2']) - const inputSchema = z.object({ owner: z.string().min(1, 'Owner is required'), repo: z.string().min(1, 'Repo is required'), @@ -16,23 +19,31 @@ const inputSchema = z.object({ export const executeGitHubTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() - if (!TOOL_IDS.has(request.toolId)) { - return Response.json( - { success: false, error: `Unsupported GitHub tool: ${request.toolId}` }, - { status: 500 } - ) - } - const parsed = inputSchema.safeParse(request.input) - if (!parsed.success) { - return Response.json({ success: false, error: 'Invalid request data' }, { status: 400 }) - } try { - return Response.json( - await getGitHubLatestCommit(parsed.data, { - requestId: request.requestId, - signal: request.signal, - }) - ) + switch (request.toolId) { + case 'github_comment': + return executeToolOperationImplementation(executeGitHubCommentOperation, request) + case 'github_comment_v2': + return executeToolOperationImplementation(executeGitHubCommentV2Operation, request) + case 'github_latest_commit': + case 'github_latest_commit_v2': { + const parsed = inputSchema.safeParse(request.input) + if (!parsed.success) { + return Response.json({ success: false, error: 'Invalid request data' }, { status: 400 }) + } + return Response.json( + await getGitHubLatestCommit(parsed.data, { + requestId: request.requestId, + signal: request.signal, + }) + ) + } + default: + return Response.json( + { success: false, error: `Unsupported GitHub tool: ${request.toolId}` }, + { status: 500 } + ) + } } catch (error) { request.signal?.throwIfAborted() const status = isPayloadSizeLimitError(error) diff --git a/apps/sim/lib/internal/github/operations.ts b/apps/sim/lib/internal/github/operations.ts index 57c7501c10a..49522552564 100644 --- a/apps/sim/lib/internal/github/operations.ts +++ b/apps/sim/lib/internal/github/operations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -10,10 +11,47 @@ import { } from '@/lib/core/utils/stream-limits' import { GitHubOperationError } from '@/lib/internal/github/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import type { LatestCommitParams, LatestCommitResponse } from '@/tools/github/types' +import { formatGitHubErrorMessage } from '@/tools/github/response-parsers' +import type { + CreateCommentParams, + LatestCommitParams, + LatestCommitResponse, +} from '@/tools/github/types' +import { secureGitHubRequest } from '@/tools/github/utils.server' +import type { ToolResponse } from '@/tools/types' const logger = createLogger('GitHubLatestCommitOperation') const MAX_COMMIT_RESPONSE_BYTES = 10 * 1024 * 1024 +const GITHUB_API_BASE = 'https://api.github.com' + +interface ReviewCommentBody { + body: string + event: 'COMMENT' +} + +interface FileCommentBodyBase { + body: string + commit_id: string | undefined + path: string | undefined +} + +type FileCommentBody = + | (FileCommentBodyBase & { subject_type: 'file' }) + | (FileCommentBodyBase & { line: number; side: string }) + +interface GitHubCommentPayload { + id?: number + body?: string + html_url?: string + user?: unknown + path?: string + line?: number + position?: number + side?: string + commit_id?: string + created_at?: string + updated_at?: string +} interface GitHubCommitFile { filename: string @@ -51,6 +89,228 @@ export interface GitHubOperationContext { signal?: AbortSignal } +function githubHeaders(apiKey: string): Record { + return { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${apiKey}`, + 'X-GitHub-Api-Version': '2022-11-28', + } +} + +function pullRequestUrl(params: CreateCommentParams): string { + return `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}` +} + +function isFileCommentRequest(params: CreateCommentParams): boolean { + return params.commentType === 'file_comment' && Boolean(params.path) +} + +function needsCommitLookup(params: CreateCommentParams): boolean { + return isFileCommentRequest(params) && !params.commitId +} + +function toLineNumber(value: unknown): number | undefined { + let parsed: number + if (typeof value === 'number') { + parsed = value + } else { + if (value === undefined || value === null) return undefined + if (typeof value !== 'string') { + throw new Error('GitHub line must be a positive integer') + } + if (!value.trim()) return undefined + parsed = Number(value.trim()) + } + if (!Number.isFinite(parsed)) { + throw new Error(`GitHub line must be a valid number, but line was ${String(value)}`) + } + if (!Number.isInteger(parsed)) { + throw new Error( + `GitHub line numbers are whole numbers, but line was ${parsed}. Set line to the integer line number in the diff.` + ) + } + return parsed +} + +function fileCommentBody( + params: CreateCommentParams, + commitId: string | undefined +): FileCommentBody { + const base = { + body: params.body, + commit_id: commitId, + path: params.path, + } + const line = toLineNumber(params.line) + if (line === undefined) return { ...base, subject_type: 'file' } + if (line < 1) throw new Error('GitHub line numbers must be positive integers') + return { ...base, line, side: params.side || 'RIGHT' } +} + +function commentEndpointUrl(params: CreateCommentParams): string { + return isFileCommentRequest(params) + ? `${pullRequestUrl(params)}/comments` + : `${pullRequestUrl(params)}/reviews` +} + +function commentRequestBody( + params: CreateCommentParams, + commitId: string | undefined +): FileCommentBody | ReviewCommentBody { + if (isFileCommentRequest(params)) return fileCommentBody(params, commitId) + return { body: params.body, event: 'COMMENT' } +} + +function readHeadSha(pullRequest: unknown): string | undefined { + if (!isRecordLike(pullRequest) || !isRecordLike(pullRequest.head)) return undefined + const sha = pullRequest.head.sha + return typeof sha === 'string' && sha ? sha : undefined +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +function readNumber(record: Record, key: string): number | undefined { + const value = record[key] + return typeof value === 'number' ? value : undefined +} + +function readCommentPayload(value: unknown): GitHubCommentPayload { + if (!isRecordLike(value)) return {} + const submittedAt = readString(value, 'submitted_at') + return { + id: readNumber(value, 'id'), + body: readString(value, 'body'), + html_url: readString(value, 'html_url'), + user: value.user, + path: readString(value, 'path'), + line: readNumber(value, 'line'), + position: readNumber(value, 'position'), + side: readString(value, 'side'), + commit_id: readString(value, 'commit_id'), + created_at: readString(value, 'created_at') ?? submittedAt, + updated_at: readString(value, 'updated_at') ?? submittedAt, + } +} + +async function assertGitHubResponseOk( + response: Response, + fallback: string, + signal?: AbortSignal +): Promise { + if (response.ok) return + + const text = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'GitHub error response', + signal, + }).catch(() => '') + let data: unknown = text + try { + data = JSON.parse(text) + } catch { + data = text + } + + throw new GitHubOperationError( + formatGitHubErrorMessage(data) ?? `${fallback} (HTTP ${response.status})`, + response.status + ) +} + +async function createComment( + params: CreateCommentParams, + signal?: AbortSignal +): Promise { + const headers = githubHeaders(params.apiKey) + + let commitId = params.commitId + if (needsCommitLookup(params)) { + const pullRequestResponse = await secureGitHubRequest(pullRequestUrl(params), { + headers, + signal, + }) + await assertGitHubResponseOk( + pullRequestResponse, + `Failed to load pull request ${params.owner}/${params.repo}#${params.pullNumber}`, + signal + ) + const pullRequest = await readResponseJsonWithLimit(pullRequestResponse, { + maxBytes: MAX_COMMIT_RESPONSE_BYTES, + label: 'GitHub pull request response', + signal, + }) + commitId = readHeadSha(pullRequest) + if (!commitId) { + throw new Error( + `GitHub returned no head commit SHA for pull request ${params.owner}/${params.repo}#${params.pullNumber}. Set commitId to comment on a specific commit.` + ) + } + } + + const response = await secureGitHubRequest(commentEndpointUrl(params), { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(commentRequestBody(params, commitId)), + signal, + }) + await assertGitHubResponseOk(response, 'Failed to create comment', signal) + return readCommentPayload( + await readResponseJsonWithLimit(response, { + maxBytes: MAX_COMMIT_RESPONSE_BYTES, + label: 'GitHub comment response', + signal, + }) + ) +} + +export async function executeGitHubCommentOperation( + params: CreateCommentParams, + signal?: AbortSignal +): Promise { + const data = await createComment(params, signal) + return { + success: true, + output: { + content: `Comment created: "${data.body}"`, + metadata: { + id: data.id, + html_url: data.html_url, + created_at: data.created_at, + updated_at: data.updated_at, + path: data.path, + line: data.line || data.position, + side: data.side, + commit_id: data.commit_id, + }, + }, + } +} + +export async function executeGitHubCommentV2Operation( + params: CreateCommentParams, + signal?: AbortSignal +): Promise { + const data = await createComment(params, signal) + return { + success: true, + output: { + id: data.id, + body: data.body, + html_url: data.html_url, + user: data.user, + path: data.path ?? null, + line: data.line ?? data.position ?? null, + side: data.side ?? null, + commit_id: data.commit_id ?? null, + created_at: data.created_at, + updated_at: data.updated_at, + }, + } +} + async function fetchChangedFileContent( file: GitHubCommitFile, apiKey: string, diff --git a/apps/sim/lib/internal/google-drive/execute-tool.test.ts b/apps/sim/lib/internal/google-drive/execute-tool.test.ts index b11eb9de86d..2081e5db43f 100644 --- a/apps/sim/lib/internal/google-drive/execute-tool.test.ts +++ b/apps/sim/lib/internal/google-drive/execute-tool.test.ts @@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ download: vi.fn(), exportFile: vi.fn(), + move: vi.fn(), upload: vi.fn(), })) vi.mock('@/lib/internal/google-drive/operations', () => ({ executeGoogleDriveDownload: mocks.download, executeGoogleDriveExport: mocks.exportFile, + executeGoogleDriveMove: mocks.move, executeGoogleDriveUpload: mocks.upload, })) @@ -24,6 +26,11 @@ import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/t const INPUTS = { google_drive_download: { accessToken: 'token', fileId: 'file-1' }, google_drive_export: { accessToken: 'token', fileId: 'file-1', mimeType: 'application/pdf' }, + google_drive_move: { + accessToken: 'token', + fileId: 'file-1', + destinationFolderId: 'folder-1', + }, google_drive_upload: { accessToken: 'token', fileName: 'notes.txt', @@ -34,6 +41,7 @@ const INPUTS = { const OPERATIONS = { google_drive_download: mocks.download, google_drive_export: mocks.exportFile, + google_drive_move: mocks.move, google_drive_upload: mocks.upload, } as const @@ -119,6 +127,24 @@ describe('executeGoogleDriveTool', () => { }) }) + it('maps oversized move responses to 413', async () => { + mocks.move.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'Google Drive move response', + maxBytes: 10, + observedBytes: 11, + }) + ) + + const response = await executeGoogleDriveTool(request('google_drive_move')) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: expect.stringContaining('Google Drive move response'), + }) + }) + it('propagates cancellation before and after operation work', async () => { const before = new AbortController() before.abort(new DOMException('cancelled', 'AbortError')) diff --git a/apps/sim/lib/internal/google-drive/execute-tool.ts b/apps/sim/lib/internal/google-drive/execute-tool.ts index ed267fe5770..d96ac227fdd 100644 --- a/apps/sim/lib/internal/google-drive/execute-tool.ts +++ b/apps/sim/lib/internal/google-drive/execute-tool.ts @@ -10,11 +10,13 @@ import { GoogleDriveOperationError } from '@/lib/internal/google-drive/errors' import { googleDriveDownloadInputSchema, googleDriveExportInputSchema, + googleDriveMoveInputSchema, googleDriveUploadInputSchema, } from '@/lib/internal/google-drive/input' import { executeGoogleDriveDownload, executeGoogleDriveExport, + executeGoogleDriveMove, executeGoogleDriveUpload, type GoogleDriveOperationContext, } from '@/lib/internal/google-drive/operations' @@ -56,6 +58,10 @@ async function dispatch( const input = parseInput(googleDriveExportInputSchema, request.input) return input instanceof Response ? input : executeGoogleDriveExport(input, context) } + case 'google_drive_move': { + const input = parseInput(googleDriveMoveInputSchema, request.input) + return input instanceof Response ? input : executeGoogleDriveMove(input, context) + } case 'google_drive_upload': { const input = parseInput(googleDriveUploadInputSchema, request.input) return input instanceof Response ? input : executeGoogleDriveUpload(input, context) @@ -78,8 +84,9 @@ function unexpectedResponse(request: InternalToolOperationCall, error: unknown): toolId: request.toolId, }) const status = - ['google_drive_download', 'google_drive_export'].includes(request.toolId) && - isPayloadSizeLimitError(error) + ['google_drive_download', 'google_drive_export', 'google_drive_move'].includes( + request.toolId + ) && isPayloadSizeLimitError(error) ? 413 : 500 return Response.json({ success: false, error: message }, { status }) diff --git a/apps/sim/lib/internal/google-drive/input.ts b/apps/sim/lib/internal/google-drive/input.ts index 062f06907ba..5c88edbf5d9 100644 --- a/apps/sim/lib/internal/google-drive/input.ts +++ b/apps/sim/lib/internal/google-drive/input.ts @@ -27,6 +27,14 @@ export const googleDriveExportInputSchema = z.object({ fileName: z.string().optional().nullable(), }) +export const googleDriveMoveInputSchema = z.object({ + accessToken: googleAccessTokenSchema, + fileId: z.string().trim().min(1, 'File ID is required'), + destinationFolderId: z.string().trim().min(1, 'Destination folder ID is required'), + removeFromCurrent: z.boolean().optional().default(true), +}) + export type GoogleDriveUploadInput = z.output export type GoogleDriveDownloadInput = z.output export type GoogleDriveExportInput = z.output +export type GoogleDriveMoveInput = z.output diff --git a/apps/sim/lib/internal/google-drive/operations.ts b/apps/sim/lib/internal/google-drive/operations.ts index 48554453620..c7d02d82f26 100644 --- a/apps/sim/lib/internal/google-drive/operations.ts +++ b/apps/sim/lib/internal/google-drive/operations.ts @@ -14,6 +14,7 @@ import { resolveGoogleDriveUploadFile } from '@/lib/internal/google-drive/file-i import type { GoogleDriveDownloadInput, GoogleDriveExportInput, + GoogleDriveMoveInput, GoogleDriveUploadInput, } from '@/lib/internal/google-drive/input' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' @@ -292,6 +293,64 @@ export async function executeGoogleDriveExport( } } +export async function executeGoogleDriveMove( + input: GoogleDriveMoveInput, + context: GoogleDriveOperationContext +) { + context.signal?.throwIfAborted() + const query = new URLSearchParams({ + addParents: input.destinationFolderId, + fields: ALL_FILE_FIELDS, + supportsAllDrives: 'true', + }) + + if (input.removeFromCurrent) { + const metadataResponse = await requestGoogleDrive({ + accessToken: input.accessToken, + label: 'moveMetadataUrl', + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + signal: context.signal, + url: `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}?fields=parents&supportsAllDrives=true`, + }) + if (!metadataResponse.ok) { + await providerJsonError( + metadataResponse, + 'Failed to retrieve file metadata', + metadataResponse.status, + context.signal + ) + } + const metadata = await responseObject(metadataResponse) + if (Array.isArray(metadata.parents) && metadata.parents.length > 0) { + query.set( + 'removeParents', + metadata.parents.filter((parent): parent is string => typeof parent === 'string').join(',') + ) + } + } + + const response = await requestGoogleDrive({ + accessToken: input.accessToken, + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + label: 'moveFileUrl', + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + method: 'PATCH', + signal: context.signal, + url: `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}?${query.toString()}`, + }) + if (!response.ok) { + await providerJsonError( + response, + 'Failed to move Google Drive file', + response.status, + context.signal + ) + } + const file = await responseObject(response) + return { success: true, output: { file } } +} + function uploadMetadata(input: GoogleDriveUploadInput, requestedMimeType: string) { return { name: input.fileName, diff --git a/apps/sim/lib/internal/managed-agent/execute-tool.ts b/apps/sim/lib/internal/managed-agent/execute-tool.ts new file mode 100644 index 00000000000..f4f00e42545 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/execute-tool.ts @@ -0,0 +1,56 @@ +import { + executeManagedAgentArchiveSessionOperation, + executeManagedAgentCreateSessionOperation, + executeManagedAgentDeleteSessionOperation, + executeManagedAgentGetSessionOperation, + executeManagedAgentInterruptSessionOperation, + executeManagedAgentListEventsOperation, + executeManagedAgentRespondCustomToolOperation, + executeManagedAgentRespondToolConfirmationOperation, + executeManagedAgentRunSessionOperation, + executeManagedAgentSendMessageOperation, + executeManagedAgentUpdateSessionOperation, +} from '@/lib/internal/managed-agent/operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeManagedAgentTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'managed_agent_archive_session': + return executeToolOperationImplementation(executeManagedAgentArchiveSessionOperation, request) + case 'managed_agent_create_session': + return executeToolOperationImplementation(executeManagedAgentCreateSessionOperation, request) + case 'managed_agent_delete_session': + return executeToolOperationImplementation(executeManagedAgentDeleteSessionOperation, request) + case 'managed_agent_get_session': + return executeToolOperationImplementation(executeManagedAgentGetSessionOperation, request) + case 'managed_agent_interrupt_session': + return executeToolOperationImplementation( + executeManagedAgentInterruptSessionOperation, + request + ) + case 'managed_agent_list_events': + return executeToolOperationImplementation(executeManagedAgentListEventsOperation, request) + case 'managed_agent_respond_custom_tool': + return executeToolOperationImplementation( + executeManagedAgentRespondCustomToolOperation, + request + ) + case 'managed_agent_respond_tool_confirmation': + return executeToolOperationImplementation( + executeManagedAgentRespondToolConfirmationOperation, + request + ) + case 'managed_agent_run_session': + return executeToolOperationImplementation(executeManagedAgentRunSessionOperation, request) + case 'managed_agent_send_message': + return executeToolOperationImplementation(executeManagedAgentSendMessageOperation, request) + case 'managed_agent_update_session': + return executeToolOperationImplementation(executeManagedAgentUpdateSessionOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported managed-agent tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/archive-session.ts b/apps/sim/lib/internal/managed-agent/operations/archive-session.ts new file mode 100644 index 00000000000..0e4374c2cdb --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/archive-session.ts @@ -0,0 +1,32 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { archiveSession } from '@/lib/managed-agents/session-client' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentArchiveSessionParams, + ManagedAgentArchiveSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentArchiveSessionOperation: InternalToolOperationImplementation< + ManagedAgentArchiveSessionParams +> = async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', archived: false }, error: target.error } + } + + try { + await archiveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, archived: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, archived: false }, + error: getErrorMessage(error, 'Failed to archive Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/create-session.ts b/apps/sim/lib/internal/managed-agent/operations/create-session.ts new file mode 100644 index 00000000000..05be9a335e6 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/create-session.ts @@ -0,0 +1,100 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { + type CreateSessionInput, + createSession, + getEnvironmentType, +} from '@/lib/managed-agents/session-client' +import { + isTruthyAck, + normalizeFiles, + normalizeMemoryAccess, + normalizeSessionParameters, + normalizeStringList, +} from '@/tools/managed_agent/normalizers' +import type { + ManagedAgentCreateSessionParams, + ManagedAgentCreateSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentCreateSessionOperation: InternalToolOperationImplementation< + ManagedAgentCreateSessionParams +> = async (params, signal, context): Promise => { + const apiKey = params.accessToken + if (!apiKey) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + + const agentId = params.agent?.trim() + const environmentId = params.environment?.trim() + if (!agentId || !environmentId) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'An agent and an environment are required.', + } + } + + const vaultIds = normalizeStringList(params.vaults) + if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { + return { + success: false, + output: { sessionId: '', started: false }, + error: + 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', + } + } + + const files = normalizeFiles(params.files) + const sessionParameters = normalizeSessionParameters(params.sessionParameters) + const memoryStoreId = params.memoryStoreId?.trim() || undefined + const memoryAccess = normalizeMemoryAccess(params.memoryAccess) + const memoryInstructions = params.memoryInstructions?.trim() || undefined + const initialMessage = (params.userMessage ?? '').toString().trim() || undefined + + const workflowId = context?.workflowId.trim() + const title = workflowId ? `Sim workflow ${workflowId}` : undefined + + // Self-hosted environments reject `resources`, so the payload must know the + // execution model. The API is authoritative; the block's hint is a fallback. + const hinted = + params.environmentType === 'self_hosted' || params.environmentType === 'cloud' + ? params.environmentType + : undefined + const environmentType = + (await getEnvironmentType({ apiKey, environmentId, ...(signal ? { signal } : {}) })) ?? hinted + + const createInput: CreateSessionInput = { + apiKey, + agentId, + environmentId, + ...(environmentType ? { environmentType } : {}), + ...(title ? { title } : {}), + ...(vaultIds.length > 0 ? { vaultIds } : {}), + ...(memoryStoreId ? { memoryStoreId } : {}), + ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), + ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), + ...(files.length > 0 ? { files } : {}), + ...(sessionParameters ? { sessionParameters } : {}), + ...(initialMessage ? { initialMessage } : {}), + ...(signal ? { signal } : {}), + } + + try { + const session = await createSession(createInput) + return { + success: true, + output: { sessionId: session.id, started: Boolean(initialMessage) }, + } + } catch (error) { + return { + success: false, + output: { sessionId: '', started: false }, + error: getErrorMessage(error, 'Failed to create Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/delete-session.ts b/apps/sim/lib/internal/managed-agent/operations/delete-session.ts new file mode 100644 index 00000000000..6104f37439c --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/delete-session.ts @@ -0,0 +1,32 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { deleteSession } from '@/lib/managed-agents/session-client' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentDeleteSessionParams, + ManagedAgentDeleteSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentDeleteSessionOperation: InternalToolOperationImplementation< + ManagedAgentDeleteSessionParams +> = async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', deleted: false }, error: target.error } + } + + try { + await deleteSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, deleted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, deleted: false }, + error: getErrorMessage(error, 'Failed to delete Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/get-session.ts b/apps/sim/lib/internal/managed-agent/operations/get-session.ts new file mode 100644 index 00000000000..bc7c8f7d600 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/get-session.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentGetSessionParams, + ManagedAgentGetSessionResponse, + ManagedAgentPendingTool, +} from '@/tools/managed_agent/types' + +const logger = createLogger('ManagedAgentGetSession') +const REQUIRES_ACTION = 'requires_action' + +export const executeManagedAgentGetSessionOperation: InternalToolOperationImplementation< + ManagedAgentGetSessionParams +> = async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + status: '', + requiresAction: false, + pendingTools: [] as ManagedAgentPendingTool[], + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + try { + const snapshot = await retrieveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + + const requiresAction = + snapshot.status === 'idle' && snapshot.stopReason?.type === REQUIRES_ACTION + const eventIds = snapshot.stopReason?.eventIds ?? [] + // Only pay for the events call when the session is actually blocked. + const pendingTools = + requiresAction && eventIds.length > 0 + ? await resolvePendingToolGates({ + apiKey: target.apiKey, + sessionId: target.sessionId, + eventIds, + ...(signal ? { signal } : {}), + }) + : [] + + // A blocked session that names no blocking events is an anomaly: it waits + // indefinitely, but nothing here can say for what. `requiresAction` stays + // true because that is the truth — reporting false would tell a workflow + // the session is fine while it is parked forever — so log it instead, so + // the dead end is visible rather than silent. + if (requiresAction && pendingTools.length === 0) { + logger.warn('Managed Agent session requires action but reported no blocking event ids', { + sessionId: target.sessionId, + }) + } + + return { + success: true, + output: { + sessionId: target.sessionId, + status: snapshot.status ?? '', + ...(snapshot.stopReason?.type ? { stopReason: snapshot.stopReason.type } : {}), + requiresAction, + pendingTools, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + ...(snapshot.usage?.inputTokens !== undefined + ? { inputTokens: snapshot.usage.inputTokens } + : {}), + ...(snapshot.usage?.outputTokens !== undefined + ? { outputTokens: snapshot.usage.outputTokens } + : {}), + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to read Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/index.ts b/apps/sim/lib/internal/managed-agent/operations/index.ts new file mode 100644 index 00000000000..0a9ec131910 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/index.ts @@ -0,0 +1,11 @@ +export { executeManagedAgentArchiveSessionOperation } from '@/lib/internal/managed-agent/operations/archive-session' +export { executeManagedAgentCreateSessionOperation } from '@/lib/internal/managed-agent/operations/create-session' +export { executeManagedAgentDeleteSessionOperation } from '@/lib/internal/managed-agent/operations/delete-session' +export { executeManagedAgentGetSessionOperation } from '@/lib/internal/managed-agent/operations/get-session' +export { executeManagedAgentInterruptSessionOperation } from '@/lib/internal/managed-agent/operations/interrupt-session' +export { executeManagedAgentListEventsOperation } from '@/lib/internal/managed-agent/operations/list-events' +export { executeManagedAgentRespondCustomToolOperation } from '@/lib/internal/managed-agent/operations/respond-custom-tool' +export { executeManagedAgentRespondToolConfirmationOperation } from '@/lib/internal/managed-agent/operations/respond-tool-confirmation' +export { executeManagedAgentRunSessionOperation } from '@/lib/internal/managed-agent/operations/run-session' +export { executeManagedAgentSendMessageOperation } from '@/lib/internal/managed-agent/operations/send-message' +export { executeManagedAgentUpdateSessionOperation } from '@/lib/internal/managed-agent/operations/update-session' diff --git a/apps/sim/lib/internal/managed-agent/operations/interrupt-session.ts b/apps/sim/lib/internal/managed-agent/operations/interrupt-session.ts new file mode 100644 index 00000000000..700df80ad9a --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/interrupt-session.ts @@ -0,0 +1,39 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { sendSessionEvents } from '@/lib/managed-agents/session-client' +import { INTERRUPT_TIMEOUT_MS } from '@/tools/managed_agent/interrupt_session' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentInterruptSessionParams, + ManagedAgentInterruptSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentInterruptSessionOperation: InternalToolOperationImplementation< + ManagedAgentInterruptSessionParams +> = async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', interrupted: false }, error: target.error } + } + + try { + await sendSessionEvents({ + apiKey: target.apiKey, + sessionId: target.sessionId, + events: [{ type: 'user.interrupt' }], + // Bounded so a stalled connection can't hang the operation. The + // workflow's own signal still cancels earlier when present; `any` + // resolves on whichever fires first. + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(INTERRUPT_TIMEOUT_MS)]) + : AbortSignal.timeout(INTERRUPT_TIMEOUT_MS), + }) + return { success: true, output: { sessionId: target.sessionId, interrupted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, interrupted: false }, + error: getErrorMessage(error, 'Failed to interrupt Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/list-events.ts b/apps/sim/lib/internal/managed-agent/operations/list-events.ts new file mode 100644 index 00000000000..e3d5a05cabc --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/list-events.ts @@ -0,0 +1,74 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { listSessionEventsPage } from '@/lib/managed-agents/session-client' +import { DEFAULT_EVENT_LIMIT } from '@/tools/managed_agent/list_events' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentListEventsParams, + ManagedAgentListEventsResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentListEventsOperation: InternalToolOperationImplementation< + ManagedAgentListEventsParams +> = async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + events: [] as unknown[], + count: 0, + assistantText: '', + truncated: false, + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const types = normalizeStringList(params.eventTypes) + // Floor BEFORE the positivity check: a fractional limit like 0.5 would pass + // `> 0` and then floor to 0, which reads as "no cap" downstream and returns + // the whole history. Anything that does not floor to a positive integer + // falls back to the default rather than silently becoming unbounded. + const requested = Math.floor(Number(params.limit)) + const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT + + try { + const { events, total } = await listSessionEventsPage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + maxItems, + ...(types.length > 0 ? { types } : {}), + ...(signal ? { signal } : {}), + }) + + let assistantText = '' + for (const event of events) { + // Skip idless events: those are stream-only previews, and the persisted + // copy carrying the same text arrives separately. + if (event.type !== 'agent.message' || !event.id || !Array.isArray(event.content)) continue + for (const block of event.content) { + if (block?.type === 'text' && typeof block.text === 'string') assistantText += block.text + } + } + + return { + success: true, + output: { + sessionId: target.sessionId, + events, + count: events.length, + assistantText, + // Compared against the untrimmed history size, not the limit: a + // session holding exactly `maxItems` events dropped nothing and must + // not be reported as a partial read. + truncated: total > events.length, + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to list Managed Agent session events'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/respond-custom-tool.ts b/apps/sim/lib/internal/managed-agent/operations/respond-custom-tool.ts new file mode 100644 index 00000000000..be4c0319a37 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/respond-custom-tool.ts @@ -0,0 +1,52 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { sendCustomToolResults } from '@/lib/managed-agents/session-client' +import { isTruthyAck } from '@/tools/managed_agent/normalizers' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentCustomToolResultParams, + ManagedAgentCustomToolResultResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentRespondCustomToolOperation: InternalToolOperationImplementation< + ManagedAgentCustomToolResultParams +> = async (params, signal): Promise => { + const emptyOutput = { sessionId: '', answeredToolUseId: '' } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const customToolUseId = params.customToolUseId?.trim() + if (!customToolUseId) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: 'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.', + } + } + + // The result may legitimately be empty (a tool that returns nothing), so + // only the id is required — an absent result is sent as an empty string. + const result = (params.result ?? '').toString() + const isError = isTruthyAck(params.isError) + + try { + await sendCustomToolResults({ + apiKey: target.apiKey, + sessionId: target.sessionId, + results: [{ customToolUseId, content: result, isError }], + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to send custom tool result'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/respond-tool-confirmation.ts b/apps/sim/lib/internal/managed-agent/operations/respond-tool-confirmation.ts new file mode 100644 index 00000000000..1ba0cfa81b4 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/respond-tool-confirmation.ts @@ -0,0 +1,62 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { sendToolConfirmations } from '@/lib/managed-agents/session-client' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentToolConfirmationParams, + ManagedAgentToolConfirmationResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentRespondToolConfirmationOperation: InternalToolOperationImplementation< + ManagedAgentToolConfirmationParams +> = async (params, signal): Promise => { + const emptyOutput = { sessionId: '', decision: '', confirmedToolUseIds: [] as string[] } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const decision = (params.decision ?? '').toString().trim().toLowerCase() + if (decision !== 'allow' && decision !== 'deny') { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: "Decision must be 'allow' or 'deny'.", + } + } + + const toolUseIds = normalizeStringList(params.toolUseIds) + if (toolUseIds.length === 0) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId, decision }, + error: + 'At least one tool-use event id is required. Read them from Get Session pendingTools[].id.', + } + } + + const denyMessage = params.denyMessage?.trim() + try { + await sendToolConfirmations({ + apiKey: target.apiKey, + sessionId: target.sessionId, + confirmations: toolUseIds.map((toolUseId) => ({ + toolUseId, + result: decision, + ...(decision === 'deny' && denyMessage ? { denyMessage } : {}), + })), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: toolUseIds }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: [] }, + error: getErrorMessage(error, 'Failed to send tool confirmation'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/run-session.ts b/apps/sim/lib/internal/managed-agent/operations/run-session.ts new file mode 100644 index 00000000000..79c2561c0c6 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/run-session.ts @@ -0,0 +1,97 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { runManagedAgentSession } from '@/lib/managed-agents/run-session' +import { + isTruthyAck, + normalizeFiles, + normalizeMemoryAccess, + normalizeSessionParameters, + normalizeStringList, +} from '@/tools/managed_agent/normalizers' +import type { + ManagedAgentRunSessionParams, + ManagedAgentRunSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentRunSessionOperation: InternalToolOperationImplementation< + ManagedAgentRunSessionParams +> = async (params, signal, context): Promise => { + const apiKey = params.accessToken + if (!apiKey) { + return { + success: false, + output: { content: '', sessionId: '' }, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + + const agentId = params.agent?.trim() + const environmentId = params.environment?.trim() + if (!agentId || !environmentId) { + return { + success: false, + output: { content: '', sessionId: '' }, + error: 'An agent and an environment are required.', + } + } + + const vaultIds = normalizeStringList(params.vaults) + if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { + return { + success: false, + output: { content: '', sessionId: '' }, + error: + 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', + } + } + + const files = normalizeFiles(params.files) + const sessionParameters = normalizeSessionParameters(params.sessionParameters) + const memoryStoreId = params.memoryStoreId?.trim() || undefined + const memoryAccess = normalizeMemoryAccess(params.memoryAccess) + const memoryInstructions = params.memoryInstructions?.trim() || undefined + + // Title the Anthropic session so it is traceable to its Sim workflow from + // the Claude Platform console. Only the workflow id is available in the + // client-safe execution context (names would require a DB lookup). + const workflowId = context?.workflowId.trim() + const title = workflowId ? `Sim workflow ${workflowId}` : undefined + + const environmentType = + params.environmentType === 'self_hosted' || params.environmentType === 'cloud' + ? params.environmentType + : undefined + + const result = await runManagedAgentSession({ + apiKey, + agentId, + environmentId, + userMessage: (params.userMessage ?? '').toString(), + ...(environmentType ? { environmentType } : {}), + ...(title ? { title } : {}), + ...(vaultIds.length > 0 ? { vaultIds } : {}), + ...(memoryStoreId ? { memoryStoreId } : {}), + ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), + ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), + ...(files.length > 0 ? { files } : {}), + ...(sessionParameters ? { sessionParameters } : {}), + ...(signal ? { signal } : {}), + }) + + if (!result.ok) { + return { + success: false, + output: { content: result.content, sessionId: result.sessionId ?? '' }, + error: result.error ?? 'Managed Agent session failed', + } + } + + return { + success: true, + output: { + content: result.content, + sessionId: result.sessionId ?? '', + ...(result.inputTokens !== undefined ? { inputTokens: result.inputTokens } : {}), + ...(result.outputTokens !== undefined ? { outputTokens: result.outputTokens } : {}), + }, + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/send-message.ts b/apps/sim/lib/internal/managed-agent/operations/send-message.ts new file mode 100644 index 00000000000..bc7f5fc31ac --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/send-message.ts @@ -0,0 +1,42 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { sendUserMessage } from '@/lib/managed-agents/session-client' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentSendMessageParams, + ManagedAgentSendMessageResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentSendMessageOperation: InternalToolOperationImplementation< + ManagedAgentSendMessageParams +> = async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', sent: false }, error: target.error } + } + + const text = (params.userMessage ?? '').toString().trim() + if (!text) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: 'A user message is required.', + } + } + + try { + await sendUserMessage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + text, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, sent: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: getErrorMessage(error, 'Failed to send message to Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/managed-agent/operations/update-session.ts b/apps/sim/lib/internal/managed-agent/operations/update-session.ts new file mode 100644 index 00000000000..cec96f2b2a0 --- /dev/null +++ b/apps/sim/lib/internal/managed-agent/operations/update-session.ts @@ -0,0 +1,63 @@ +import { getErrorMessage } from '@sim/utils/errors' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { updateSession } from '@/lib/managed-agents/session-client' +import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers' +import { resolveSessionTarget } from '@/tools/managed_agent/shared' +import type { + ManagedAgentUpdateSessionParams, + ManagedAgentUpdateSessionResponse, +} from '@/tools/managed_agent/types' + +export const executeManagedAgentUpdateSessionOperation: InternalToolOperationImplementation< + ManagedAgentUpdateSessionParams +> = async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', updated: false }, error: target.error } + } + + // A whitespace-only title is treated as "not provided", not as a request to + // blank the session's title — otherwise a stray space in the field would + // both slip past the guard below and silently clear an existing title. + const trimmedTitle = params.title?.trim() + const title = trimmedTitle ? trimmedTitle : undefined + + // Clearing metadata needs its own explicit signal. An empty metadata table + // cannot mean "clear": a table the author never touched is also empty, so + // inferring intent from emptiness would wipe a session's metadata on every + // title-only update. `{}` is only sent when the author asks for it. + const clearMetadata = isTruthyAck(params.clearMetadata) + const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters) + if (title === undefined && metadata === undefined) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: 'Provide a title or metadata to update, or check "Clear metadata".', + } + } + + try { + const snapshot = await updateSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(title !== undefined ? { title } : {}), + ...(metadata !== undefined ? { metadata } : {}), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { + sessionId: target.sessionId, + updated: true, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: getErrorMessage(error, 'Failed to update Managed Agent session'), + } + } +} diff --git a/apps/sim/lib/internal/microsoft-ad/execute-tool.ts b/apps/sim/lib/internal/microsoft-ad/execute-tool.ts new file mode 100644 index 00000000000..ab29147ef1a --- /dev/null +++ b/apps/sim/lib/internal/microsoft-ad/execute-tool.ts @@ -0,0 +1,15 @@ +import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeMicrosoftAdTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'microsoft_ad_add_user_app_role_assignment': + return executeToolOperationImplementation(executeAddUserAppRoleAssignmentOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported microsoft-ad tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.test.ts b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.test.ts new file mode 100644 index 00000000000..87a453e22f1 --- /dev/null +++ b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment' + +const INPUT = { + accessToken: 'access-token', + userId: '11111111-1111-4111-8111-111111111111', + resourceId: '22222222-2222-4222-8222-222222222222', + appRoleId: '33333333-3333-4333-8333-333333333333', +} + +describe('executeAddUserAppRoleAssignmentOperation', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('rejects malformed successful Graph JSON instead of fabricating a null assignment', async () => { + fetchMock.mockResolvedValueOnce(new Response('not-json')) + + await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow( + 'Microsoft Graph returned malformed JSON for the app role assignment' + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects a successful non-object assignment payload', async () => { + fetchMock.mockResolvedValueOnce(Response.json(null)) + + await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow( + 'Microsoft Graph returned an invalid app role assignment' + ) + }) + + it('rejects a successful empty assignment payload', async () => { + fetchMock.mockResolvedValueOnce(Response.json({})) + + await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow( + 'Microsoft Graph returned an invalid app role assignment' + ) + }) +}) diff --git a/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts new file mode 100644 index 00000000000..73d3a03f79c --- /dev/null +++ b/apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts @@ -0,0 +1,50 @@ +import { isRecordLike } from '@sim/utils/object' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { + mapAppRoleAssignment, + readIdentifiers, +} from '@/tools/microsoft_ad/add_user_app_role_assignment' +import type { + MicrosoftAdAddUserAppRoleAssignmentParams, + MicrosoftAdAddUserAppRoleAssignmentResponse, +} from '@/tools/microsoft_ad/types' +import { extractGraphErrorMessage, resolveGraphUserObjectId } from '@/tools/microsoft_ad/utils' + +export const executeAddUserAppRoleAssignmentOperation: InternalToolOperationImplementation< + MicrosoftAdAddUserAppRoleAssignmentParams +> = async (params, signal): Promise => { + const { userId, resourceId, appRoleId } = readIdentifiers(params) + const principalId = await resolveGraphUserObjectId(userId, params.accessToken, signal) + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/appRoleAssignments`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ principalId, resourceId, appRoleId }), + signal, + } + ) + let body: unknown + try { + body = await response.json() + } catch { + signal?.throwIfAborted() + if (response.ok) { + throw new Error('Microsoft Graph returned malformed JSON for the app role assignment') + } + body = {} + } + signal?.throwIfAborted() + if (!response.ok) { + throw new Error(extractGraphErrorMessage(body, 'Failed to grant the app role to the user')) + } + if (!isRecordLike(body) || typeof body.id !== 'string' || !body.id.trim()) { + throw new Error('Microsoft Graph returned an invalid app role assignment') + } + + return { success: true, output: { assignment: mapAppRoleAssignment(body) } } +} diff --git a/apps/sim/lib/internal/netsuite/execute-tool.ts b/apps/sim/lib/internal/netsuite/execute-tool.ts new file mode 100644 index 00000000000..1e82b31836a --- /dev/null +++ b/apps/sim/lib/internal/netsuite/execute-tool.ts @@ -0,0 +1,98 @@ +import { + executeNetsuiteAttachRecordOperation, + executeNetsuiteBatchCreateRecordsOperation, + executeNetsuiteBatchDeleteRecordsOperation, + executeNetsuiteBatchGetRecordsOperation, + executeNetsuiteBatchUpdateRecordsOperation, + executeNetsuiteBatchUpsertRecordsOperation, + executeNetsuiteCreateRecordOperation, + executeNetsuiteDeleteRecordOperation, + executeNetsuiteDetachRecordOperation, + executeNetsuiteExecuteActionOperation, + executeNetsuiteExecuteDatasetOperation, + executeNetsuiteExecuteSuiteQLOperation, + executeNetsuiteGetAsyncResultOperation, + executeNetsuiteGetAsyncStatusOperation, + executeNetsuiteGetGovernanceLimitsOperation, + executeNetsuiteGetRecordFormOperation, + executeNetsuiteGetRecordMetadataOperation, + executeNetsuiteGetRecordOperation, + executeNetsuiteGetSelectOptionsOperation, + executeNetsuiteGetServerTimeOperation, + executeNetsuiteGetSubresourceOperation, + executeNetsuiteListDatasetsOperation, + executeNetsuiteListRecordsOperation, + executeNetsuiteListRecordTypesOperation, + executeNetsuiteTransformRecordOperation, + executeNetsuiteUpdateRecordOperation, + executeNetsuiteUpsertRecordOperation, +} from '@/lib/internal/netsuite/operations' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeNetsuiteTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'netsuite_attach_record': + return executeToolOperationImplementation(executeNetsuiteAttachRecordOperation, request) + case 'netsuite_batch_create_records': + return executeToolOperationImplementation(executeNetsuiteBatchCreateRecordsOperation, request) + case 'netsuite_batch_delete_records': + return executeToolOperationImplementation(executeNetsuiteBatchDeleteRecordsOperation, request) + case 'netsuite_batch_get_records': + return executeToolOperationImplementation(executeNetsuiteBatchGetRecordsOperation, request) + case 'netsuite_batch_update_records': + return executeToolOperationImplementation(executeNetsuiteBatchUpdateRecordsOperation, request) + case 'netsuite_batch_upsert_records': + return executeToolOperationImplementation(executeNetsuiteBatchUpsertRecordsOperation, request) + case 'netsuite_create_record': + return executeToolOperationImplementation(executeNetsuiteCreateRecordOperation, request) + case 'netsuite_delete_record': + return executeToolOperationImplementation(executeNetsuiteDeleteRecordOperation, request) + case 'netsuite_detach_record': + return executeToolOperationImplementation(executeNetsuiteDetachRecordOperation, request) + case 'netsuite_execute_action': + return executeToolOperationImplementation(executeNetsuiteExecuteActionOperation, request) + case 'netsuite_execute_dataset': + return executeToolOperationImplementation(executeNetsuiteExecuteDatasetOperation, request) + case 'netsuite_execute_suiteql': + return executeToolOperationImplementation(executeNetsuiteExecuteSuiteQLOperation, request) + case 'netsuite_get_async_result': + return executeToolOperationImplementation(executeNetsuiteGetAsyncResultOperation, request) + case 'netsuite_get_async_status': + return executeToolOperationImplementation(executeNetsuiteGetAsyncStatusOperation, request) + case 'netsuite_get_governance_limits': + return executeToolOperationImplementation( + executeNetsuiteGetGovernanceLimitsOperation, + request + ) + case 'netsuite_get_record': + return executeToolOperationImplementation(executeNetsuiteGetRecordOperation, request) + case 'netsuite_get_record_form': + return executeToolOperationImplementation(executeNetsuiteGetRecordFormOperation, request) + case 'netsuite_get_record_metadata': + return executeToolOperationImplementation(executeNetsuiteGetRecordMetadataOperation, request) + case 'netsuite_get_select_options': + return executeToolOperationImplementation(executeNetsuiteGetSelectOptionsOperation, request) + case 'netsuite_get_server_time': + return executeToolOperationImplementation(executeNetsuiteGetServerTimeOperation, request) + case 'netsuite_get_subresource': + return executeToolOperationImplementation(executeNetsuiteGetSubresourceOperation, request) + case 'netsuite_list_datasets': + return executeToolOperationImplementation(executeNetsuiteListDatasetsOperation, request) + case 'netsuite_list_record_types': + return executeToolOperationImplementation(executeNetsuiteListRecordTypesOperation, request) + case 'netsuite_list_records': + return executeToolOperationImplementation(executeNetsuiteListRecordsOperation, request) + case 'netsuite_transform_record': + return executeToolOperationImplementation(executeNetsuiteTransformRecordOperation, request) + case 'netsuite_update_record': + return executeToolOperationImplementation(executeNetsuiteUpdateRecordOperation, request) + case 'netsuite_upsert_record': + return executeToolOperationImplementation(executeNetsuiteUpsertRecordOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported netsuite tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/netsuite/operations/attach-record.ts b/apps/sim/lib/internal/netsuite/operations/attach-record.ts new file mode 100644 index 00000000000..927d2287c79 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/attach-record.ts @@ -0,0 +1,43 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteAttachParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeRelatedType, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteAttachRecordOperation: InternalToolOperationImplementation< + NetSuiteAttachParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const relatedType = normalizeRelatedType(params.relatedType) + const roleId = optionalTrim(params.roleId) + const roleExternalId = optionalTrim(params.roleExternalId) + if (roleId && roleExternalId) { + throw new Error('Provide either a contact role ID or external ID, not both') + } + if (relatedType === 'file' && (roleId || roleExternalId)) { + throw new Error('Contact roles cannot be provided when attaching a file') + } + return { + method: 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' }, + { value: '!attach', label: 'Attach operation' }, + { value: relatedType, label: 'Related type' }, + { value: params.relatedId, label: 'Related ID' } + ), + success: { status: 204, body: 'none' }, + body: roleId + ? { role: { id: roleId } } + : roleExternalId + ? { role: { externalId: roleExternalId } } + : {}, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/batch-create-records.ts b/apps/sim/lib/internal/netsuite/operations/batch-create-records.ts new file mode 100644 index 00000000000..e891bae28aa --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/batch-create-records.ts @@ -0,0 +1,8 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types' +import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteBatchCreateRecordsOperation: InternalToolOperationImplementation< + NetSuiteBatchWriteParams +> = (params, signal) => + executeNetSuiteRequest(params, () => buildBatchWriteRequest('POST', params), signal) diff --git a/apps/sim/lib/internal/netsuite/operations/batch-delete-records.ts b/apps/sim/lib/internal/netsuite/operations/batch-delete-records.ts new file mode 100644 index 00000000000..8fd4c03cd6f --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/batch-delete-records.ts @@ -0,0 +1,30 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteBatchDeleteParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeBatchIds, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteBatchDeleteRecordsOperation: InternalToolOperationImplementation< + NetSuiteBatchDeleteParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key') + return { + method: 'DELETE', + path: buildRecordPath({ value: params.recordType, label: 'Record type' }), + success: { status: 202, body: 'none' }, + responseLocation: 'async-job', + query: { ids: normalizeBatchIds(params.ids) }, + headers: { + Prefer: 'respond-async', + ...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}), + }, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/batch-get-records.ts b/apps/sim/lib/internal/netsuite/operations/batch-get-records.ts new file mode 100644 index 00000000000..b26cdc3a4b3 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/batch-get-records.ts @@ -0,0 +1,40 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteBatchGetParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeBatchIds, + normalizeOptionalBoolean, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteBatchGetRecordsOperation: InternalToolOperationImplementation< + NetSuiteBatchGetParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key') + return { + method: 'GET', + path: buildRecordPath({ value: params.recordType, label: 'Record type' }), + success: { status: 202, body: 'none' }, + responseLocation: 'async-job', + query: { + expandRecords: true, + ids: normalizeBatchIds(params.ids), + fields: optionalTrim(params.fields, 'Fields'), + expand: optionalTrim(params.expand, 'Expand'), + expandSubResources: normalizeOptionalBoolean( + params.expandSubResources, + 'Expand subresources' + ), + }, + headers: { + Prefer: 'respond-async', + ...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}), + }, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/batch-update-records.ts b/apps/sim/lib/internal/netsuite/operations/batch-update-records.ts new file mode 100644 index 00000000000..c94222e615e --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/batch-update-records.ts @@ -0,0 +1,8 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types' +import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteBatchUpdateRecordsOperation: InternalToolOperationImplementation< + NetSuiteBatchWriteParams +> = (params, signal) => + executeNetSuiteRequest(params, () => buildBatchWriteRequest('PATCH', params), signal) diff --git a/apps/sim/lib/internal/netsuite/operations/batch-upsert-records.ts b/apps/sim/lib/internal/netsuite/operations/batch-upsert-records.ts new file mode 100644 index 00000000000..556b2f61967 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/batch-upsert-records.ts @@ -0,0 +1,8 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types' +import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteBatchUpsertRecordsOperation: InternalToolOperationImplementation< + NetSuiteBatchWriteParams +> = (params, signal) => + executeNetSuiteRequest(params, () => buildBatchWriteRequest('PUT', params), signal) diff --git a/apps/sim/lib/internal/netsuite/operations/create-record.ts b/apps/sim/lib/internal/netsuite/operations/create-record.ts new file mode 100644 index 00000000000..3861e53aed6 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/create-record.ts @@ -0,0 +1,22 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteCreateRecordParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest, optionalTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteCreateRecordOperation: InternalToolOperationImplementation< + NetSuiteCreateRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const replace = optionalTrim(params.replace, 'Replace sublists') + return { + method: 'POST', + path: buildRecordPath({ value: params.recordType, label: 'Record type' }), + success: replace ? { status: 201, body: 'object' } : { status: 204, body: 'none' }, + responseLocation: 'resource', + query: { replace }, + body: params.body, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/delete-record.ts b/apps/sim/lib/internal/netsuite/operations/delete-record.ts new file mode 100644 index 00000000000..518d49df160 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/delete-record.ts @@ -0,0 +1,19 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteDeleteRecordParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteDeleteRecordOperation: InternalToolOperationImplementation< + NetSuiteDeleteRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'DELETE', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' } + ), + success: { status: 204, body: 'none' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/detach-record.ts b/apps/sim/lib/internal/netsuite/operations/detach-record.ts new file mode 100644 index 00000000000..929c17964b2 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/detach-record.ts @@ -0,0 +1,26 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteRelationshipParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeRelatedType, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteDetachRecordOperation: InternalToolOperationImplementation< + NetSuiteRelationshipParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' }, + { value: '!detach', label: 'Detach operation' }, + { value: normalizeRelatedType(params.relatedType), label: 'Related type' }, + { value: params.relatedId, label: 'Related ID' } + ), + success: { status: 204, body: 'none' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/execute-action.ts b/apps/sim/lib/internal/netsuite/operations/execute-action.ts new file mode 100644 index 00000000000..347398f4f91 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/execute-action.ts @@ -0,0 +1,21 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteExecuteActionParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest, requiredTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteExecuteActionOperation: InternalToolOperationImplementation< + NetSuiteExecuteActionParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' }, + { value: `@${requiredTrim(params.action, 'Action')}`, label: 'Action' } + ), + success: { status: 200, body: 'object', validator: 'record-action' }, + body: params.body ?? {}, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/execute-dataset.ts b/apps/sim/lib/internal/netsuite/operations/execute-dataset.ts new file mode 100644 index 00000000000..704e062d342 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/execute-dataset.ts @@ -0,0 +1,21 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteExecuteDatasetParams } from '@/tools/netsuite/types' +import { + encodePathSegment, + executeNetSuiteRequest, + normalizePagination, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteExecuteDatasetOperation: InternalToolOperationImplementation< + NetSuiteExecuteDatasetParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: `/services/rest/query/v1/dataset/${encodePathSegment(params.datasetId, 'Dataset ID')}/result`, + success: { status: 200, body: 'object', validator: 'collection-page' }, + query: normalizePagination(params.limit, params.offset), + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/execute-suiteql.ts b/apps/sim/lib/internal/netsuite/operations/execute-suiteql.ts new file mode 100644 index 00000000000..a7a143067c4 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/execute-suiteql.ts @@ -0,0 +1,19 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteSuiteQLParams } from '@/tools/netsuite/types' +import { executeNetSuiteRequest, normalizePagination, requiredTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteExecuteSuiteQLOperation: InternalToolOperationImplementation< + NetSuiteSuiteQLParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'POST', + path: '/services/rest/query/v1/suiteql', + success: { status: 200, body: 'object', validator: 'suiteql-page' }, + query: normalizePagination(params.limit, params.offset), + headers: { Prefer: 'transient' }, + body: { q: requiredTrim(params.query, 'SuiteQL query') }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-async-result.ts b/apps/sim/lib/internal/netsuite/operations/get-async-result.ts new file mode 100644 index 00000000000..d913177d5a6 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-async-result.ts @@ -0,0 +1,16 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetAsyncResultParams } from '@/tools/netsuite/types' +import { encodePathSegment, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteGetAsyncResultOperation: InternalToolOperationImplementation< + NetSuiteGetAsyncResultParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}/task/${encodePathSegment(params.taskId, 'Task ID')}/result`, + success: { status: 200, body: 'optional-object' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-async-status.ts b/apps/sim/lib/internal/netsuite/operations/get-async-status.ts new file mode 100644 index 00000000000..f36628b3f7b --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-async-status.ts @@ -0,0 +1,38 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetAsyncStatusParams } from '@/tools/netsuite/types' +import { encodePathSegment, executeNetSuiteRequest, requiredTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteGetAsyncStatusOperation: InternalToolOperationImplementation< + NetSuiteGetAsyncStatusParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const view = params.view ?? 'job' + if (view !== 'job' && view !== 'tasks' && view !== 'task') { + throw new Error('Async status view must be job, tasks, or task') + } + const jobPath = `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}` + if (view === 'job') { + return { + method: 'GET', + path: jobPath, + success: { status: 200, body: 'object', validator: 'async-job' }, + } + } + const taskPath = + view === 'task' + ? `/${encodePathSegment(requiredTrim(params.taskId ?? '', 'Task ID'), 'Task ID')}` + : '' + return { + method: 'GET', + path: `${jobPath}/task${taskPath}`, + success: { + status: 200, + body: 'object', + validator: view === 'task' ? 'async-task' : 'async-task-collection', + }, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-governance-limits.ts b/apps/sim/lib/internal/netsuite/operations/get-governance-limits.ts new file mode 100644 index 00000000000..84b520b5b99 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-governance-limits.ts @@ -0,0 +1,16 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteSystemParams } from '@/tools/netsuite/types' +import { executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteGetGovernanceLimitsOperation: InternalToolOperationImplementation< + NetSuiteSystemParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: '/services/rest/system/v1/governanceLimits', + success: { status: 200, body: 'object', validator: 'governance-limits' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-record-form.ts b/apps/sim/lib/internal/netsuite/operations/get-record-form.ts new file mode 100644 index 00000000000..a0b11c76bc6 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-record-form.ts @@ -0,0 +1,39 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetRecordFormParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeOptionalBoolean, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteGetRecordFormOperation: InternalToolOperationImplementation< + NetSuiteGetRecordFormParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const recordId = optionalTrim(params.recordId) + return { + method: recordId ? 'PATCH' : 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + ...(recordId ? [{ value: recordId, label: 'Record ID' }] : []) + ), + success: { status: 200, body: 'object' }, + query: { + fields: optionalTrim(params.fields), + expand: optionalTrim(params.expand), + expandSubResources: normalizeOptionalBoolean( + params.expandSubResources, + 'Expand subresources' + ), + }, + headers: { + Accept: `application/vnd.oracle.resource+json; type=${recordId ? 'edit-form' : 'create-form'}`, + }, + body: params.body ?? {}, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-record-metadata.ts b/apps/sim/lib/internal/netsuite/operations/get-record-metadata.ts new file mode 100644 index 00000000000..f2db6083cf6 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-record-metadata.ts @@ -0,0 +1,18 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { getMetadataAccept } from '@/tools/netsuite/get_record_metadata' +import type { NetSuiteGetRecordMetadataParams } from '@/tools/netsuite/types' +import { encodePathSegment, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteGetRecordMetadataOperation: InternalToolOperationImplementation< + NetSuiteGetRecordMetadataParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: `/services/rest/record/v1/metadata-catalog/${encodePathSegment(params.recordType, 'Record type')}`, + success: { status: 200, body: 'object' }, + headers: { Accept: getMetadataAccept(params.format) }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-record.ts b/apps/sim/lib/internal/netsuite/operations/get-record.ts new file mode 100644 index 00000000000..036a6e11193 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-record.ts @@ -0,0 +1,32 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetRecordParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizeOptionalBoolean, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteGetRecordOperation: InternalToolOperationImplementation< + NetSuiteGetRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' } + ), + success: { status: 200, body: 'object' }, + query: { + fields: optionalTrim(params.fields), + expand: optionalTrim(params.expand), + expandSubResources: normalizeOptionalBoolean( + params.expandSubResources, + 'Expand subresources' + ), + }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-select-options.ts b/apps/sim/lib/internal/netsuite/operations/get-select-options.ts new file mode 100644 index 00000000000..ab6553ffa76 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-select-options.ts @@ -0,0 +1,36 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetSelectOptionsParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizePagination, + optionalTrim, + requiredTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteGetSelectOptionsOperation: InternalToolOperationImplementation< + NetSuiteGetSelectOptionsParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => { + const recordId = optionalTrim(params.recordId) + const pagination = normalizePagination(params.limit, params.offset) + return { + method: recordId ? 'PATCH' : 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + ...(recordId ? [{ value: recordId, label: 'Record ID' }] : []) + ), + success: { status: 200, body: 'object' }, + query: { + ...pagination, + fields: requiredTrim(params.fields, 'Fields'), + q: optionalTrim(params.q), + }, + headers: { Accept: 'application/vnd.oracle.resource+json; type=select-options' }, + body: params.body ?? {}, + } + }, + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-server-time.ts b/apps/sim/lib/internal/netsuite/operations/get-server-time.ts new file mode 100644 index 00000000000..bd432828fca --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-server-time.ts @@ -0,0 +1,16 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteSystemParams } from '@/tools/netsuite/types' +import { executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteGetServerTimeOperation: InternalToolOperationImplementation< + NetSuiteSystemParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: '/services/rest/system/v1/serverTime', + success: { status: 200, body: 'object', validator: 'server-time' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/get-subresource.ts b/apps/sim/lib/internal/netsuite/operations/get-subresource.ts new file mode 100644 index 00000000000..80c51886bfd --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/get-subresource.ts @@ -0,0 +1,24 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteGetSubresourceParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + buildSubresourcePath, + executeNetSuiteRequest, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteGetSubresourceOperation: InternalToolOperationImplementation< + NetSuiteGetSubresourceParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' }, + ...buildSubresourcePath(params.subresourcePath) + ), + success: { status: 200, body: 'object' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/index.ts b/apps/sim/lib/internal/netsuite/operations/index.ts new file mode 100644 index 00000000000..592eb9372c7 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/index.ts @@ -0,0 +1,27 @@ +export { executeNetsuiteAttachRecordOperation } from '@/lib/internal/netsuite/operations/attach-record' +export { executeNetsuiteBatchCreateRecordsOperation } from '@/lib/internal/netsuite/operations/batch-create-records' +export { executeNetsuiteBatchDeleteRecordsOperation } from '@/lib/internal/netsuite/operations/batch-delete-records' +export { executeNetsuiteBatchGetRecordsOperation } from '@/lib/internal/netsuite/operations/batch-get-records' +export { executeNetsuiteBatchUpdateRecordsOperation } from '@/lib/internal/netsuite/operations/batch-update-records' +export { executeNetsuiteBatchUpsertRecordsOperation } from '@/lib/internal/netsuite/operations/batch-upsert-records' +export { executeNetsuiteCreateRecordOperation } from '@/lib/internal/netsuite/operations/create-record' +export { executeNetsuiteDeleteRecordOperation } from '@/lib/internal/netsuite/operations/delete-record' +export { executeNetsuiteDetachRecordOperation } from '@/lib/internal/netsuite/operations/detach-record' +export { executeNetsuiteExecuteActionOperation } from '@/lib/internal/netsuite/operations/execute-action' +export { executeNetsuiteExecuteDatasetOperation } from '@/lib/internal/netsuite/operations/execute-dataset' +export { executeNetsuiteExecuteSuiteQLOperation } from '@/lib/internal/netsuite/operations/execute-suiteql' +export { executeNetsuiteGetAsyncResultOperation } from '@/lib/internal/netsuite/operations/get-async-result' +export { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status' +export { executeNetsuiteGetGovernanceLimitsOperation } from '@/lib/internal/netsuite/operations/get-governance-limits' +export { executeNetsuiteGetRecordOperation } from '@/lib/internal/netsuite/operations/get-record' +export { executeNetsuiteGetRecordFormOperation } from '@/lib/internal/netsuite/operations/get-record-form' +export { executeNetsuiteGetRecordMetadataOperation } from '@/lib/internal/netsuite/operations/get-record-metadata' +export { executeNetsuiteGetSelectOptionsOperation } from '@/lib/internal/netsuite/operations/get-select-options' +export { executeNetsuiteGetServerTimeOperation } from '@/lib/internal/netsuite/operations/get-server-time' +export { executeNetsuiteGetSubresourceOperation } from '@/lib/internal/netsuite/operations/get-subresource' +export { executeNetsuiteListDatasetsOperation } from '@/lib/internal/netsuite/operations/list-datasets' +export { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types' +export { executeNetsuiteListRecordsOperation } from '@/lib/internal/netsuite/operations/list-records' +export { executeNetsuiteTransformRecordOperation } from '@/lib/internal/netsuite/operations/transform-record' +export { executeNetsuiteUpdateRecordOperation } from '@/lib/internal/netsuite/operations/update-record' +export { executeNetsuiteUpsertRecordOperation } from '@/lib/internal/netsuite/operations/upsert-record' diff --git a/apps/sim/lib/internal/netsuite/operations/list-datasets.ts b/apps/sim/lib/internal/netsuite/operations/list-datasets.ts new file mode 100644 index 00000000000..44b6157b8b0 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/list-datasets.ts @@ -0,0 +1,17 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteListDatasetsParams } from '@/tools/netsuite/types' +import { executeNetSuiteRequest, normalizePagination } from '@/tools/netsuite/utils' + +export const executeNetsuiteListDatasetsOperation: InternalToolOperationImplementation< + NetSuiteListDatasetsParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: '/services/rest/query/v1/dataset/', + success: { status: 200, body: 'object', validator: 'collection-page' }, + query: normalizePagination(params.limit, params.offset), + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/list-record-types.ts b/apps/sim/lib/internal/netsuite/operations/list-record-types.ts new file mode 100644 index 00000000000..a9dbef966e9 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/list-record-types.ts @@ -0,0 +1,16 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteListRecordTypesParams } from '@/tools/netsuite/types' +import { executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteListRecordTypesOperation: InternalToolOperationImplementation< + NetSuiteListRecordTypesParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: '/services/rest/record/v1/metadata-catalog', + success: { status: 200, body: 'object', validator: 'metadata-catalog' }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/list-records.ts b/apps/sim/lib/internal/netsuite/operations/list-records.ts new file mode 100644 index 00000000000..1b7321c8119 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/list-records.ts @@ -0,0 +1,25 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteListRecordsParams } from '@/tools/netsuite/types' +import { + buildRecordPath, + executeNetSuiteRequest, + normalizePagination, + optionalTrim, +} from '@/tools/netsuite/utils' + +export const executeNetsuiteListRecordsOperation: InternalToolOperationImplementation< + NetSuiteListRecordsParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'GET', + path: buildRecordPath({ value: params.recordType, label: 'Record type' }), + success: { status: 200, body: 'object', validator: 'collection-page' }, + query: { + ...normalizePagination(params.limit, params.offset), + q: optionalTrim(params.q, 'Filter'), + }, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/transform-record.ts b/apps/sim/lib/internal/netsuite/operations/transform-record.ts new file mode 100644 index 00000000000..fedec972b1b --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/transform-record.ts @@ -0,0 +1,23 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteTransformRecordParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest } from '@/tools/netsuite/utils' + +export const executeNetsuiteTransformRecordOperation: InternalToolOperationImplementation< + NetSuiteTransformRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'POST', + path: buildRecordPath( + { value: params.recordType, label: 'Source record type' }, + { value: params.recordId, label: 'Record ID' }, + { value: '!transform', label: 'Transform operation' }, + { value: params.targetRecordType, label: 'Target record type' } + ), + success: { status: 204, body: 'none' }, + responseLocation: 'resource-optional', + body: params.body ?? {}, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/update-record.ts b/apps/sim/lib/internal/netsuite/operations/update-record.ts new file mode 100644 index 00000000000..7f3bd121d4a --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/update-record.ts @@ -0,0 +1,22 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteUpdateRecordParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest, optionalTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteUpdateRecordOperation: InternalToolOperationImplementation< + NetSuiteUpdateRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'PATCH', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: params.recordId, label: 'Record ID' } + ), + success: { status: 204, body: 'none' }, + responseLocation: 'resource', + query: { replace: optionalTrim(params.replace, 'Replace sublists') }, + body: params.body, + }), + signal + ) diff --git a/apps/sim/lib/internal/netsuite/operations/upsert-record.ts b/apps/sim/lib/internal/netsuite/operations/upsert-record.ts new file mode 100644 index 00000000000..55395d9ac35 --- /dev/null +++ b/apps/sim/lib/internal/netsuite/operations/upsert-record.ts @@ -0,0 +1,21 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { NetSuiteUpsertRecordParams } from '@/tools/netsuite/types' +import { buildRecordPath, executeNetSuiteRequest, requiredTrim } from '@/tools/netsuite/utils' + +export const executeNetsuiteUpsertRecordOperation: InternalToolOperationImplementation< + NetSuiteUpsertRecordParams +> = (params, signal) => + executeNetSuiteRequest( + params, + () => ({ + method: 'PUT', + path: buildRecordPath( + { value: params.recordType, label: 'Record type' }, + { value: `eid:${requiredTrim(params.externalId, 'External ID')}`, label: 'External ID' } + ), + success: { status: 204, body: 'none' }, + responseLocation: 'resource-optional', + body: params.body, + }), + signal + ) diff --git a/apps/sim/lib/internal/okta/execute-tool.ts b/apps/sim/lib/internal/okta/execute-tool.ts new file mode 100644 index 00000000000..b5b7ebd64c8 --- /dev/null +++ b/apps/sim/lib/internal/okta/execute-tool.ts @@ -0,0 +1,15 @@ +import { executeOktaUpdateGroupOperation } from '@/lib/internal/okta/operations/update-group' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeOktaTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'okta_update_group': + return executeToolOperationImplementation(executeOktaUpdateGroupOperation, request) + default: + return Response.json( + { success: false, error: `Unsupported okta tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/okta/operations/update-group.ts b/apps/sim/lib/internal/okta/operations/update-group.ts new file mode 100644 index 00000000000..dfcf829dbf5 --- /dev/null +++ b/apps/sim/lib/internal/okta/operations/update-group.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { validateOktaDomain } from '@/lib/core/security/input-validation' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { OktaGroup, OktaUpdateGroupParams, OktaUpdateGroupResponse } from '@/tools/okta/types' +import { mergeOktaGroupProfile, oktaHeaders, throwOktaError } from '@/tools/okta/utils' + +const logger = createLogger('OktaUpdateGroup') + +async function transformUpdateGroupResponse(response: Response): Promise { + if (!response.ok) { + await throwOktaError(response, logger, 'Failed to update group in Okta') + } + + const group: OktaGroup = await response.json() + return { + success: true, + output: { + id: group.id, + name: group.profile?.name ?? '', + description: group.profile?.description ?? null, + type: group.type, + created: group.created, + lastUpdated: group.lastUpdated, + lastMembershipUpdated: group.lastMembershipUpdated ?? null, + success: true, + }, + } +} + +export const executeOktaUpdateGroupOperation: InternalToolOperationImplementation< + OktaUpdateGroupParams +> = async (params, signal): Promise => { + const domain = validateOktaDomain(params.domain) + const url = `https://${domain}/api/v1/groups/${encodeURIComponent(params.groupId.trim())}` + const headers = oktaHeaders(params.apiKey) + + const readResponse = await fetch(url, { headers, signal }) + if (!readResponse.ok) { + await throwOktaError(readResponse, logger, 'Failed to load group for update in Okta') + } + const existing: OktaGroup = await readResponse.json() + + const writeResponse = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify({ profile: mergeOktaGroupProfile(existing.profile, params) }), + signal, + }) + + return transformUpdateGroupResponse(writeResponse) +} diff --git a/apps/sim/lib/internal/salesforce/execute-tool.ts b/apps/sim/lib/internal/salesforce/execute-tool.ts new file mode 100644 index 00000000000..7356c87a6c7 --- /dev/null +++ b/apps/sim/lib/internal/salesforce/execute-tool.ts @@ -0,0 +1,18 @@ +import { executeSalesforceUpdateCustomFieldOperation } from '@/lib/internal/salesforce/operations/update-custom-field' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeSalesforceTool: InternalToolOperationHandler = async (request) => { + switch (request.toolId) { + case 'salesforce_update_custom_field': + return executeToolOperationImplementation( + executeSalesforceUpdateCustomFieldOperation, + request + ) + default: + return Response.json( + { success: false, error: `Unsupported salesforce tool: ${request.toolId}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/internal/salesforce/operations/update-custom-field.test.ts b/apps/sim/lib/internal/salesforce/operations/update-custom-field.test.ts new file mode 100644 index 00000000000..e6d19cd9d48 --- /dev/null +++ b/apps/sim/lib/internal/salesforce/operations/update-custom-field.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeSalesforceUpdateCustomFieldOperation } from '@/lib/internal/salesforce/operations/update-custom-field' + +const PARAMS = { + accessToken: 'salesforce-token', + instanceUrl: 'https://example.my.salesforce.com', + fieldId: '00N000000000001', + label: 'Updated label', +} + +describe('salesforce update custom field operation', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('passes the execution signal to both Salesforce requests', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(Response.json({ Metadata: { type: 'Text', label: 'Old label' } })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + const controller = new AbortController() + + await executeSalesforceUpdateCustomFieldOperation(PARAMS as never, controller.signal) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal) + expect(fetchMock.mock.calls[1]?.[1]?.signal).toBe(controller.signal) + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ + Metadata: { type: 'Text', label: 'Updated label' }, + }) + }) + + it('does not patch after cancellation arrives during the metadata read', async () => { + const fetchMock = vi.mocked(fetch) + const controller = new AbortController() + const reason = new DOMException('cancelled', 'AbortError') + fetchMock.mockImplementationOnce(async () => { + controller.abort(reason) + return Response.json({ Metadata: { type: 'Text', label: 'Old label' } }) + }) + + await expect( + executeSalesforceUpdateCustomFieldOperation(PARAMS as never, controller.signal) + ).rejects.toBe(reason) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('fails before patching when a successful metadata read is malformed JSON', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response('not-json')) + + await expect(executeSalesforceUpdateCustomFieldOperation(PARAMS as never)).rejects.toThrow( + /malformed JSON/ + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('fails before patching when a successful read omits Metadata', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(Response.json({ Id: PARAMS.fieldId })) + + await expect(executeSalesforceUpdateCustomFieldOperation(PARAMS as never)).rejects.toThrow( + /no custom field metadata/ + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects a field ID that could escape the CustomField path before provider work', async () => { + const fetchMock = vi.mocked(fetch) + + await expect( + executeSalesforceUpdateCustomFieldOperation({ + ...PARAMS, + fieldId: '../CustomObject', + } as never) + ).rejects.toThrow('Field ID must be a 15- or 18-character Salesforce record ID') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/salesforce/operations/update-custom-field.ts b/apps/sim/lib/internal/salesforce/operations/update-custom-field.ts new file mode 100644 index 00000000000..8bb290ef3ba --- /dev/null +++ b/apps/sim/lib/internal/salesforce/operations/update-custom-field.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { + SalesforceUpdateCustomFieldParams, + SalesforceUpdateCustomFieldResponse, +} from '@/tools/salesforce/types' +import { + extractErrorMessage, + getInstanceUrl, + mergeCustomFieldMetadata, + requireId, +} from '@/tools/salesforce/utils' + +const logger = createLogger('SalesforceUpdateCustomField') +const SALESFORCE_RECORD_ID_PATTERN = /^[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$/ + +export const executeSalesforceUpdateCustomFieldOperation: InternalToolOperationImplementation< + SalesforceUpdateCustomFieldParams +> = async (params, signal): Promise => { + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + const fieldId = requireId(params.fieldId, 'Field ID') + if (!SALESFORCE_RECORD_ID_PATTERN.test(fieldId)) { + throw new Error('Field ID must be a 15- or 18-character Salesforce record ID') + } + const url = `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${encodeURIComponent(fieldId)}` + const headers = { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + + const readResponse = await fetch(url, { headers, signal }) + let existing: unknown + try { + existing = await readResponse.json() + } catch { + signal?.throwIfAborted() + if (readResponse.ok) { + throw new Error('Salesforce returned malformed JSON while loading custom field metadata') + } + existing = {} + } + if (!readResponse.ok) { + const errorMessage = extractErrorMessage( + existing, + readResponse.status, + 'Failed to load custom field for update' + ) + logger.error('Failed to read custom field metadata', { status: readResponse.status }) + throw new Error(errorMessage) + } + signal?.throwIfAborted() + if (!isRecordLike(existing) || !isRecordLike(existing.Metadata)) { + throw new Error('Salesforce returned no custom field metadata to update') + } + + const metadata = mergeCustomFieldMetadata(existing.Metadata, params) + + const patchResponse = await fetch(url, { + method: 'PATCH', + headers, + body: JSON.stringify({ Metadata: metadata }), + signal, + }) + if (!patchResponse.ok) { + const errorData = await patchResponse.json().catch(() => { + signal?.throwIfAborted() + return {} + }) + const errorMessage = extractErrorMessage( + errorData, + patchResponse.status, + 'Failed to update custom field in Salesforce' + ) + logger.error('Failed to update custom field', { status: patchResponse.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + id: fieldId, + updated: true, + }, + } +} diff --git a/apps/sim/lib/internal/slack/execute-tool.ts b/apps/sim/lib/internal/slack/execute-tool.ts index fe98dd27b29..d3b6362af75 100644 --- a/apps/sim/lib/internal/slack/execute-tool.ts +++ b/apps/sim/lib/internal/slack/execute-tool.ts @@ -24,6 +24,9 @@ import { executeSlackUpdateMessage, type SlackOperationContext, } from '@/lib/internal/slack/operations' +import { executeSlackGetChannelHistoryOperation } from '@/lib/internal/slack/operations/get-channel-history' +import { executeSlackGetThreadRepliesOperation } from '@/lib/internal/slack/operations/get-thread-replies' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input' import type { InternalToolOperationCall, @@ -87,6 +90,10 @@ export const executeSlackTool: InternalToolOperationHandler = async (request) => return executeOperation(slackDownloadContract, request, (input) => executeSlackDownload(input, request.signal) ) + case 'slack_get_channel_history': + return executeToolOperationImplementation(executeSlackGetChannelHistoryOperation, request) + case 'slack_get_thread_replies': + return executeToolOperationImplementation(executeSlackGetThreadRepliesOperation, request) case 'slack_ephemeral_message': return executeOperation(slackSendEphemeralContract, request, (input) => executeSlackSendEphemeral(input, request.signal) diff --git a/apps/sim/lib/internal/slack/operations.test.ts b/apps/sim/lib/internal/slack/operations.test.ts index 4eca2326476..7d97783f171 100644 --- a/apps/sim/lib/internal/slack/operations.test.ts +++ b/apps/sim/lib/internal/slack/operations.test.ts @@ -28,6 +28,8 @@ import { executeSlackSendMessage, executeSlackUpdateMessage, } from '@/lib/internal/slack/operations' +import { executeSlackGetChannelHistoryOperation } from '@/lib/internal/slack/operations/get-channel-history' +import { executeSlackGetThreadRepliesOperation } from '@/lib/internal/slack/operations/get-thread-replies' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' const originalFetch = global.fetch @@ -238,4 +240,38 @@ describe('Slack operations', () => { size: 3, }) }) + + it.each([ + ['channel history', executeSlackGetChannelHistoryOperation, { channel: 'C1' }], + ['thread replies', executeSlackGetThreadRepliesOperation, { channel: 'C1', threadTs: '1.0' }], + ] as const)('passes cancellation through paginated %s reads', async (_name, operation, input) => { + const controller = new AbortController() + vi.mocked(global.fetch).mockResolvedValueOnce( + slackResponse({ ok: true, messages: [], response_metadata: { next_cursor: '' } }) + ) + + await operation({ accessToken: 'token', ...input } as never, controller.signal) + + expect(vi.mocked(global.fetch).mock.calls[0]?.[1]).toMatchObject({ + signal: controller.signal, + }) + }) + + it('interrupts a paginated Slack rate-limit wait when cancelled', async () => { + const controller = new AbortController() + vi.mocked(global.fetch).mockResolvedValueOnce( + slackResponse({ ok: false, error: 'ratelimited' }, 429) + ) + + const result = executeSlackGetChannelHistoryOperation( + { accessToken: 'token', channel: 'C1' }, + controller.signal + ) + const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)) + controller.abort(new DOMException('cancelled', 'AbortError')) + + await rejection + expect(global.fetch).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/lib/internal/slack/operations/get-channel-history.ts b/apps/sim/lib/internal/slack/operations/get-channel-history.ts new file mode 100644 index 00000000000..15c73f035f2 --- /dev/null +++ b/apps/sim/lib/internal/slack/operations/get-channel-history.ts @@ -0,0 +1,40 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { DEFAULT_MAX_PAGES } from '@/tools/slack/get_channel_history' +import type { SlackGetChannelHistoryParams } from '@/tools/slack/types' +import { fetchSlackMessagesPaginated, resolvePositiveInt } from '@/tools/slack/utils' + +export const executeSlackGetChannelHistoryOperation: InternalToolOperationImplementation< + SlackGetChannelHistoryParams +> = async (params: SlackGetChannelHistoryParams, signal) => { + const token = params.accessToken || params.botToken + if (!token) { + throw new Error('Missing Slack credentials. Provide an OAuth connection or a bot token.') + } + + const result = await fetchSlackMessagesPaginated({ + token, + method: 'conversations.history', + baseParams: { + channel: params.channel, + oldest: params.oldest, + latest: params.latest, + inclusive: params.inclusive ? 'true' : undefined, + }, + limit: resolvePositiveInt(params.limit, 200), + cursor: params.cursor, + maxPages: resolvePositiveInt(params.maxPages, DEFAULT_MAX_PAGES), + missingScopeHint: 'channels:history, groups:history, im:history, mpim:history', + signal, + }) + + return { + success: true, + output: { + messages: result.messages, + count: result.messages.length, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + pages: result.pages, + }, + } +} diff --git a/apps/sim/lib/internal/slack/operations/get-thread-replies.ts b/apps/sim/lib/internal/slack/operations/get-thread-replies.ts new file mode 100644 index 00000000000..08ec41855cd --- /dev/null +++ b/apps/sim/lib/internal/slack/operations/get-thread-replies.ts @@ -0,0 +1,48 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import { DEFAULT_MAX_PAGES } from '@/tools/slack/get_thread_replies' +import type { SlackGetThreadRepliesParams } from '@/tools/slack/types' +import { fetchSlackMessagesPaginated, resolvePositiveInt } from '@/tools/slack/utils' + +export const executeSlackGetThreadRepliesOperation: InternalToolOperationImplementation< + SlackGetThreadRepliesParams +> = async (params: SlackGetThreadRepliesParams, signal) => { + const token = params.accessToken || params.botToken + if (!token) { + throw new Error('Missing Slack credentials. Provide an OAuth connection or a bot token.') + } + + const result = await fetchSlackMessagesPaginated({ + token, + method: 'conversations.replies', + baseParams: { + channel: params.channel, + ts: params.threadTs, + oldest: params.oldest, + latest: params.latest, + inclusive: params.inclusive ? 'true' : undefined, + }, + limit: resolvePositiveInt(params.limit, 200), + cursor: params.cursor, + maxPages: resolvePositiveInt(params.maxPages, DEFAULT_MAX_PAGES), + missingScopeHint: 'channels:history, groups:history, im:history, mpim:history', + signal, + }) + + const messages = result.messages + const threadTs = params.threadTs?.trim() + const parentMessage = messages.find((msg) => msg.ts === threadTs) ?? null + const replies = parentMessage ? messages.filter((msg) => msg !== parentMessage) : messages + + return { + success: true, + output: { + parentMessage, + replies, + messages, + replyCount: replies.length, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + pages: result.pages, + }, + } +} diff --git a/apps/sim/lib/internal/supabase/execute-tool.ts b/apps/sim/lib/internal/supabase/execute-tool.ts index 56a2c329abd..dae40d6b8e4 100644 --- a/apps/sim/lib/internal/supabase/execute-tool.ts +++ b/apps/sim/lib/internal/supabase/execute-tool.ts @@ -1,10 +1,19 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { executeSupabaseStorageUpload } from '@/lib/internal/supabase/operations' +import { executeStorageGetPublicUrlOperation } from '@/lib/internal/supabase/operations/storage-get-public-url' +import { executeStorageUpdateBucketOperation } from '@/lib/internal/supabase/operations/storage-update-bucket' import { supabaseStorageUploadInputSchema } from '@/lib/internal/supabase/schema' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' export const executeSupabaseTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() + if (request.toolId === 'supabase_storage_get_public_url') { + return executeToolOperationImplementation(executeStorageGetPublicUrlOperation, request) + } + if (request.toolId === 'supabase_storage_update_bucket') { + return executeToolOperationImplementation(executeStorageUpdateBucketOperation, request) + } if (request.toolId !== 'supabase_storage_upload') { return Response.json({ error: `Unsupported Supabase tool: ${request.toolId}` }, { status: 500 }) } diff --git a/apps/sim/lib/internal/supabase/operations/storage-get-public-url.ts b/apps/sim/lib/internal/supabase/operations/storage-get-public-url.ts new file mode 100644 index 00000000000..8f206ab0f4f --- /dev/null +++ b/apps/sim/lib/internal/supabase/operations/storage-get-public-url.ts @@ -0,0 +1,28 @@ +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { SupabaseStorageGetPublicUrlParams } from '@/tools/supabase/types' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' + +export const executeStorageGetPublicUrlOperation: InternalToolOperationImplementation< + SupabaseStorageGetPublicUrlParams +> = async (params: SupabaseStorageGetPublicUrlParams) => { + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` + + if (params.download) { + // Supabase's `download` query param is a filename override, not a + // boolean flag — an empty value forces a download while preserving + // the original filename. Sending the literal string "true" would + // instead rename the downloaded file to "true". + publicUrl += '?download=' + } + + return { + success: true, + output: { + message: 'Successfully generated public URL', + publicUrl, + }, + error: undefined, + } +} diff --git a/apps/sim/lib/internal/supabase/operations/storage-update-bucket.test.ts b/apps/sim/lib/internal/supabase/operations/storage-update-bucket.test.ts new file mode 100644 index 00000000000..a84af6dbe45 --- /dev/null +++ b/apps/sim/lib/internal/supabase/operations/storage-update-bucket.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeStorageUpdateBucketOperation } from '@/lib/internal/supabase/operations/storage-update-bucket' + +const INPUT = { + apiKey: 'service-role-key', + projectId: 'projectref', + bucket: 'documents', +} + +describe('executeStorageUpdateBucketOperation', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sends only explicitly changed fields in one non-redirecting update request', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValueOnce(Response.json({ message: 'Successfully updated' })) + + await executeStorageUpdateBucketOperation({ ...INPUT, isPublic: true }, controller.signal) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://projectref.supabase.co/storage/v1/bucket/documents', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ public: true }), + redirect: 'error', + signal: controller.signal, + }) + ) + }) + + it('treats whitespace-only file limits as omitted without reading the bucket', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ message: 'Successfully updated' })) + + await executeStorageUpdateBucketOperation({ + ...INPUT, + isPublic: false, + fileSizeLimit: ' ' as never, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) + expect(payload).toEqual({ public: false }) + }) + + it('rejects a nonnumeric file limit before updating the bucket', async () => { + const result = await executeStorageUpdateBucketOperation({ + ...INPUT, + fileSizeLimit: 'not-a-number' as never, + }) + + expect(result).toMatchObject({ + success: false, + error: 'File size limit must be a finite number', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([true, [], {}, '0x100'])('rejects a non-decimal file limit %j', async (fileSizeLimit) => { + const result = await executeStorageUpdateBucketOperation({ + ...INPUT, + fileSizeLimit: fileSizeLimit as never, + }) + + expect(result).toMatchObject({ + success: false, + error: 'File size limit must be a finite number', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('preserves a no-op update while still verifying access to the bucket', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ id: 'documents' })) + + const result = await executeStorageUpdateBucketOperation({ + ...INPUT, + fileSizeLimit: ' ' as never, + }) + + expect(result).toEqual({ + success: true, + output: { + message: 'Successfully updated storage bucket', + results: { message: 'Successfully updated' }, + }, + error: undefined, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://projectref.supabase.co/storage/v1/bucket/documents', + expect.objectContaining({ method: 'GET', redirect: 'error' }) + ) + }) + + it('propagates cancellation instead of returning a failed tool envelope', async () => { + const controller = new AbortController() + fetchMock.mockImplementationOnce(async (_url, init) => { + controller.abort(new DOMException('cancelled', 'AbortError')) + throw (init?.signal as AbortSignal).reason + }) + + await expect( + executeStorageUpdateBucketOperation({ ...INPUT, isPublic: true }, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('returns a structured failure for an invalid project reference', async () => { + const result = await executeStorageUpdateBucketOperation({ + ...INPUT, + projectId: '../invalid', + }) + + expect(result).toMatchObject({ + success: false, + output: { message: 'Failed to update storage bucket', results: {} }, + error: expect.any(String), + }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/supabase/operations/storage-update-bucket.ts b/apps/sim/lib/internal/supabase/operations/storage-update-bucket.ts new file mode 100644 index 00000000000..f2f6734d8d4 --- /dev/null +++ b/apps/sim/lib/internal/supabase/operations/storage-update-bucket.ts @@ -0,0 +1,103 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { filterUndefined } from '@sim/utils/object' +import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' +import type { + SupabaseStorageUpdateBucketParams, + SupabaseStorageUpdateBucketResponse, +} from '@/tools/supabase/types' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' + +export const executeStorageUpdateBucketOperation: InternalToolOperationImplementation< + SupabaseStorageUpdateBucketParams +> = async ( + params: SupabaseStorageUpdateBucketParams, + signal +): Promise => { + try { + const baseUrl = supabaseBaseUrl(params.projectId) + const bucket = encodeStorageSegment(params.bucket) + const headers = { + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + } + const hasValue = (value: unknown): boolean => + value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== '') + const rawFileSizeLimit: unknown = params.fileSizeLimit + const fileSizeLimit = hasValue(rawFileSizeLimit) + ? typeof rawFileSizeLimit === 'number' + ? rawFileSizeLimit + : typeof rawFileSizeLimit === 'string' && + /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(rawFileSizeLimit.trim()) + ? Number(rawFileSizeLimit) + : Number.NaN + : undefined + if (fileSizeLimit !== undefined && !Number.isFinite(fileSizeLimit)) { + throw new Error('File size limit must be a finite number') + } + + const payload = filterUndefined({ + public: hasValue(params.isPublic) ? params.isPublic : undefined, + file_size_limit: fileSizeLimit, + allowed_mime_types: hasValue(params.allowedMimeTypes) ? params.allowedMimeTypes : undefined, + }) + + if (Object.keys(payload).length === 0) { + const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'GET', + headers, + redirect: 'error', + signal, + }) + if (!currentResponse.ok) { + const errorText = await currentResponse.text() + throw new Error(`Failed to read current bucket configuration: ${errorText}`) + } + await currentResponse.body?.cancel() + signal?.throwIfAborted() + return { + success: true, + output: { + message: 'Successfully updated storage bucket', + results: { message: 'Successfully updated' }, + }, + error: undefined, + } + } + + const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'PUT', + headers, + body: JSON.stringify(payload), + redirect: 'error', + signal, + }) + + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + throw new Error(`Failed to update bucket: ${errorText}`) + } + + const data = await updateResponse.json() + signal?.throwIfAborted() + + return { + success: true, + output: { + message: 'Successfully updated storage bucket', + results: data, + }, + error: undefined, + } + } catch (error) { + signal?.throwIfAborted() + return { + success: false, + output: { + message: 'Failed to update storage bucket', + results: {}, + }, + error: getErrorMessage(error, 'Unknown error occurred'), + } + } +} diff --git a/apps/sim/lib/internal/tool-operations/execute.test.ts b/apps/sim/lib/internal/tool-operations/execute.test.ts new file mode 100644 index 00000000000..0bfb5c3ea79 --- /dev/null +++ b/apps/sim/lib/internal/tool-operations/execute.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +function operationCall(input: unknown, signal?: AbortSignal): InternalToolOperationCall { + return { + toolId: 'example_operation', + input, + headers: new Headers(), + context: { workflowId: 'workflow-1', workspaceId: 'workspace-1' }, + requestId: 'request-1', + ...(signal ? { signal } : {}), + } +} + +describe('executeToolOperationImplementation', () => { + it.each([undefined, null, [], 'value'])( + 'rejects non-object semantic input before execution: %j', + async (input) => { + const operation = vi.fn() + + const response = await executeToolOperationImplementation(operation, operationCall(input)) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Invalid operation input', + }) + expect(operation).not.toHaveBeenCalled() + } + ) + + it('forwards semantic input, cancellation, and trusted context without HTTP metadata', async () => { + const controller = new AbortController() + const call = operationCall({ value: 42 }, controller.signal) + const operation = vi.fn().mockResolvedValue({ + success: true, + output: { value: 42 }, + }) + + const response = await executeToolOperationImplementation(operation, call) + + expect(operation).toHaveBeenCalledWith({ value: 42 }, controller.signal, call.context) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { value: 42 }, + }) + }) + + it('preserves a structured tool failure response', async () => { + const response = await executeToolOperationImplementation( + async () => ({ + success: false, + output: { accepted: false }, + error: 'Provider rejected the operation', + retryable: false, + }), + operationCall({ value: 42 }) + ) + + await expect(response.json()).resolves.toEqual({ + success: false, + output: { accepted: false }, + error: 'Provider rejected the operation', + retryable: false, + }) + }) + + it('does not report cancellation after a mutation has committed', async () => { + const controller = new AbortController() + const response = await executeToolOperationImplementation( + async () => { + controller.abort(new Error('late cancellation')) + return { success: true, output: { committed: true } } + }, + operationCall({ value: 42 }, controller.signal) + ) + + await expect(response.json()).resolves.toEqual({ + success: true, + output: { committed: true }, + }) + }) +}) diff --git a/apps/sim/lib/internal/tool-operations/execute.ts b/apps/sim/lib/internal/tool-operations/execute.ts new file mode 100644 index 00000000000..bbc7693fa50 --- /dev/null +++ b/apps/sim/lib/internal/tool-operations/execute.ts @@ -0,0 +1,18 @@ +import type { + InternalToolOperationCall, + InternalToolOperationImplementation, +} from '@/lib/internal/tool-operations/types' + +/** Executes one typed operation implementation behind a registered tool handler. */ +export async function executeToolOperationImplementation( + operation: InternalToolOperationImplementation, + request: InternalToolOperationCall +): Promise { + if (!request.input || typeof request.input !== 'object' || Array.isArray(request.input)) { + return Response.json({ success: false, error: 'Invalid operation input' }, { status: 400 }) + } + + request.signal?.throwIfAborted() + const result = await operation(request.input as Input, request.signal, request.context) + return Response.json(result) +} diff --git a/apps/sim/lib/internal/tool-operations/registry.server.ts b/apps/sim/lib/internal/tool-operations/registry.server.ts index 55f825b0310..1b6dcfba17f 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.ts @@ -643,10 +643,101 @@ const DOCUSIGN_TOOL_IDS = [ const THINKING_TOOL_IDS = ['thinking_tool'] as const +const BITBUCKET_TOOL_IDS = [ + 'bitbucket_get_file', + 'bitbucket_get_pipeline_step_log', + 'bitbucket_get_pull_request_diff', + 'bitbucket_get_pull_request_diffstat', +] as const + +const BROWSER_USE_TOOL_IDS = ['browser_use_run_task'] as const + +const CBINSIGHTS_TOOL_IDS = [ + 'cbinsights_chat', + 'cbinsights_get_commercial_maturity_history', + 'cbinsights_get_exit_probability_history', + 'cbinsights_get_mosaic_history', + 'cbinsights_get_org_business_relationships', + 'cbinsights_get_org_funding_window', + 'cbinsights_get_org_fundings', + 'cbinsights_get_org_investments', + 'cbinsights_get_org_management_and_board', + 'cbinsights_get_org_outlook', + 'cbinsights_get_org_portfolio_exits', + 'cbinsights_get_org_revenue', + 'cbinsights_get_scouting_report', + 'cbinsights_get_strategy_map', + 'cbinsights_list_business_relationships', + 'cbinsights_list_funding_window', + 'cbinsights_list_fundings', + 'cbinsights_list_investments', + 'cbinsights_list_management_and_board', + 'cbinsights_list_outlook', + 'cbinsights_list_portfolio_exits', + 'cbinsights_list_revenue', + 'cbinsights_lookup_organizations', + 'cbinsights_rag', + 'cbinsights_search_firmographics', +] as const + +const CLOUDFLARE_TOOL_IDS = ['cloudflare_get_zone_settings'] as const +const DATADOG_TOOL_IDS = ['datadog_update_slo'] as const + +const MANAGED_AGENT_TOOL_IDS = [ + 'managed_agent_archive_session', + 'managed_agent_create_session', + 'managed_agent_delete_session', + 'managed_agent_get_session', + 'managed_agent_interrupt_session', + 'managed_agent_list_events', + 'managed_agent_respond_custom_tool', + 'managed_agent_respond_tool_confirmation', + 'managed_agent_run_session', + 'managed_agent_send_message', + 'managed_agent_update_session', +] as const + +const MICROSOFT_AD_TOOL_IDS = ['microsoft_ad_add_user_app_role_assignment'] as const + +const NETSUITE_TOOL_IDS = [ + 'netsuite_attach_record', + 'netsuite_batch_create_records', + 'netsuite_batch_delete_records', + 'netsuite_batch_get_records', + 'netsuite_batch_update_records', + 'netsuite_batch_upsert_records', + 'netsuite_create_record', + 'netsuite_delete_record', + 'netsuite_detach_record', + 'netsuite_execute_action', + 'netsuite_execute_dataset', + 'netsuite_execute_suiteql', + 'netsuite_get_async_result', + 'netsuite_get_async_status', + 'netsuite_get_governance_limits', + 'netsuite_get_record', + 'netsuite_get_record_form', + 'netsuite_get_record_metadata', + 'netsuite_get_select_options', + 'netsuite_get_server_time', + 'netsuite_get_subresource', + 'netsuite_list_datasets', + 'netsuite_list_record_types', + 'netsuite_list_records', + 'netsuite_transform_record', + 'netsuite_update_record', + 'netsuite_upsert_record', +] as const + +const OKTA_TOOL_IDS = ['okta_update_group'] as const +const SALESFORCE_TOOL_IDS = ['salesforce_update_custom_field'] as const + const SLACK_TOOL_IDS = [ 'slack_add_reaction', 'slack_delete_message', 'slack_download', + 'slack_get_channel_history', + 'slack_get_thread_replies', 'slack_ephemeral_message', 'slack_message', 'slack_message_reader', @@ -791,6 +882,7 @@ const GOOGLE_VAULT_TOOL_IDS = ['google_vault_download_export_file'] as const const GOOGLE_DRIVE_TOOL_IDS = [ 'google_drive_download', 'google_drive_export', + 'google_drive_move', 'google_drive_upload', ] as const @@ -798,7 +890,12 @@ const STAGEHAND_TOOL_IDS = ['stagehand_agent', 'stagehand_extract'] as const const VISION_TOOL_IDS = ['vision_tool', 'vision_tool_v2'] as const -const GITHUB_TOOL_IDS = ['github_latest_commit', 'github_latest_commit_v2'] as const +const GITHUB_TOOL_IDS = [ + 'github_comment', + 'github_comment_v2', + 'github_latest_commit', + 'github_latest_commit_v2', +] as const const TWILIO_VOICE_TOOL_IDS = ['twilio_voice_get_recording'] as const @@ -1006,7 +1103,11 @@ const DROPBOX_TOOL_IDS = ['dropbox_upload'] as const const FIREFLIES_TOOL_IDS = ['fireflies_upload_audio'] as const -const SUPABASE_TOOL_IDS = ['supabase_storage_upload'] as const +const SUPABASE_TOOL_IDS = [ + 'supabase_storage_get_public_url', + 'supabase_storage_update_bucket', + 'supabase_storage_upload', +] as const const SQUARE_TOOL_IDS = ['square_create_catalog_image'] as const @@ -1190,6 +1291,36 @@ registerFamily(handlerLoaders, DOCUSIGN_TOOL_IDS, async () => { registerFamily(handlerLoaders, THINKING_TOOL_IDS, async () => { return (await import('@/lib/internal/thinking/execute-tool')).executeThinkingTool }) +registerFamily(handlerLoaders, BITBUCKET_TOOL_IDS, async () => { + return (await import('@/lib/internal/bitbucket/execute-tool')).executeBitbucketTool +}) +registerFamily(handlerLoaders, BROWSER_USE_TOOL_IDS, async () => { + return (await import('@/lib/internal/browser-use/execute-tool')).executeBrowserUseTool +}) +registerFamily(handlerLoaders, CBINSIGHTS_TOOL_IDS, async () => { + return (await import('@/lib/internal/cbinsights/execute-tool')).executeCbinsightsTool +}) +registerFamily(handlerLoaders, CLOUDFLARE_TOOL_IDS, async () => { + return (await import('@/lib/internal/cloudflare/execute-tool')).executeCloudflareTool +}) +registerFamily(handlerLoaders, DATADOG_TOOL_IDS, async () => { + return (await import('@/lib/internal/datadog/execute-tool')).executeDatadogTool +}) +registerFamily(handlerLoaders, MANAGED_AGENT_TOOL_IDS, async () => { + return (await import('@/lib/internal/managed-agent/execute-tool')).executeManagedAgentTool +}) +registerFamily(handlerLoaders, MICROSOFT_AD_TOOL_IDS, async () => { + return (await import('@/lib/internal/microsoft-ad/execute-tool')).executeMicrosoftAdTool +}) +registerFamily(handlerLoaders, NETSUITE_TOOL_IDS, async () => { + return (await import('@/lib/internal/netsuite/execute-tool')).executeNetsuiteTool +}) +registerFamily(handlerLoaders, OKTA_TOOL_IDS, async () => { + return (await import('@/lib/internal/okta/execute-tool')).executeOktaTool +}) +registerFamily(handlerLoaders, SALESFORCE_TOOL_IDS, async () => { + return (await import('@/lib/internal/salesforce/execute-tool')).executeSalesforceTool +}) registerFamily(handlerLoaders, SLACK_TOOL_IDS, async () => { return (await import('@/lib/internal/slack/execute-tool')).executeSlackTool }) diff --git a/apps/sim/lib/internal/tool-operations/types.ts b/apps/sim/lib/internal/tool-operations/types.ts index 635172c66b5..bac7f82926b 100644 --- a/apps/sim/lib/internal/tool-operations/types.ts +++ b/apps/sim/lib/internal/tool-operations/types.ts @@ -1,6 +1,14 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import type { ToolResponse } from '@/tools/types' + +/** Typed implementation used by a registered internal tool operation handler. */ +export type InternalToolOperationImplementation

= ( + params: P, + signal?: AbortSignal, + context?: InternalToolOperationContext +) => Promise | ToolResponse /** Trusted runtime scope shared by every in-process tool operation. */ export interface InternalToolOperationContext { diff --git a/apps/sim/tools/bitbucket/get_file.ts b/apps/sim/tools/bitbucket/get_file.ts index 72bef8e16e7..fedb87de7c0 100644 --- a/apps/sim/tools/bitbucket/get_file.ts +++ b/apps/sim/tools/bitbucket/get_file.ts @@ -1,24 +1,16 @@ import type { BitbucketGetFileParams, BitbucketToolResponse } from '@/tools/bitbucket/types' import { - assertBitbucketResponseOk, BITBUCKET_API_BASE, BITBUCKET_DEFAULT_MAX_CHARACTERS, BITBUCKET_ERROR_EXTRACTOR, - BITBUCKET_RAW_TRANSFER_MAX_BYTES, - BITBUCKET_READ_RETRY, BITBUCKET_REPOSITORY_PARAMS, - bitbucketHeaders, - bitbucketHeadRange, - bitbucketJson, - bitbucketMaxCharacters, - bitbucketRawHead, bitbucketRepositoryPath, encodeBitbucketRepositoryPath, encodeBitbucketSegment, - normalizeBitbucketFileMetadata, } from '@/tools/bitbucket/utils' import { requireBitbucketSha1 } from '@/tools/bitbucket/validation' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' interface BitbucketFileOutput { content: string | null @@ -29,13 +21,13 @@ interface BitbucketFileOutput { contentType: string | null } -function fileUrl(params: BitbucketGetFileParams, metadata = false): string { +export function fileUrl(params: BitbucketGetFileParams, metadata = false): string { const commit = requireBitbucketSha1(params.commit, 'commit') const url = `${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/src/${encodeBitbucketSegment(commit, 'commit')}/${encodeBitbucketRepositoryPath(params.path)}` return metadata ? `${url}?format=meta` : url } -export const bitbucketGetFileTool: ToolConfig< +export const bitbucketGetFileTool: InternalToolConfig< BitbucketGetFileParams, BitbucketToolResponse > = { @@ -66,68 +58,8 @@ export const bitbucketGetFileTool: ToolConfig< default: BITBUCKET_DEFAULT_MAX_CHARACTERS, }, }, - directExecution: async (params, signal) => { - bitbucketMaxCharacters(params.maxCharacters) - const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server') - const metadataResponse = await secureBitbucketRead( - fileUrl(params, true), - bitbucketHeaders(params.accessToken), - 256 * 1024, - { signal } - ) - await assertBitbucketResponseOk(metadataResponse) - const metadata = normalizeBitbucketFileMetadata(await bitbucketJson(metadataResponse)) - if (metadata.isBinary === true) { - return { - success: true, - output: { - content: null, - binary: true, - truncated: metadata.size === null ? null : metadata.size > 0, - returnedBytes: 0, - fullBytes: metadata.size, - contentType: null, - }, - } - } - - const rawResponse = await secureBitbucketRead( - fileUrl(params), - bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketHeadRange(params.maxCharacters), - }), - BITBUCKET_RAW_TRANSFER_MAX_BYTES, - { stripAuthOnRedirect: true, signal } - ) - await assertBitbucketResponseOk(rawResponse) - const raw = await bitbucketRawHead(rawResponse, params.maxCharacters, metadata.isBinary) - const fullBytes = raw.fullBytes ?? metadata.size - return { - success: true, - output: { - ...raw, - truncated: - raw.binary === true && raw.truncated === null && fullBytes !== null - ? fullBytes > 0 - : raw.truncated, - fullBytes, - }, - } - }, - request: { - url: (params) => fileUrl(params), - method: 'GET', - headers: (params) => - bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketHeadRange(params.maxCharacters), - }), - retry: BITBUCKET_READ_RETRY, - stripAuthOnRedirect: true, - }, - transformResponse: async () => { - throw new Error('Bitbucket file reads require the metadata preflight direct execution path') + operation: { + input: createInternalToolOperationInput, }, outputs: { content: { diff --git a/apps/sim/tools/bitbucket/get_pipeline_step_log.ts b/apps/sim/tools/bitbucket/get_pipeline_step_log.ts index c83aff9adfe..67f1451a608 100644 --- a/apps/sim/tools/bitbucket/get_pipeline_step_log.ts +++ b/apps/sim/tools/bitbucket/get_pipeline_step_log.ts @@ -3,21 +3,15 @@ import type { BitbucketToolResponse, } from '@/tools/bitbucket/types' import { - assertBitbucketResponseOk, BITBUCKET_API_BASE, BITBUCKET_DEFAULT_LOG_CHARACTERS, BITBUCKET_ERROR_EXTRACTOR, - BITBUCKET_LOG_TRANSFER_MAX_BYTES, - BITBUCKET_READ_RETRY, BITBUCKET_REPOSITORY_PARAMS, - bitbucketHeaders, - bitbucketMaxCharacters, - bitbucketRawTail, bitbucketRepositoryPath, - bitbucketTailRange, encodeBitbucketSegment, } from '@/tools/bitbucket/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' interface BitbucketPipelineLogOutput { log: string @@ -26,15 +20,15 @@ interface BitbucketPipelineLogOutput { } /** A suffix range against an empty log is unsatisfiable; Bitbucket answers 416 rather than 200. */ -const BITBUCKET_RANGE_NOT_SATISFIABLE = 416 +export const BITBUCKET_RANGE_NOT_SATISFIABLE = 416 /** RFC 7233 unsatisfied-range header for a zero-length log; any other 416 is a genuine failure. */ -const EMPTY_CONTENT_RANGE_PATTERN = /^bytes \*\/0$/ +export const EMPTY_CONTENT_RANGE_PATTERN = /^bytes \*\/0$/ -function stepLogUrl(params: BitbucketGetPipelineStepLogParams): string { +export function stepLogUrl(params: BitbucketGetPipelineStepLogParams): string { return `${BITBUCKET_API_BASE}${bitbucketRepositoryPath(params.workspaceSlug, params.repoSlug)}/pipelines/${encodeBitbucketSegment(params.pipelineUuid, 'pipelineUuid')}/steps/${encodeBitbucketSegment(params.stepUuid, 'stepUuid')}/log` } -export const bitbucketGetPipelineStepLogTool: ToolConfig< +export const bitbucketGetPipelineStepLogTool: InternalToolConfig< BitbucketGetPipelineStepLogParams, BitbucketToolResponse > = { @@ -65,41 +59,8 @@ export const bitbucketGetPipelineStepLogTool: ToolConfig< default: BITBUCKET_DEFAULT_LOG_CHARACTERS, }, }, - directExecution: async (params, signal) => { - bitbucketMaxCharacters(params.maxCharacters, true) - const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server') - const response = await secureBitbucketRead( - stepLogUrl(params), - bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketTailRange(params.maxCharacters), - }), - BITBUCKET_LOG_TRANSFER_MAX_BYTES, - { stripAuthOnRedirect: true, signal } - ) - if ( - response.status === BITBUCKET_RANGE_NOT_SATISFIABLE && - EMPTY_CONTENT_RANGE_PATTERN.test(response.headers.get('content-range') ?? '') - ) { - await response.body?.cancel() - return { success: true, output: { log: '', truncated: false, totalBytes: 0 } } - } - await assertBitbucketResponseOk(response) - return { success: true, output: await bitbucketRawTail(response, params.maxCharacters) } - }, - request: { - url: stepLogUrl, - method: 'GET', - headers: (params) => - bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketTailRange(params.maxCharacters), - }), - retry: BITBUCKET_READ_RETRY, - stripAuthOnRedirect: true, - }, - transformResponse: async () => { - throw new Error('Bitbucket step-log reads require the byte-capped direct execution path') + operation: { + input: createInternalToolOperationInput, }, outputs: { log: { type: 'string', description: 'Bounded trailing UTF-8 log text' }, diff --git a/apps/sim/tools/bitbucket/get_pull_request_diff.ts b/apps/sim/tools/bitbucket/get_pull_request_diff.ts index 5aee9943a51..779e1f966ad 100644 --- a/apps/sim/tools/bitbucket/get_pull_request_diff.ts +++ b/apps/sim/tools/bitbucket/get_pull_request_diff.ts @@ -3,20 +3,16 @@ import type { BitbucketToolResponse, } from '@/tools/bitbucket/types' import { - assertBitbucketResponseOk, BITBUCKET_API_BASE, BITBUCKET_DEFAULT_MAX_CHARACTERS, BITBUCKET_ERROR_EXTRACTOR, BITBUCKET_PULL_REQUEST_PARAMS, - BITBUCKET_RAW_TRANSFER_MAX_BYTES, - BITBUCKET_READ_RETRY, - bitbucketHeaders, - bitbucketHeadRange, bitbucketPullRequestPath, bitbucketRawHead, bitbucketRepositoryPathQuery, } from '@/tools/bitbucket/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' interface BitbucketDiffOutput { diff: string @@ -26,12 +22,12 @@ interface BitbucketDiffOutput { fullBytes: number | null } -function pullRequestDiffUrl(params: BitbucketGetPullRequestDiffParams): string { +export function pullRequestDiffUrl(params: BitbucketGetPullRequestDiffParams): string { bitbucketRepositoryPathQuery(params.path) return `${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/diff` } -async function transformDiff( +export async function transformDiff( response: Response, maxCharacters: number | undefined ): Promise> { @@ -50,7 +46,7 @@ async function transformDiff( } } -export const bitbucketGetPullRequestDiffTool: ToolConfig< +export const bitbucketGetPullRequestDiffTool: InternalToolConfig< BitbucketGetPullRequestDiffParams, BitbucketToolResponse > = { @@ -79,39 +75,9 @@ export const bitbucketGetPullRequestDiffTool: ToolConfig< default: BITBUCKET_DEFAULT_MAX_CHARACTERS, }, }, - directExecution: async (params, signal) => { - const { secureBitbucketPullRequestRedirect } = await import('@/tools/bitbucket/utils.server') - const headers = bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketHeadRange(params.maxCharacters), - }) - const response = await secureBitbucketPullRequestRedirect( - pullRequestDiffUrl(params), - params.workspaceSlug, - params.repoSlug, - 'diff', - headers, - BITBUCKET_RAW_TRANSFER_MAX_BYTES, - { - signal, - targetQuery: { path: bitbucketRepositoryPathQuery(params.path), binary: 'false' }, - } - ) - await assertBitbucketResponseOk(response) - return transformDiff(response, params.maxCharacters) + operation: { + input: createInternalToolOperationInput, }, - request: { - url: pullRequestDiffUrl, - method: 'GET', - headers: (params) => - bitbucketHeaders(params.accessToken, { - json: false, - range: bitbucketHeadRange(params.maxCharacters), - }), - retry: BITBUCKET_READ_RETRY, - stripAuthOnRedirect: true, - }, - transformResponse: async (response, params) => transformDiff(response, params?.maxCharacters), outputs: { diff: { type: 'string', description: 'Bounded unified diff text decoded as UTF-8' }, decodingLossy: { diff --git a/apps/sim/tools/bitbucket/get_pull_request_diffstat.ts b/apps/sim/tools/bitbucket/get_pull_request_diffstat.ts index 4bb9c822c20..27016c182b4 100644 --- a/apps/sim/tools/bitbucket/get_pull_request_diffstat.ts +++ b/apps/sim/tools/bitbucket/get_pull_request_diffstat.ts @@ -7,37 +7,30 @@ import { type BitbucketToolResponse, } from '@/tools/bitbucket/types' import { - assertBitbucketResponseOk, BITBUCKET_API_BASE, BITBUCKET_ERROR_EXTRACTOR, BITBUCKET_PAGINATION_PARAMS, BITBUCKET_PULL_REQUEST_PARAMS, - BITBUCKET_READ_RETRY, - bitbucketHeaders, - bitbucketJson, - bitbucketPageLength, bitbucketPullRequestPath, - normalizeBitbucketDiffstat, - normalizeBitbucketPage, - validateBitbucketPullRequestRedirect, } from '@/tools/bitbucket/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -function pullRequestDiffstatUrl(params: BitbucketPaginatedPullRequestParams): string { +export function pullRequestDiffstatUrl(params: BitbucketPaginatedPullRequestParams): string { const url = new URL( `${BITBUCKET_API_BASE}${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/diffstat` ) return url.toString() } -function decodedPathname(url: string): string { +export function decodedPathname(url: string): string { return new URL(url).pathname .split('/') .map((segment) => decodeURIComponent(segment)) .join('/') } -export const bitbucketGetPullRequestDiffstatTool: ToolConfig< +export const bitbucketGetPullRequestDiffstatTool: InternalToolConfig< BitbucketPaginatedPullRequestParams, BitbucketToolResponse> > = { @@ -51,76 +44,9 @@ export const bitbucketGetPullRequestDiffstatTool: ToolConfig< requiredScopes: ['pullrequest', 'repository'], }, params: { ...BITBUCKET_PULL_REQUEST_PARAMS, ...BITBUCKET_PAGINATION_PARAMS }, - directExecution: async (params, signal) => { - const { - resolveBitbucketPullRequestRedirect, - secureBitbucketPullRequestRedirect, - secureBitbucketRead, - } = await import('@/tools/bitbucket/utils.server') - const initialUrl = pullRequestDiffstatUrl(params) - const headers = bitbucketHeaders(params.accessToken) - let response: Response - if (params.nextUrl !== undefined) { - const continuation = validateBitbucketPullRequestRedirect( - params.nextUrl, - params.workspaceSlug, - params.repoSlug, - 'diffstat' - ) - const resolvedTarget = await resolveBitbucketPullRequestRedirect( - initialUrl, - params.workspaceSlug, - params.repoSlug, - 'diffstat', - headers, - { signal } - ) - if (decodedPathname(continuation) !== decodedPathname(resolvedTarget)) { - throw new Error('nextUrl does not belong to this Bitbucket pull request diffstat') - } - response = await secureBitbucketRead(continuation, headers, 2 * 1024 * 1024, { - maxRedirects: 0, - signal, - }) - } else { - response = await secureBitbucketPullRequestRedirect( - initialUrl, - params.workspaceSlug, - params.repoSlug, - 'diffstat', - headers, - 2 * 1024 * 1024, - { - signal, - targetQuery: { pagelen: String(bitbucketPageLength(params.pageLen)) }, - } - ) - } - await assertBitbucketResponseOk(response) - return { - success: true, - output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat), - } + operation: { + input: createInternalToolOperationInput, }, - request: { - url: (params) => - params.nextUrl - ? validateBitbucketPullRequestRedirect( - params.nextUrl, - params.workspaceSlug, - params.repoSlug, - 'diffstat' - ) - : pullRequestDiffstatUrl(params), - method: 'GET', - headers: (params) => bitbucketHeaders(params.accessToken), - retry: BITBUCKET_READ_RETRY, - stripAuthOnRedirect: true, - }, - transformResponse: async (response) => ({ - success: true, - output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat), - }), outputs: { items: { type: 'array', diff --git a/apps/sim/tools/bitbucket/pipelines.test.ts b/apps/sim/tools/bitbucket/pipelines.test.ts index 3108ef92c27..844d57b189c 100644 --- a/apps/sim/tools/bitbucket/pipelines.test.ts +++ b/apps/sim/tools/bitbucket/pipelines.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeBitbucketGetPipelineStepLogOperation } from '@/lib/internal/bitbucket/operations/get-pipeline-step-log' import { bitbucketGetPipelineTool } from '@/tools/bitbucket/get_pipeline' -import { bitbucketGetPipelineStepLogTool } from '@/tools/bitbucket/get_pipeline_step_log' import { bitbucketListPipelineStepsTool } from '@/tools/bitbucket/list_pipeline_steps' import { bitbucketListPipelinesTool } from '@/tools/bitbucket/list_pipelines' import { bitbucketStopPipelineTool } from '@/tools/bitbucket/stop_pipeline' @@ -41,7 +41,7 @@ async function runStepLog( params: BitbucketGetPipelineStepLogParams ): Promise<{ output: { log: string; truncated: boolean; totalBytes: number | null } }> { serverMocks.secureBitbucketRead.mockResolvedValueOnce(response) - return (await bitbucketGetPipelineStepLogTool.directExecution!(params)) as { + return (await executeBitbucketGetPipelineStepLogOperation(params)) as { output: { log: string; truncated: boolean; totalBytes: number | null } } } @@ -201,7 +201,7 @@ describe('Bitbucket pipeline request builders', () => { ).toThrow(/does not belong/) }) - it('encodes pipeline and step UUID path segments', () => { + it('encodes pipeline and step UUID path segments', async () => { const pipelineParams = { ...REPOSITORY_PARAMS, pipelineUuid: '{pipeline/one ?#}', @@ -212,13 +212,15 @@ describe('Bitbucket pipeline request builders', () => { expect(requestUrl(bitbucketStopPipelineTool, pipelineParams)).toBe( 'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D/stopPipeline' ) - expect( - requestUrl(bitbucketGetPipelineStepLogTool, { - ...pipelineParams, - stepUuid: '{step/one ?#}', - } satisfies BitbucketGetPipelineStepLogParams) - ).toBe( - 'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D/steps/%7Bstep%2Fone%20%3F%23%7D/log' + await runStepLog(new Response(''), { + ...pipelineParams, + stepUuid: '{step/one ?#}', + } satisfies BitbucketGetPipelineStepLogParams) + expect(serverMocks.secureBitbucketRead).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pipelines/%7Bpipeline%2Fone%20%3F%23%7D/steps/%7Bstep%2Fone%20%3F%23%7D/log', + expect.any(Object), + expect.any(Number), + expect.any(Object) ) }) @@ -415,27 +417,27 @@ describe('Bitbucket pipeline response normalization', () => { }) describe('Bitbucket pipeline step logs', () => { - it('requests a bounded byte tail and drops authorization on redirects', () => { + it('requests a bounded byte tail and drops authorization on redirects', async () => { const params = { ...REPOSITORY_PARAMS, pipelineUuid: '{pipeline-1}', stepUuid: '{step-1}', maxCharacters: 4_096, } satisfies BitbucketGetPipelineStepLogParams - expect(bitbucketGetPipelineStepLogTool.request.headers(params)).toMatchObject({ - Accept: '*/*', - Authorization: 'Bearer oauth-token', - Range: 'bytes=-16384', - }) - expect(bitbucketGetPipelineStepLogTool.request.stripAuthOnRedirect).toBe(true) - expect(bitbucketGetPipelineStepLogTool.request.retry).toMatchObject({ - enabled: true, - maxRetries: 2, - retryIdempotentOnly: true, - }) + await runStepLog(new Response(''), params) + expect(serverMocks.secureBitbucketRead).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Accept: '*/*', + Authorization: 'Bearer oauth-token', + Range: 'bytes=-16384', + }), + expect.any(Number), + expect.objectContaining({ stripAuthOnRedirect: true }) + ) }) - it('overfetches a provider-compatible minimum for small log tails', () => { + it('overfetches a provider-compatible minimum for small log tails', async () => { const params = { ...REPOSITORY_PARAMS, pipelineUuid: '{pipeline-1}', @@ -443,9 +445,13 @@ describe('Bitbucket pipeline step logs', () => { maxCharacters: 100, } satisfies BitbucketGetPipelineStepLogParams - expect(bitbucketGetPipelineStepLogTool.request.headers(params)).toMatchObject({ - Range: 'bytes=-4096', - }) + await runStepLog(new Response(''), params) + expect(serverMocks.secureBitbucketRead).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ Range: 'bytes=-4096' }), + expect.any(Number), + expect.any(Object) + ) }) it('trims the partial leading line of a ranged log and reports total bytes', async () => { @@ -509,7 +515,7 @@ describe('Bitbucket pipeline step log transfer boundary', () => { it('reads through the capped server path instead of the buffered tool request', async () => { serverMocks.secureBitbucketRead.mockResolvedValue(new Response('done\n')) - await bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS) + await executeBitbucketGetPipelineStepLogOperation(LOG_PARAMS) const [url, headers, maxBytes, options] = serverMocks.secureBitbucketRead.mock.calls[0] expect(url).toContain('/pipelines/%7Bpipeline-1%7D/steps/%7Bstep-1%7D/log') @@ -523,7 +529,7 @@ describe('Bitbucket pipeline step log transfer boundary', () => { new Response('', { status: 416, headers: { 'Content-Range': 'bytes */0' } }) ) - const result = await bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS) + const result = await executeBitbucketGetPipelineStepLogOperation(LOG_PARAMS) expect(result).toEqual({ success: true, @@ -536,7 +542,7 @@ describe('Bitbucket pipeline step log transfer boundary', () => { Response.json({ error: { message: 'No such step' } }, { status: 404 }) ) - await expect(bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)).rejects.toThrow( + await expect(executeBitbucketGetPipelineStepLogOperation(LOG_PARAMS)).rejects.toThrow( /No such step/ ) }) @@ -549,7 +555,7 @@ describe('Bitbucket pipeline step log transfer boundary', () => { ) ) - await expect(bitbucketGetPipelineStepLogTool.directExecution!(LOG_PARAMS)).rejects.toThrow( + await expect(executeBitbucketGetPipelineStepLogOperation(LOG_PARAMS)).rejects.toThrow( /Range rejected: proxy does not support ranges/ ) }) diff --git a/apps/sim/tools/bitbucket/pull-requests.test.ts b/apps/sim/tools/bitbucket/pull-requests.test.ts index 4e189981b0d..c053e4e2a34 100644 --- a/apps/sim/tools/bitbucket/pull-requests.test.ts +++ b/apps/sim/tools/bitbucket/pull-requests.test.ts @@ -2,14 +2,14 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { executeBitbucketGetPullRequestDiffOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diff' +import { executeBitbucketGetPullRequestDiffstatOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diffstat' import { bitbucketApprovePullRequestTool } from '@/tools/bitbucket/approve_pull_request' import { bitbucketCreatePullRequestTool } from '@/tools/bitbucket/create_pull_request' import { bitbucketCreatePullRequestCommentTool } from '@/tools/bitbucket/create_pull_request_comment' import { bitbucketDeclinePullRequestTool } from '@/tools/bitbucket/decline_pull_request' import { bitbucketGetMergeTaskStatusTool } from '@/tools/bitbucket/get_merge_task_status' import { bitbucketGetPullRequestTool } from '@/tools/bitbucket/get_pull_request' -import { bitbucketGetPullRequestDiffTool } from '@/tools/bitbucket/get_pull_request_diff' -import { bitbucketGetPullRequestDiffstatTool } from '@/tools/bitbucket/get_pull_request_diffstat' import { bitbucketListPullRequestCommentsTool } from '@/tools/bitbucket/list_pull_request_comments' import { bitbucketListPullRequestCommitStatusesTool } from '@/tools/bitbucket/list_pull_request_commit_statuses' import { bitbucketListPullRequestsTool } from '@/tools/bitbucket/list_pull_requests' @@ -631,7 +631,7 @@ describe('Bitbucket pull request diff safety', () => { maxCharacters: 100, } satisfies BitbucketGetPullRequestDiffParams - const result = await bitbucketGetPullRequestDiffTool.directExecution!(params) + const result = await executeBitbucketGetPullRequestDiffOperation(params) expect(result).toMatchObject({ success: true, @@ -654,12 +654,11 @@ describe('Bitbucket pull request diff safety', () => { 10 * 1024 * 1024, { signal: undefined, targetQuery: { path: 'src/my file.ts', binary: 'false' } } ) - expect(requestUrl(bitbucketGetPullRequestDiffTool, params)).not.toContain('path=') }) it('rejects hostile repository-relative paths before making a redirect request', async () => { await expect( - bitbucketGetPullRequestDiffTool.directExecution!({ + executeBitbucketGetPullRequestDiffOperation({ ...PULL_REQUEST_PARAMS, path: '../secret', }) @@ -668,10 +667,14 @@ describe('Bitbucket pull request diff safety', () => { }) it('locally caps raw diff text when a Range response is ignored', async () => { - const result = await bitbucketGetPullRequestDiffTool.transformResponse!( - new Response('0123456789', { headers: { 'Content-Length': '10' } }), - { ...PULL_REQUEST_PARAMS, path: 'src/index.ts', maxCharacters: 4 } + serverMocks.secureBitbucketPullRequestRedirect.mockResolvedValueOnce( + new Response('0123456789', { headers: { 'Content-Length': '10' } }) ) + const result = await executeBitbucketGetPullRequestDiffOperation({ + ...PULL_REQUEST_PARAMS, + path: 'src/index.ts', + maxCharacters: 4, + }) expect(result.output).toEqual({ diff: '0123', decodingLossy: false, @@ -682,12 +685,16 @@ describe('Bitbucket pull request diff safety', () => { }) it('lossily decodes invalid UTF-8 only for pull request diffs', async () => { - const result = await bitbucketGetPullRequestDiffTool.transformResponse!( + serverMocks.secureBitbucketPullRequestRedirect.mockResolvedValueOnce( new Response(new Uint8Array([0x41, 0x80]), { headers: { 'Content-Length': '2', 'Content-Type': 'text/plain' }, - }), - { ...PULL_REQUEST_PARAMS, path: 'src/index.ts', maxCharacters: 100 } + }) ) + const result = await executeBitbucketGetPullRequestDiffOperation({ + ...PULL_REQUEST_PARAMS, + path: 'src/index.ts', + maxCharacters: 100, + }) expect(result.output).toEqual({ diff: 'A�', @@ -707,7 +714,7 @@ describe('Bitbucket pull request diff safety', () => { pageLen: 25, } satisfies BitbucketPaginatedPullRequestParams - const result = await bitbucketGetPullRequestDiffstatTool.directExecution!(params) + const result = await executeBitbucketGetPullRequestDiffstatOperation(params) expect(result.output.items[0]).toEqual({ type: 'diffstat', @@ -741,7 +748,7 @@ describe('Bitbucket pull request diff safety', () => { Response.json({ values: [RAW_DIFFSTAT], page: 2 }) ) - const result = await bitbucketGetPullRequestDiffstatTool.directExecution!({ + const result = await executeBitbucketGetPullRequestDiffstatOperation({ ...PULL_REQUEST_PARAMS, nextUrl, pageLen: 99, @@ -763,7 +770,7 @@ describe('Bitbucket pull request diff safety', () => { ) await expect( - bitbucketGetPullRequestDiffstatTool.directExecution!({ + executeBitbucketGetPullRequestDiffstatOperation({ ...PULL_REQUEST_PARAMS, nextUrl: 'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/diffstat/source-team/source-repo:6315b3bac849%0Dunrelated?page=2', @@ -780,7 +787,7 @@ describe('Bitbucket pull request diff safety', () => { ] for (const nextUrl of invalid) { await expect( - bitbucketGetPullRequestDiffstatTool.directExecution!({ + executeBitbucketGetPullRequestDiffstatOperation({ ...PULL_REQUEST_PARAMS, nextUrl, }) @@ -791,9 +798,10 @@ describe('Bitbucket pull request diff safety', () => { }) it('normalizes executor-provided diffstat JSON through the same transform', async () => { - const result = await bitbucketGetPullRequestDiffstatTool.transformResponse!( + serverMocks.secureBitbucketPullRequestRedirect.mockResolvedValueOnce( Response.json({ values: [RAW_DIFFSTAT], page: 3, pagelen: 20 }) ) + const result = await executeBitbucketGetPullRequestDiffstatOperation(PULL_REQUEST_PARAMS) expect(result.output).toMatchObject({ items: [{ newPath: 'src/new.ts', linesAdded: 12 }], page: { page: 3, pageLen: 20 }, diff --git a/apps/sim/tools/bitbucket/repository-source.test.ts b/apps/sim/tools/bitbucket/repository-source.test.ts index b28a9a9a5c7..e2008fae9bb 100644 --- a/apps/sim/tools/bitbucket/repository-source.test.ts +++ b/apps/sim/tools/bitbucket/repository-source.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { executeBitbucketGetFileOperation } from '@/lib/internal/bitbucket/operations/get-file' import { bitbucketCreateBranchTool } from '@/tools/bitbucket/create_branch' import { bitbucketDeleteBranchTool } from '@/tools/bitbucket/delete_branch' import { bitbucketGetCommitTool } from '@/tools/bitbucket/get_commit' @@ -170,6 +171,10 @@ describe('Bitbucket action tool contracts', () => { it('enables bounded retry only on safe reads, never on mutations', () => { for (const tool of bitbucketTools) { + if ('operation' in tool) { + expect('request' in tool, tool.id).toBe(false) + continue + } const method = typeof tool.request.method === 'function' ? null : tool.request.method if (method !== 'GET') { expect(tool.request.retry, tool.id).toBeUndefined() @@ -328,7 +333,7 @@ describe('Bitbucket workspace and repository tools', () => { }) describe('Bitbucket source tools', () => { - it('builds encoded branch, commit, directory, and file URLs', () => { + it('builds encoded branch, commit, directory, and file URLs', async () => { expect( requestUrl(bitbucketListBranchesTool, { ...REPOSITORY_PARAMS, @@ -381,13 +386,13 @@ describe('Bitbucket source tools', () => { path: 'README.md', } satisfies BitbucketFileParams) ).toThrow(/commit must be a full 40-character SHA-1/) - expect(() => - requestUrl(bitbucketGetFileTool, { + await expect( + executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: true, path: 'README.md', } as unknown as BitbucketGetFileParams) - ).toThrow(/commit must be a full 40-character SHA-1/) + ).rejects.toThrow(/commit must be a full 40-character SHA-1/) }) it('keeps directory listing shallow and binds its cursor to the selected path', () => { @@ -653,7 +658,7 @@ describe('Bitbucket source tools', () => { path: 'assets/logo.png', } satisfies BitbucketGetFileParams - const result = await bitbucketGetFileTool.directExecution!(params) + const result = await executeBitbucketGetFileOperation(params) expect(result).toEqual({ success: true, @@ -671,7 +676,7 @@ describe('Bitbucket source tools', () => { `https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/src/${FEATURE_SHA}/assets/logo.png?format=meta`, expect.objectContaining({ Authorization: 'Bearer oauth-token' }), 256 * 1024, - { signal: undefined } + { stripAuthOnRedirect: true, signal: undefined } ) }) @@ -691,7 +696,7 @@ describe('Bitbucket source tools', () => { }) ) - const result = await bitbucketGetFileTool.directExecution!({ + const result = await executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: COMMIT_SHA, path: 'assets/logo.png', @@ -715,7 +720,7 @@ describe('Bitbucket source tools', () => { ) await expect( - bitbucketGetFileTool.directExecution!({ + executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: COMMIT_SHA, path: 'src', @@ -726,7 +731,7 @@ describe('Bitbucket source tools', () => { it('validates maxCharacters before the metadata preflight', async () => { await expect( - bitbucketGetFileTool.directExecution!({ + executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: COMMIT_SHA, path: 'README.md', @@ -735,7 +740,7 @@ describe('Bitbucket source tools', () => { ).rejects.toThrow(/maxCharacters must be an integer between 1 and 500000/) expect(serverMocks.secureBitbucketRead).not.toHaveBeenCalled() await expect( - bitbucketGetFileTool.directExecution!({ + executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: 'main', path: 'README.md', @@ -770,7 +775,7 @@ describe('Bitbucket source tools', () => { maxCharacters: 4, } satisfies BitbucketGetFileParams - const result = await bitbucketGetFileTool.directExecution!(params) + const result = await executeBitbucketGetFileOperation(params) expect(result).toEqual({ success: true, @@ -813,7 +818,7 @@ describe('Bitbucket source tools', () => { }) ) - const result = await bitbucketGetFileTool.directExecution!({ + const result = await executeBitbucketGetFileOperation({ ...REPOSITORY_PARAMS, commit: COMMIT_SHA, path: 'unknown.bin', @@ -828,15 +833,9 @@ describe('Bitbucket source tools', () => { }) }) - it('uses the normal HTTP path only as a guarded executor fallback', async () => { - expect(bitbucketGetFileTool.request.stripAuthOnRedirect).toBe(true) - await expect( - bitbucketGetFileTool.transformResponse!(new Response('content'), { - ...REPOSITORY_PARAMS, - commit: COMMIT_SHA, - path: 'README.md', - }) - ).rejects.toThrow(/metadata preflight direct execution path/) + it('uses only the registered operation path', () => { + expect(bitbucketGetFileTool.operation).toBeDefined() + expect('request' in bitbucketGetFileTool).toBe(false) }) it('builds the list-commits endpoint with its opaque cursor bound', () => { diff --git a/apps/sim/tools/browser_use/run_task.ts b/apps/sim/tools/browser_use/run_task.ts index e8968f397c2..4cffd51ad2b 100644 --- a/apps/sim/tools/browser_use/run_task.ts +++ b/apps/sim/tools/browser_use/run_task.ts @@ -1,307 +1,8 @@ -import { createLogger } from '@sim/logger' -import { sleep } from '@sim/utils/helpers' -import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -const logger = createLogger('BrowserUseTool') - -const POLL_INTERVAL_MS = 5000 -const MAX_POLL_TIME_MS = getMaxExecutionTimeout() -const MAX_CONSECUTIVE_ERRORS = 3 -const API_BASE = 'https://api.browser-use.com/api/v2' - -async function createSessionWithProfile( - profileId: string, - apiKey: string -): Promise<{ sessionId: string } | { error: string }> { - try { - const response = await fetch(`${API_BASE}/sessions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Browser-Use-API-Key': apiKey, - }, - body: JSON.stringify({ - profileId: profileId.trim(), - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`Failed to create session with profile: ${errorText}`) - return { error: `Failed to create session with profile: ${response.statusText}` } - } - - const data = (await response.json()) as { id: string } - logger.info(`Created session ${data.id} with profile ${profileId}`) - return { sessionId: data.id } - } catch (error: any) { - logger.error('Error creating session with profile:', error) - return { error: `Error creating session: ${error.message}` } - } -} - -async function stopSession(sessionId: string, apiKey: string): Promise { - try { - const response = await fetch(`${API_BASE}/sessions/${sessionId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'X-Browser-Use-API-Key': apiKey, - }, - body: JSON.stringify({ action: 'stop' }), - }) - - if (response.ok) { - logger.info(`Stopped session ${sessionId}`) - } else { - logger.warn(`Failed to stop session ${sessionId}: ${response.statusText}`) - } - } catch (error: any) { - logger.warn(`Error stopping session ${sessionId}:`, error) - } -} - -async function fetchSessionLiveUrl( - sessionId: string, - apiKey: string -): Promise<{ liveUrl: string | null; publicShareUrl: string | null }> { - try { - const response = await fetch(`${API_BASE}/sessions/${sessionId}`, { - method: 'GET', - headers: { 'X-Browser-Use-API-Key': apiKey }, - }) - if (!response.ok) { - return { liveUrl: null, publicShareUrl: null } - } - const data = (await response.json()) as { liveUrl?: string; publicShareUrl?: string } - return { - liveUrl: data.liveUrl ?? null, - publicShareUrl: data.publicShareUrl ?? null, - } - } catch (error: any) { - logger.warn(`Error fetching session ${sessionId}:`, error) - return { liveUrl: null, publicShareUrl: null } - } -} - -function normalizeSecrets(variables: BrowserUseRunTaskParams['variables']): Record { - const secrets: Record = {} - if (!variables) return secrets - - if (Array.isArray(variables)) { - for (const row of variables as Array>) { - if (row?.cells?.Key && row.cells.Value !== undefined) { - secrets[row.cells.Key] = row.cells.Value - } else if (row?.Key && row.Value !== undefined) { - secrets[row.Key] = row.Value - } - } - } else if (typeof variables === 'object') { - for (const [k, v] of Object.entries(variables)) { - if (typeof v === 'string') secrets[k] = v - } - } - return secrets -} - -function parseAllowedDomains(input?: string | string[]): string[] | undefined { - if (!input) return undefined - const arr = Array.isArray(input) - ? input - : input - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return arr.length > 0 ? arr : undefined -} - -function buildRequestBody( - params: BrowserUseRunTaskParams, - sessionId?: string -): Record { - const body: Record = { task: params.task } - - if (sessionId) body.sessionId = sessionId - if (params.model) body.llm = params.model - if (params.startUrl?.trim()) body.startUrl = params.startUrl.trim() - if (typeof params.maxSteps === 'number' && params.maxSteps > 0) body.maxSteps = params.maxSteps - if (params.structuredOutput) body.structuredOutput = params.structuredOutput - if (typeof params.flashMode === 'boolean') body.flashMode = params.flashMode - if (typeof params.thinking === 'boolean') body.thinking = params.thinking - if (typeof params.vision === 'boolean' || params.vision === 'auto') body.vision = params.vision - if (params.systemPromptExtension) body.systemPromptExtension = params.systemPromptExtension - if (typeof params.highlightElements === 'boolean') - body.highlightElements = params.highlightElements - - const allowedDomains = parseAllowedDomains(params.allowedDomains) - if (allowedDomains) body.allowedDomains = allowedDomains - - const secrets = normalizeSecrets(params.variables) - if (Object.keys(secrets).length > 0) body.secrets = secrets - - if ( - params.metadata && - typeof params.metadata === 'object' && - Object.keys(params.metadata).length > 0 - ) - body.metadata = params.metadata - - return body -} - -async function fetchTaskStatus( - taskId: string, - apiKey: string -): Promise<{ ok: true; data: any } | { ok: false; error: string }> { - try { - const response = await fetch(`${API_BASE}/tasks/${taskId}`, { - method: 'GET', - headers: { 'X-Browser-Use-API-Key': apiKey }, - }) - - if (!response.ok) { - return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` } - } - - return { ok: true, data: await response.json() } - } catch (error: any) { - return { ok: false, error: error.message || 'Network error' } - } -} - -interface PollResult { - success: boolean - output: any - steps: any[] - sessionId: string | null - liveUrl: string | null - publicShareUrl: string | null - error?: string -} - -async function pollForCompletion(taskId: string, apiKey: string): Promise { - let consecutiveErrors = 0 - let sessionId: string | null = null - let liveUrl: string | null = null - let publicShareUrl: string | null = null - const startTime = Date.now() - - while (Date.now() - startTime < MAX_POLL_TIME_MS) { - const result = await fetchTaskStatus(taskId, apiKey) - - if (!result.ok) { - consecutiveErrors++ - logger.warn( - `Error polling task ${taskId} (attempt ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${result.error}` - ) - - if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - return { - success: false, - output: null, - steps: [], - sessionId, - liveUrl, - publicShareUrl, - error: `Failed to poll task status after ${MAX_CONSECUTIVE_ERRORS} attempts: ${result.error}`, - } - } - - await sleep(POLL_INTERVAL_MS) - continue - } - - consecutiveErrors = 0 - const taskData = result.data - if (taskData.sessionId) sessionId = taskData.sessionId - const status = taskData.status - - logger.info(`BrowserUse task ${taskId} status: ${status}`) - - if (sessionId && !liveUrl) { - const session = await fetchSessionLiveUrl(sessionId, apiKey) - if (session.liveUrl) { - liveUrl = session.liveUrl - logger.info(`BrowserUse live URL: ${liveUrl}`) - } - if (session.publicShareUrl) publicShareUrl = session.publicShareUrl - } - - if (['finished', 'failed', 'stopped'].includes(status)) { - return { - success: status === 'finished', - output: taskData.output ?? null, - steps: taskData.steps || [], - sessionId, - liveUrl, - publicShareUrl, - } - } - - await sleep(POLL_INTERVAL_MS) - } - - const finalResult = await fetchTaskStatus(taskId, apiKey) - if (finalResult.ok && ['finished', 'failed', 'stopped'].includes(finalResult.data.status)) { - return { - success: finalResult.data.status === 'finished', - output: finalResult.data.output ?? null, - steps: finalResult.data.steps || [], - sessionId: finalResult.data.sessionId ?? sessionId, - liveUrl, - publicShareUrl, - } - } - - return { - success: false, - output: null, - steps: [], - sessionId, - liveUrl, - publicShareUrl, - error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`, - } -} - -async function createShareUrl(sessionId: string, apiKey: string): Promise { - try { - const response = await fetch(`${API_BASE}/sessions/${sessionId}/public-share`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Browser-Use-API-Key': apiKey, - }, - }) - - if (!response.ok) { - logger.warn(`Failed to create share URL for session ${sessionId}: ${response.statusText}`) - return null - } - - const data = (await response.json()) as { shareUrl?: string; shareToken?: string } - return data.shareUrl ?? null - } catch (error: any) { - logger.warn(`Error creating share URL for session ${sessionId}:`, error) - return null - } -} - -function emptyOutput(): BrowserUseRunTaskResponse['output'] { - return { - id: '', - success: false, - output: null, - steps: [], - liveUrl: null, - shareUrl: null, - sessionId: null, - } -} - -export const runTaskTool: ToolConfig = { +export const runTaskTool: InternalToolConfig = { id: 'browser_use_run_task', name: 'Browser Use', description: 'Runs a browser automation task using BrowserUse', @@ -400,13 +101,8 @@ export const runTaskTool: ToolConfig ({ - 'Content-Type': 'application/json', - 'X-Browser-Use-API-Key': params.apiKey, - }), + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ @@ -417,83 +113,6 @@ export const runTaskTool: ToolConfig => { - let sessionId: string | undefined - - if (params.profile_id) { - logger.info(`Creating session with profile ID: ${params.profile_id}`) - const sessionResult = await createSessionWithProfile(params.profile_id, params.apiKey) - if ('error' in sessionResult) { - return { success: false, output: emptyOutput(), error: sessionResult.error } - } - sessionId = sessionResult.sessionId - } - - const requestBody = buildRequestBody(params, sessionId) - logger.info('Creating BrowserUse task', { hasSession: !!sessionId }) - - try { - const response = await fetch(`${API_BASE}/tasks`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Browser-Use-API-Key': params.apiKey, - }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`Failed to create task: ${errorText}`) - return { - success: false, - output: emptyOutput(), - error: `Failed to create task: ${response.statusText}`, - } - } - - const data = (await response.json()) as { id: string; sessionId?: string } - const taskId = data.id - const initialSessionId = sessionId ?? data.sessionId ?? null - logger.info(`Created BrowserUse task ${taskId}`, { sessionId: initialSessionId }) - - const result = await pollForCompletion(taskId, params.apiKey) - - const finalSessionId = result.sessionId ?? initialSessionId - const shareUrl = - result.publicShareUrl ?? - (finalSessionId ? await createShareUrl(finalSessionId, params.apiKey) : null) - - if (sessionId) { - await stopSession(sessionId, params.apiKey) - } - - return { - success: result.success && !result.error, - output: { - id: taskId, - success: result.success, - output: result.output, - steps: result.steps, - liveUrl: result.liveUrl, - shareUrl, - sessionId: finalSessionId, - }, - error: result.error, - } - } catch (error: any) { - logger.error('Error creating BrowserUse task:', error) - if (sessionId) { - await stopSession(sessionId, params.apiKey) - } - return { - success: false, - output: emptyOutput(), - error: `Error creating task: ${error.message}`, - } - } - }, - outputs: { id: { type: 'string', description: 'Task execution identifier' }, success: { type: 'boolean', description: 'Task completion status' }, diff --git a/apps/sim/tools/browser_use/types.ts b/apps/sim/tools/browser_use/types.ts index 9be3469e06e..1316bf41a14 100644 --- a/apps/sim/tools/browser_use/types.ts +++ b/apps/sim/tools/browser_use/types.ts @@ -3,7 +3,7 @@ import type { ToolResponse } from '@/tools/types' export interface BrowserUseRunTaskParams { task: string apiKey: string - variables?: Record | Array> + variables?: Record | Array> model?: string startUrl?: string allowedDomains?: string | string[] @@ -18,7 +18,7 @@ export interface BrowserUseRunTaskParams { profile_id?: string } -interface BrowserUseTaskStep { +export interface BrowserUseTaskStep { number: number memory: string evaluationPreviousGoal: string @@ -32,7 +32,7 @@ interface BrowserUseTaskStep { interface BrowserUseTaskOutput { id: string success: boolean - output: string | null + output: unknown steps: BrowserUseTaskStep[] liveUrl: string | null shareUrl: string | null diff --git a/apps/sim/tools/cbinsights/cbinsights.test.ts b/apps/sim/tools/cbinsights/cbinsights.test.ts index ac7583372f8..c95afe745f9 100644 --- a/apps/sim/tools/cbinsights/cbinsights.test.ts +++ b/apps/sim/tools/cbinsights/cbinsights.test.ts @@ -2,13 +2,18 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeCbinsightsChatOperation } from '@/lib/internal/cbinsights/operations/chat' +import { executeCbinsightsGetCommercialMaturityHistoryOperation } from '@/lib/internal/cbinsights/operations/get-commercial-maturity-history' +import { executeCbinsightsGetOrgFundingsOperation } from '@/lib/internal/cbinsights/operations/get-org-fundings' +import { executeCbinsightsGetOrgOutlookOperation } from '@/lib/internal/cbinsights/operations/get-org-outlook' +import { executeCbinsightsListBusinessRelationshipsOperation } from '@/lib/internal/cbinsights/operations/list-business-relationships' +import { executeCbinsightsListFundingsOperation } from '@/lib/internal/cbinsights/operations/list-fundings' +import { executeCbinsightsLookupOrganizationsOperation } from '@/lib/internal/cbinsights/operations/lookup-organizations' +import { executeCbinsightsRagOperation } from '@/lib/internal/cbinsights/operations/rag' +import { executeCbinsightsSearchFirmographicsOperation } from '@/lib/internal/cbinsights/operations/search-firmographics' import { cbinsightsChatTool } from '@/tools/cbinsights/chat' -import { cbinsightsGetOrgFundingsTool } from '@/tools/cbinsights/get_org_fundings' -import { cbinsightsGetOrgOutlookTool } from '@/tools/cbinsights/get_org_outlook' import { cbinsightsGetScoutingReportTool } from '@/tools/cbinsights/get_scouting_report' import { cbinsightsListBusinessRelationshipsTool } from '@/tools/cbinsights/list_business_relationships' -import { cbinsightsListFundingsTool } from '@/tools/cbinsights/list_fundings' -import { cbinsightsLookupOrganizationsTool } from '@/tools/cbinsights/lookup_organizations' import { cbinsightsRagTool } from '@/tools/cbinsights/rag' import { cbinsightsSearchFirmographicsTool } from '@/tools/cbinsights/search_firmographics' import { cbInsightsTokenCacheSize, resetCbInsightsTokenCache } from '@/tools/cbinsights/utils' @@ -51,7 +56,7 @@ describe('cbinsights authorization', () => { it('exchanges the client credentials before the data call, then bearers the token', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsLookupOrganizationsTool.directExecution!({ + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'CB Insights', } as never) @@ -69,8 +74,8 @@ describe('cbinsights authorization', () => { it('reuses a cached token rather than re-authorizing per call', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }, { body: { orgs: [] } }]) - await cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS, names: 'a' } as never) - await cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS, names: 'b' } as never) + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a' } as never) + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'b' } as never) expect(calls.filter((call) => call.url.endsWith('/v2/authorize'))).toHaveLength(1) }) @@ -88,7 +93,7 @@ describe('cbinsights authorization', () => { { body: { orgs: [{ orgId: 1 }] } }, ]) - const result = await cbinsightsLookupOrganizationsTool.directExecution!({ + const result = await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'CB Insights', } as never) @@ -112,7 +117,7 @@ describe('cbinsights authorization', () => { ]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS, names: 'a' } as never) + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a' } as never) ).rejects.toThrow(/still nope/) expect(calls).toHaveLength(4) }) @@ -121,7 +126,7 @@ describe('cbinsights authorization', () => { mockFetch([AUTH_OK, { status: 403, body: { error: 'Insufficient credits' } }]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS, names: 'a' } as never) + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a' } as never) ).rejects.toThrow(/Insufficient credits/) }) @@ -129,7 +134,7 @@ describe('cbinsights authorization', () => { mockFetch([{ body: {} }]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS, names: 'a' } as never) + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a' } as never) ).rejects.toThrow(/returned no token/) }) }) @@ -147,7 +152,7 @@ describe('cbinsights token cache', () => { mockFetch(responses) for (let index = 0; index < 200; index++) { - await cbinsightsLookupOrganizationsTool.directExecution!({ + await executeCbinsightsLookupOrganizationsOperation({ clientId: `id-${index}`, clientSecret: 'secret', names: 'a', @@ -162,14 +167,14 @@ describe('cbinsights request building', () => { it('rejects a lookup with no search parameter', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ ...CREDS } as never) + executeCbinsightsLookupOrganizationsOperation({ ...CREDS } as never) ).rejects.toThrow(/at least one of "names", "urls", or "profileUrl"/) }) it('rejects the profileUrl + names combination the API rejects', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'CB Insights', profileUrl: 'https://app.cbinsights.com/profiles/c/jp3o4', @@ -180,11 +185,11 @@ describe('cbinsights request building', () => { it('accepts a comma-separated list and a JSON array alike', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }, { body: { orgs: [] } }]) - await cbinsightsLookupOrganizationsTool.directExecution!({ + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'CB Insights, Stripe', } as never) - await cbinsightsLookupOrganizationsTool.directExecution!({ + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: '["CB Insights","Stripe"]', } as never) @@ -195,7 +200,7 @@ describe('cbinsights request building', () => { it('clamps limit into the documented 1-100 range', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsLookupOrganizationsTool.directExecution!({ + await executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a', limit: 5000, @@ -207,10 +212,34 @@ describe('cbinsights request building', () => { mockFetch([AUTH_OK]) const orgIds = Array.from({ length: 101 }, (_, index) => index + 1) await expect( - cbinsightsListFundingsTool.directExecution!({ ...CREDS, orgIds } as never) + executeCbinsightsListFundingsOperation({ ...CREDS, orgIds } as never) ).rejects.toThrow(/at most 100 organization IDs/) }) + it('rejects more than 100 firmographics organization IDs before spending a round trip', async () => { + mockFetch([AUTH_OK]) + const orgIds = Array.from({ length: 101 }, (_, index) => index + 1) + await expect( + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, orgIds } as never) + ).rejects.toThrow(/at most 100 organization IDs/) + expect(calls).toHaveLength(0) + }) + + it.each([ + ['startDate', 20260725], + ['endDate', { date: '2026-08-28' }], + ])('rejects a non-string commercial maturity %s', async (field, value) => { + mockFetch([AUTH_OK]) + await expect( + executeCbinsightsGetCommercialMaturityHistoryOperation({ + ...CREDS, + orgId: 129410, + [field]: value, + } as never) + ).rejects.toThrow(new RegExp(`"${field}" must be a string`)) + expect(calls).toHaveLength(0) + }) + /* * Dropping the bad entries instead would run against a silently narrower set: * a typo would spend credits on the wrong organizations and still report @@ -219,7 +248,7 @@ describe('cbinsights request building', () => { it('rejects a required ID list containing an invalid entry rather than dropping it', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsListFundingsTool.directExecution!({ + executeCbinsightsListFundingsOperation({ ...CREDS, orgIds: '129410, notanid, 1034157', } as never) @@ -229,7 +258,7 @@ describe('cbinsights request building', () => { it('rejects a mistyped optional filter rather than silently widening the search', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', sectorIds: 'four', @@ -239,7 +268,7 @@ describe('cbinsights request building', () => { it('still treats an unset optional filter as absent', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', sectorIds: '', @@ -256,13 +285,13 @@ describe('cbinsights request building', () => { it('tolerates a trailing or doubled comma identically on both paths', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }, { body: { orgs: [] } }]) - await cbinsightsListFundingsTool.directExecution!({ + await executeCbinsightsListFundingsOperation({ ...CREDS, orgIds: '129410, 1034157,', } as never) expect(JSON.parse(String(calls[1].init.body)).orgIds).toEqual([129410, 1034157]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, sectorIds: '1,,2', } as never) @@ -280,7 +309,7 @@ describe('cbinsights request building', () => { async (entry) => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', vcBacked: entry, @@ -297,7 +326,7 @@ describe('cbinsights request building', () => { [false, false], ])('still accepts the boolean form %j a dropdown emits', async (entry, expected) => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', vcBacked: entry, @@ -307,7 +336,7 @@ describe('cbinsights request building', () => { it('treats the dropdown\'s "Any" option as no filter at all', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', vcBacked: '', @@ -322,7 +351,7 @@ describe('cbinsights request building', () => { it('rejects a sort direction that is neither asc nor desc', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', sortField: 'mosaicOverall', @@ -339,7 +368,7 @@ describe('cbinsights request building', () => { it('rejects a mistyped limit rather than falling back to the endpoint default', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: 'a', limit: 'twenty', @@ -355,7 +384,7 @@ describe('cbinsights request building', () => { it('rejects a non-text entry in a free-text filter rather than stringifying it', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsLookupOrganizationsTool.directExecution!({ + executeCbinsightsLookupOrganizationsOperation({ ...CREDS, names: [{ name: 'CB Insights' }], } as never) @@ -365,7 +394,7 @@ describe('cbinsights request building', () => { it('treats a whitespace-only numeric bound as unset, not as zero', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', minCurrentHeadcount: ' ', @@ -376,7 +405,7 @@ describe('cbinsights request building', () => { it('rejects a mistyped numeric bound rather than dropping it', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', minCurrentHeadcount: 'fifty', @@ -391,7 +420,7 @@ describe('cbinsights request building', () => { it('refuses a firmographics search carrying only paging or sort', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ + executeCbinsightsSearchFirmographicsOperation({ ...CREDS, limit: 100, nextPageToken: 'tok', @@ -402,7 +431,7 @@ describe('cbinsights request building', () => { it('still sends paging and sort alongside a real filter', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', limit: 25, @@ -428,7 +457,7 @@ describe('cbinsights request building', () => { async (entry) => { mockFetch([AUTH_OK]) await expect( - cbinsightsGetOrgOutlookTool.directExecution!({ ...CREDS, orgId: entry } as never) + executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: entry } as never) ).rejects.toThrow(/"orgId" must be a positive integer/) } ) @@ -436,12 +465,12 @@ describe('cbinsights request building', () => { it('rejects a numeric ID past the precision limit on both input shapes', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsGetOrgOutlookTool.directExecution!({ ...CREDS, orgId: 1e20 } as never) + executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: 1e20 } as never) ).rejects.toThrow(/"orgId" must be a positive integer/) mockFetch([AUTH_OK]) await expect( - cbinsightsGetOrgOutlookTool.directExecution!({ + executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: '12345678901234567890', } as never) @@ -450,17 +479,17 @@ describe('cbinsights request building', () => { it('still accepts a plain decimal ID, with or without padding', async () => { mockFetch([AUTH_OK, { body: {} }, { body: {} }]) - await cbinsightsGetOrgOutlookTool.directExecution!({ ...CREDS, orgId: ' 129410 ' } as never) + await executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: ' 129410 ' } as never) expect(calls[1].url).toContain('/organizations/129410/outlook') - await cbinsightsGetOrgOutlookTool.directExecution!({ ...CREDS, orgId: 129410 } as never) + await executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: 129410 } as never) expect(calls[2].url).toContain('/organizations/129410/outlook') }) it('rejects an alternate numeric notation inside a bulk ID list', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsListFundingsTool.directExecution!({ + executeCbinsightsListFundingsOperation({ ...CREDS, orgIds: '129410, 0x10', } as never) @@ -470,7 +499,7 @@ describe('cbinsights request building', () => { it('rejects a non-integer organization ID rather than interpolating it into the path', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsGetOrgOutlookTool.directExecution!({ + executeCbinsightsGetOrgOutlookOperation({ ...CREDS, orgId: '12/../../admin', } as never) @@ -479,7 +508,7 @@ describe('cbinsights request building', () => { it('puts the organization ID on the path for a scoped endpoint', async () => { mockFetch([AUTH_OK, { body: {} }]) - await cbinsightsGetOrgFundingsTool.directExecution!({ ...CREDS, orgId: 129410 } as never) + await executeCbinsightsGetOrgFundingsOperation({ ...CREDS, orgId: 129410 } as never) expect(calls[1].url).toBe( 'https://api.cbinsights.com/v2/organizations/129410/financialtransactions/fundings' ) @@ -487,7 +516,7 @@ describe('cbinsights request building', () => { it('omits filters the caller left blank instead of sending them empty', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', marketNames: '', @@ -500,7 +529,7 @@ describe('cbinsights request building', () => { it('builds the single sort object from the two plain fields', async () => { mockFetch([AUTH_OK, { body: { orgs: [] } }]) - await cbinsightsSearchFirmographicsTool.directExecution!({ + await executeCbinsightsSearchFirmographicsOperation({ ...CREDS, keyword: 'fintech', sortField: 'mosaicOverall', @@ -516,14 +545,14 @@ describe('cbinsights request building', () => { it('rejects a firmographics search with no filter at all', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsSearchFirmographicsTool.directExecution!({ ...CREDS } as never) + executeCbinsightsSearchFirmographicsOperation({ ...CREDS } as never) ).rejects.toThrow(/at least one search parameter/) }) it('rejects a RAG message over the documented 10,000-character cap', async () => { mockFetch([AUTH_OK]) await expect( - cbinsightsRagTool.directExecution!({ ...CREDS, message: 'x'.repeat(10_001) } as never) + executeCbinsightsRagOperation({ ...CREDS, message: 'x'.repeat(10_001) } as never) ).rejects.toThrow(/under 10,000 characters/) }) }) @@ -543,7 +572,7 @@ describe('cbinsights response mapping', () => { }, ]) - const result = await cbinsightsGetOrgFundingsTool.directExecution!({ + const result = await executeCbinsightsGetOrgFundingsOperation({ ...CREDS, orgId: 129410, } as never) @@ -560,7 +589,7 @@ describe('cbinsights response mapping', () => { it('reports absent collections as empty and absent scalars as null', async () => { mockFetch([AUTH_OK, { body: {} }]) - const result = await cbinsightsGetOrgFundingsTool.directExecution!({ + const result = await executeCbinsightsGetOrgFundingsOperation({ ...CREDS, orgId: 1, } as never) @@ -582,7 +611,7 @@ describe('cbinsights response mapping', () => { it('reports only the fields the business-relationships endpoint documents', async () => { mockFetch([AUTH_OK, { body: { orgs: [{ orgId: 1 }], nextPageToken: 'tok' } }]) - const result = await cbinsightsListBusinessRelationshipsTool.directExecution!({ + const result = await executeCbinsightsListBusinessRelationshipsOperation({ ...CREDS, orgIds: '1', } as never) @@ -607,7 +636,7 @@ describe('cbinsights response mapping', () => { }, ]) - const result = await cbinsightsChatTool.directExecution!({ + const result = await executeCbinsightsChatOperation({ ...CREDS, message: 'Which markets are growing?', } as never) @@ -619,7 +648,7 @@ describe('cbinsights response mapping', () => { it('sends the conversation ID back under the API name when continuing a chat', async () => { mockFetch([AUTH_OK, { body: {} }]) - await cbinsightsChatTool.directExecution!({ + await executeCbinsightsChatOperation({ ...CREDS, message: 'and then?', chatId: 'conv-1', @@ -633,15 +662,10 @@ describe('cbinsights response mapping', () => { }) describe('cbinsights model-input projection', () => { - /* - * `directExecution` still runs `projectToolModelInputParams`, so declaring the - * message here is what keeps an activated Sim secret from reaching a third - * party's model as plaintext. Only the message is model-visible — the - * credentials and the conversation ID are not. - */ + /** The operation boundary projects only the message into third-party model input. */ it('projects only the message on the two generative endpoints', () => { - const chatSelect = cbinsightsChatTool.request.modelInput - const ragSelect = cbinsightsRagTool.request.modelInput + const chatSelect = cbinsightsChatTool.operation.modelInput + const ragSelect = cbinsightsRagTool.operation.modelInput expect(chatSelect?.mode).toBe('project') expect(ragSelect?.mode).toBe('project') @@ -661,7 +685,7 @@ describe('cbinsights model-input projection', () => { * an AI-backed provider is not on its own a reason to project. */ it('leaves the ID-only endpoints unprojected', () => { - expect(cbinsightsGetScoutingReportTool.request.modelInput).toBeUndefined() - expect(cbinsightsSearchFirmographicsTool.request.modelInput).toBeUndefined() + expect(cbinsightsGetScoutingReportTool.operation.modelInput).toBeUndefined() + expect(cbinsightsSearchFirmographicsTool.operation.modelInput).toBeUndefined() }) }) diff --git a/apps/sim/tools/cbinsights/chat.ts b/apps/sim/tools/cbinsights/chat.ts index aef00af2f85..bf386532997 100644 --- a/apps/sim/tools/cbinsights/chat.ts +++ b/apps/sim/tools/cbinsights/chat.ts @@ -1,126 +1,85 @@ import type { CbInsightsAuthParams, CbInsightsChatResponse } from '@/tools/cbinsights/types' -import { - asArray, - asString, - asStringArray, - cbInsightsRequest, - compactBody, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsChatParams extends CbInsightsAuthParams { +export interface CbInsightsChatParams extends CbInsightsAuthParams { message: string chatId?: string } -export const cbinsightsChatTool: ToolConfig = { - id: 'cbinsights_chat', - name: 'CB Insights Chat', - description: - 'Ask ChatCBI a question in natural language and get an answer grounded in CB Insights data, with its sources and suggested follow-ups. Uses generative AI and can be wrong — verify anything that matters.', - version: '1.0.0', +export const cbinsightsChatTool: InternalToolConfig = + { + id: 'cbinsights_chat', + name: 'CB Insights Chat', + description: + 'Ask ChatCBI a question in natural language and get an answer grounded in CB Insights data, with its sources and suggested follow-ups. Uses generative AI and can be wrong — verify anything that matters.', + version: '1.0.0', - params: { - clientId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CB Insights API client ID, exchanged for a bearer token before each call', - }, - clientSecret: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CB Insights API client secret, exchanged for a bearer token before each call', - }, - message: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: - 'The question to ask, e.g. "Which emerging technology markets are seeing the highest equity funding growth right now?"', - }, - chatId: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: - 'Conversation ID returned by a previous call. Pass it to continue that conversation rather than starting a new one.', + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CB Insights API client ID, exchanged for a bearer token before each call', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CB Insights API client secret, exchanged for a bearer token before each call', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The question to ask, e.g. "Which emerging technology markets are seeing the highest equity funding growth right now?"', + }, + chatId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Conversation ID returned by a previous call. Pass it to continue that conversation rather than starting a new one.', + }, }, - }, - request: { - url: () => '', - method: 'POST', - headers: () => ({}), - /** - * The message is the prompt CB Insights feeds to its own model — the - * endpoint documentation states so directly — so an activated Sim secret is - * projected to its canonical label before it leaves Sim. - */ - modelInput: { - mode: 'project', - select: (params) => ({ message: params.message }), + operation: { + input: createInternalToolOperationInput, + modelInput: { + mode: 'project', + select: (params) => ({ message: params.message }), + }, }, - }, - - directExecution: async (params, signal) => { - const message = params.message?.trim() - if (!message) throw new Error('CB Insights "message" is required') - return cbInsightsRequest<{ - chatID?: unknown - title?: unknown - message?: unknown - sources?: unknown - relatedContent?: unknown - suggestions?: unknown - }>( - params, - { - path: '/v2/chatcbi', - body: compactBody({ message, chatID: params.chatId?.trim() }), + outputs: { + chatId: { + type: 'string', + nullable: true, + description: 'Conversation ID. Pass it back as chatId to continue this conversation.', + }, + title: { + type: 'string', + nullable: true, + description: 'Title CB Insights gave the conversation', + }, + message: { + type: 'string', + nullable: true, + description: "ChatCBI's answer, as Markdown", + }, + sources: { + type: 'json', + description: + 'Sources behind the answer as [{sourceIndex, result: {title, url, date, thumbnailUrl}}]', + }, + relatedContent: { + type: 'json', + description: 'Related references as [{title, url, date, thumbnailUrl}]', + }, + suggestions: { + type: 'json', + description: 'Suggested follow-up questions', }, - (data) => ({ - chatId: asString(data.chatID), - title: asString(data.title), - message: asString(data.message), - sources: asArray(data.sources), - relatedContent: asArray(data.relatedContent), - suggestions: asStringArray(data.suggestions), - }), - signal - ) - }, - - outputs: { - chatId: { - type: 'string', - nullable: true, - description: 'Conversation ID. Pass it back as chatId to continue this conversation.', - }, - title: { - type: 'string', - nullable: true, - description: 'Title CB Insights gave the conversation', - }, - message: { - type: 'string', - nullable: true, - description: "ChatCBI's answer, as Markdown", - }, - sources: { - type: 'json', - description: - 'Sources behind the answer as [{sourceIndex, result: {title, url, date, thumbnailUrl}}]', - }, - relatedContent: { - type: 'json', - description: 'Related references as [{title, url, date, thumbnailUrl}]', - }, - suggestions: { - type: 'json', - description: 'Suggested follow-up questions', }, - }, -} + } diff --git a/apps/sim/tools/cbinsights/get_commercial_maturity_history.ts b/apps/sim/tools/cbinsights/get_commercial_maturity_history.ts index f6cd6cedef6..a9b56590629 100644 --- a/apps/sim/tools/cbinsights/get_commercial_maturity_history.ts +++ b/apps/sim/tools/cbinsights/get_commercial_maturity_history.ts @@ -1,13 +1,13 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, compactBody, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsCommercialMaturityHistoryParams extends CbInsightsOrgParams { +export interface CbInsightsCommercialMaturityHistoryParams extends CbInsightsOrgParams { startDate?: string endDate?: string } -export const cbinsightsGetCommercialMaturityHistoryTool: ToolConfig< +export const cbinsightsGetCommercialMaturityHistoryTool: InternalToolConfig< CbInsightsCommercialMaturityHistoryParams, ToolResponse > = { @@ -53,22 +53,8 @@ export const cbinsightsGetCommercialMaturityHistoryTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ commercialMaturityHistory?: unknown }>( - params, - { - path: `/v2/organizations/${orgId}/commercialmaturityhistory`, - body: compactBody({ - startDate: params.startDate?.trim(), - endDate: params.endDate?.trim(), - }), - }, - (data) => ({ commercialMaturityHistory: asArray(data.commercialMaturityHistory) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_exit_probability_history.ts b/apps/sim/tools/cbinsights/get_exit_probability_history.ts index 2118f8257c4..0f43be6cf6f 100644 --- a/apps/sim/tools/cbinsights/get_exit_probability_history.ts +++ b/apps/sim/tools/cbinsights/get_exit_probability_history.ts @@ -1,19 +1,13 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - asString, - cbInsightsRequest, - compactBody, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsExitProbabilityHistoryParams extends CbInsightsOrgParams { +export interface CbInsightsExitProbabilityHistoryParams extends CbInsightsOrgParams { startDate?: string endDate?: string } -export const cbinsightsGetExitProbabilityHistoryTool: ToolConfig< +export const cbinsightsGetExitProbabilityHistoryTool: InternalToolConfig< CbInsightsExitProbabilityHistoryParams, ToolResponse > = { @@ -59,26 +53,8 @@ export const cbinsightsGetExitProbabilityHistoryTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ ipo?: unknown; mna?: unknown; incompleteRoundType?: unknown }>( - params, - { - path: `/v2/organizations/${orgId}/exitprobabilityhistory`, - body: compactBody({ - startDate: params.startDate?.trim(), - endDate: params.endDate?.trim(), - }), - }, - (data) => ({ - ipo: asArray(data.ipo), - mna: asArray(data.mna), - incompleteRoundType: asString(data.incompleteRoundType), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_mosaic_history.ts b/apps/sim/tools/cbinsights/get_mosaic_history.ts index b8a5d2bb1b4..aadc27063bc 100644 --- a/apps/sim/tools/cbinsights/get_mosaic_history.ts +++ b/apps/sim/tools/cbinsights/get_mosaic_history.ts @@ -1,12 +1,12 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, compactBody, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsMosaicHistoryParams extends CbInsightsOrgParams { +export interface CbInsightsMosaicHistoryParams extends CbInsightsOrgParams { startDate?: string } -export const cbinsightsGetMosaicHistoryTool: ToolConfig< +export const cbinsightsGetMosaicHistoryTool: InternalToolConfig< CbInsightsMosaicHistoryParams, ToolResponse > = { @@ -45,31 +45,8 @@ export const cbinsightsGetMosaicHistoryTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - overall?: unknown - management?: unknown - market?: unknown - momentum?: unknown - money?: unknown - }>( - params, - { - path: `/v2/organizations/${orgId}/mosaichistory`, - body: compactBody({ startDate: params.startDate?.trim() }), - }, - (data) => ({ - overall: asArray(data.overall), - management: asArray(data.management), - market: asArray(data.market), - momentum: asArray(data.momentum), - money: asArray(data.money), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_business_relationships.ts b/apps/sim/tools/cbinsights/get_org_business_relationships.ts index 9169dd92c89..8310cb80475 100644 --- a/apps/sim/tools/cbinsights/get_org_business_relationships.ts +++ b/apps/sim/tools/cbinsights/get_org_business_relationships.ts @@ -1,8 +1,8 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetOrgBusinessRelationshipsTool: ToolConfig< +export const cbinsightsGetOrgBusinessRelationshipsTool: InternalToolConfig< CbInsightsOrgParams, ToolResponse > = { @@ -34,16 +34,8 @@ export const cbinsightsGetOrgBusinessRelationshipsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ businessRelationships?: unknown }>( - params, - { path: `/v2/organizations/${orgId}/businessrelationships` }, - (data) => ({ businessRelationships: asArray(data.businessRelationships) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_funding_window.ts b/apps/sim/tools/cbinsights/get_org_funding_window.ts index ad01cfeda9e..740420adfd7 100644 --- a/apps/sim/tools/cbinsights/get_org_funding_window.ts +++ b/apps/sim/tools/cbinsights/get_org_funding_window.ts @@ -1,14 +1,11 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asNumber, - asRecord, - asString, - cbInsightsRequest, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetOrgFundingWindowTool: ToolConfig = { +export const cbinsightsGetOrgFundingWindowTool: InternalToolConfig< + CbInsightsOrgParams, + ToolResponse +> = { id: 'cbinsights_get_org_funding_window', name: 'CB Insights Get Organization Funding Window', description: @@ -37,28 +34,8 @@ export const cbinsightsGetOrgFundingWindowTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - windowStart?: unknown - windowEnd?: unknown - cohortNextRoundRate?: unknown - cohortCriteria?: unknown - latestFunding?: unknown - }>( - params, - { path: `/v2/organizations/${orgId}/fundingwindow` }, - (data) => ({ - windowStart: asString(data.windowStart), - windowEnd: asString(data.windowEnd), - cohortNextRoundRate: asNumber(data.cohortNextRoundRate), - cohortCriteria: asRecord(data.cohortCriteria), - latestFunding: asRecord(data.latestFunding), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_fundings.ts b/apps/sim/tools/cbinsights/get_org_fundings.ts index 7ae926c8d2e..8dca5300b90 100644 --- a/apps/sim/tools/cbinsights/get_org_fundings.ts +++ b/apps/sim/tools/cbinsights/get_org_fundings.ts @@ -1,20 +1,16 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsOrgFundingsParams extends CbInsightsOrgParams { +export interface CbInsightsOrgFundingsParams extends CbInsightsOrgParams { limit?: number | string nextPageToken?: string } -export const cbinsightsGetOrgFundingsTool: ToolConfig = { +export const cbinsightsGetOrgFundingsTool: InternalToolConfig< + CbInsightsOrgFundingsParams, + ToolResponse +> = { id: 'cbinsights_get_org_fundings', name: 'CB Insights Get Organization Fundings', description: @@ -55,32 +51,8 @@ export const cbinsightsGetOrgFundingsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - fundings?: unknown - capTableHistory?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: `/v2/organizations/${orgId}/financialtransactions/fundings`, - body: compactBody({ - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ - fundings: asArray(data.fundings), - capTableHistory: asArray(data.capTableHistory), - ...pageInfo(data), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_investments.ts b/apps/sim/tools/cbinsights/get_org_investments.ts index 4653f54a88c..1e99e94dea4 100644 --- a/apps/sim/tools/cbinsights/get_org_investments.ts +++ b/apps/sim/tools/cbinsights/get_org_investments.ts @@ -1,20 +1,13 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsOrgInvestmentsParams extends CbInsightsOrgParams { +export interface CbInsightsOrgInvestmentsParams extends CbInsightsOrgParams { limit?: number | string nextPageToken?: string } -export const cbinsightsGetOrgInvestmentsTool: ToolConfig< +export const cbinsightsGetOrgInvestmentsTool: InternalToolConfig< CbInsightsOrgInvestmentsParams, ToolResponse > = { @@ -58,27 +51,8 @@ export const cbinsightsGetOrgInvestmentsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - investments?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: `/v2/organizations/${orgId}/financialtransactions/investments`, - body: compactBody({ - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ investments: asArray(data.investments), ...pageInfo(data) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_management_and_board.ts b/apps/sim/tools/cbinsights/get_org_management_and_board.ts index d8c3ac7385c..7ac7e247b32 100644 --- a/apps/sim/tools/cbinsights/get_org_management_and_board.ts +++ b/apps/sim/tools/cbinsights/get_org_management_and_board.ts @@ -1,19 +1,12 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - asNumber, - cbInsightsRequest, - compactBody, - parseIdListParam, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -interface CbInsightsOrgManagementParams extends CbInsightsOrgParams { +export interface CbInsightsOrgManagementParams extends CbInsightsOrgParams { titleIds?: number[] | string } -export const cbinsightsGetOrgManagementAndBoardTool: ToolConfig< +export const cbinsightsGetOrgManagementAndBoardTool: InternalToolConfig< CbInsightsOrgManagementParams, ToolResponse > = { @@ -51,22 +44,8 @@ export const cbinsightsGetOrgManagementAndBoardTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ people?: unknown; mosaicManagement?: unknown }>( - params, - { - path: `/v2/organizations/${orgId}/managementandboard`, - body: compactBody({ titleIds: parseIdListParam(params.titleIds, 'titleIds') }), - }, - (data) => ({ - people: asArray(data.people), - mosaicManagement: asNumber(data.mosaicManagement), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_outlook.ts b/apps/sim/tools/cbinsights/get_org_outlook.ts index dd152e174f6..02f9665f25e 100644 --- a/apps/sim/tools/cbinsights/get_org_outlook.ts +++ b/apps/sim/tools/cbinsights/get_org_outlook.ts @@ -1,8 +1,8 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asRecord, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetOrgOutlookTool: ToolConfig = { +export const cbinsightsGetOrgOutlookTool: InternalToolConfig = { id: 'cbinsights_get_org_outlook', name: 'CB Insights Get Organization Outlook', description: @@ -31,24 +31,8 @@ export const cbinsightsGetOrgOutlookTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - mosaicScore?: unknown - commercialMaturity?: unknown - exitProbability?: unknown - }>( - params, - { path: `/v2/organizations/${orgId}/outlook` }, - (data) => ({ - mosaicScore: asRecord(data.mosaicScore), - commercialMaturity: asRecord(data.commercialMaturity), - exitProbability: asRecord(data.exitProbability), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_portfolio_exits.ts b/apps/sim/tools/cbinsights/get_org_portfolio_exits.ts index c070d0d4201..2d522a71232 100644 --- a/apps/sim/tools/cbinsights/get_org_portfolio_exits.ts +++ b/apps/sim/tools/cbinsights/get_org_portfolio_exits.ts @@ -1,8 +1,11 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetOrgPortfolioExitsTool: ToolConfig = { +export const cbinsightsGetOrgPortfolioExitsTool: InternalToolConfig< + CbInsightsOrgParams, + ToolResponse +> = { id: 'cbinsights_get_org_portfolio_exits', name: 'CB Insights Get Organization Portfolio Exits', description: @@ -31,16 +34,8 @@ export const cbinsightsGetOrgPortfolioExitsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ portfolioExits?: unknown }>( - params, - { path: `/v2/organizations/${orgId}/financialtransactions/portfolioexits` }, - (data) => ({ portfolioExits: asArray(data.portfolioExits) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_org_revenue.ts b/apps/sim/tools/cbinsights/get_org_revenue.ts index 0e07aa1c656..4b1ac78670d 100644 --- a/apps/sim/tools/cbinsights/get_org_revenue.ts +++ b/apps/sim/tools/cbinsights/get_org_revenue.ts @@ -1,14 +1,8 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - asNumber, - asString, - cbInsightsRequest, - requireOrgId, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetOrgRevenueTool: ToolConfig = { +export const cbinsightsGetOrgRevenueTool: InternalToolConfig = { id: 'cbinsights_get_org_revenue', name: 'CB Insights Get Organization Revenue', description: @@ -37,26 +31,8 @@ export const cbinsightsGetOrgRevenueTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - orgId?: unknown - orgName?: unknown - orgUrl?: unknown - revenue?: unknown - }>( - params, - { path: `/v2/organizations/${orgId}/revenuebyyear` }, - (data) => ({ - orgId: asNumber(data.orgId), - orgName: asString(data.orgName), - orgUrl: asString(data.orgUrl), - revenue: asArray(data.revenue), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_scouting_report.ts b/apps/sim/tools/cbinsights/get_scouting_report.ts index 8107d8047bf..1dc99e3c3f4 100644 --- a/apps/sim/tools/cbinsights/get_scouting_report.ts +++ b/apps/sim/tools/cbinsights/get_scouting_report.ts @@ -1,14 +1,11 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asRecord, - asString, - cbInsightsRequest, - requireOrgId, - SCOUTING_REPORT_TIMEOUT_MS, -} from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetScoutingReportTool: ToolConfig = { +export const cbinsightsGetScoutingReportTool: InternalToolConfig< + CbInsightsOrgParams, + ToolResponse +> = { id: 'cbinsights_get_scouting_report', name: 'CB Insights Get Scouting Report', description: @@ -37,27 +34,8 @@ export const cbinsightsGetScoutingReportTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ - orgInfo?: unknown - reportMarkdown?: unknown - reportJson?: unknown - }>( - params, - { - path: `/v2/organizations/${orgId}/scoutingreport`, - timeoutMs: SCOUTING_REPORT_TIMEOUT_MS, - }, - (data) => ({ - orgInfo: asRecord(data.orgInfo), - reportMarkdown: asString(data.reportMarkdown), - reportJson: asString(data.reportJson), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/get_strategy_map.ts b/apps/sim/tools/cbinsights/get_strategy_map.ts index eb4d997d234..60bf2ebba40 100644 --- a/apps/sim/tools/cbinsights/get_strategy_map.ts +++ b/apps/sim/tools/cbinsights/get_strategy_map.ts @@ -1,8 +1,8 @@ import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' -export const cbinsightsGetStrategyMapTool: ToolConfig = { +export const cbinsightsGetStrategyMapTool: InternalToolConfig = { id: 'cbinsights_get_strategy_map', name: 'CB Insights Get Strategy Map', description: @@ -31,20 +31,8 @@ export const cbinsightsGetStrategyMapTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const orgId = requireOrgId(params.orgId) - return cbInsightsRequest<{ orgName?: unknown; logoUrl?: unknown; categories?: unknown }>( - params, - { path: `/v2/organizations/${orgId}/strategymap` }, - (data) => ({ - orgName: asString(data.orgName), - logoUrl: asString(data.logoUrl), - categories: asArray(data.categories), - }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/list_business_relationships.ts b/apps/sim/tools/cbinsights/list_business_relationships.ts index dc56cf69254..127e2985d8a 100644 --- a/apps/sim/tools/cbinsights/list_business_relationships.ts +++ b/apps/sim/tools/cbinsights/list_business_relationships.ts @@ -2,20 +2,14 @@ import type { CbInsightsOrgListParams, CbInsightsPagedOrgListResponse, } from '@/tools/cbinsights/types' -import { - asArray, - asString, - cbInsightsRequest, - compactBody, - requireOrgIds, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListBusinessRelationshipsParams extends CbInsightsOrgListParams { +export interface CbInsightsListBusinessRelationshipsParams extends CbInsightsOrgListParams { nextPageToken?: string } -export const cbinsightsListBusinessRelationshipsTool: ToolConfig< +export const cbinsightsListBusinessRelationshipsTool: InternalToolConfig< CbInsightsListBusinessRelationshipsParams, CbInsightsPagedOrgListResponse > = { @@ -52,21 +46,9 @@ export const cbinsightsListBusinessRelationshipsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ orgs?: unknown; nextPageToken?: unknown }>( - params, - { - path: '/v2/businessrelationships', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ orgs: asArray(data.orgs), nextPageToken: asString(data.nextPageToken) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_funding_window.ts b/apps/sim/tools/cbinsights/list_funding_window.ts index 9829e664a7b..3f7d5beb8c4 100644 --- a/apps/sim/tools/cbinsights/list_funding_window.ts +++ b/apps/sim/tools/cbinsights/list_funding_window.ts @@ -1,10 +1,10 @@ import type { CbInsightsOrgListParams, CbInsightsOrgListResponse } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListFundingWindowParams extends CbInsightsOrgListParams {} +export interface CbInsightsListFundingWindowParams extends CbInsightsOrgListParams {} -export const cbinsightsListFundingWindowTool: ToolConfig< +export const cbinsightsListFundingWindowTool: InternalToolConfig< CbInsightsListFundingWindowParams, CbInsightsOrgListResponse > = { @@ -35,20 +35,9 @@ export const cbinsightsListFundingWindowTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ orgs?: unknown }>( - params, - { - path: '/v2/outlook/fundingwindow', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - }), - }, - (data) => ({ orgs: asArray(data.orgs) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_fundings.ts b/apps/sim/tools/cbinsights/list_fundings.ts index 2e84624bf27..af12dafa3af 100644 --- a/apps/sim/tools/cbinsights/list_fundings.ts +++ b/apps/sim/tools/cbinsights/list_fundings.ts @@ -1,20 +1,13 @@ import type { CbInsightsListResponse, CbInsightsOrgListParams } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - requireOrgIds, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListFundingsParams extends CbInsightsOrgListParams { +export interface CbInsightsListFundingsParams extends CbInsightsOrgListParams { limit?: number | string nextPageToken?: string } -export const cbinsightsListFundingsTool: ToolConfig< +export const cbinsightsListFundingsTool: InternalToolConfig< CbInsightsListFundingsParams, CbInsightsListResponse > = { @@ -57,27 +50,9 @@ export const cbinsightsListFundingsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ - orgs?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: '/v2/financialtransactions/fundings', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_investments.ts b/apps/sim/tools/cbinsights/list_investments.ts index 5fc5afef73b..663c7d38a1b 100644 --- a/apps/sim/tools/cbinsights/list_investments.ts +++ b/apps/sim/tools/cbinsights/list_investments.ts @@ -1,20 +1,13 @@ import type { CbInsightsListResponse, CbInsightsOrgListParams } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - requireOrgIds, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListInvestmentsParams extends CbInsightsOrgListParams { +export interface CbInsightsListInvestmentsParams extends CbInsightsOrgListParams { limit?: number | string nextPageToken?: string } -export const cbinsightsListInvestmentsTool: ToolConfig< +export const cbinsightsListInvestmentsTool: InternalToolConfig< CbInsightsListInvestmentsParams, CbInsightsListResponse > = { @@ -57,27 +50,9 @@ export const cbinsightsListInvestmentsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ - orgs?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: '/v2/financialtransactions/investments', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_management_and_board.ts b/apps/sim/tools/cbinsights/list_management_and_board.ts index 3ce7c4ff99d..67edfbf26df 100644 --- a/apps/sim/tools/cbinsights/list_management_and_board.ts +++ b/apps/sim/tools/cbinsights/list_management_and_board.ts @@ -1,18 +1,12 @@ import type { CbInsightsOrgListParams, CbInsightsOrgListResponse } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - compactBody, - parseIdListParam, - requireOrgIds, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListManagementAndBoardParams extends CbInsightsOrgListParams { +export interface CbInsightsListManagementAndBoardParams extends CbInsightsOrgListParams { titleIds?: number[] | string } -export const cbinsightsListManagementAndBoardTool: ToolConfig< +export const cbinsightsListManagementAndBoardTool: InternalToolConfig< CbInsightsListManagementAndBoardParams, CbInsightsOrgListResponse > = { @@ -49,21 +43,9 @@ export const cbinsightsListManagementAndBoardTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ orgs?: unknown }>( - params, - { - path: '/v2/managementandboard', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - titleIds: parseIdListParam(params.titleIds, 'titleIds'), - }), - }, - (data) => ({ orgs: asArray(data.orgs) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_outlook.ts b/apps/sim/tools/cbinsights/list_outlook.ts index 1b9a3feae97..908d82f19ae 100644 --- a/apps/sim/tools/cbinsights/list_outlook.ts +++ b/apps/sim/tools/cbinsights/list_outlook.ts @@ -1,10 +1,10 @@ import type { CbInsightsOrgListParams, CbInsightsOrgListResponse } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListOutlookParams extends CbInsightsOrgListParams {} +export interface CbInsightsListOutlookParams extends CbInsightsOrgListParams {} -export const cbinsightsListOutlookTool: ToolConfig< +export const cbinsightsListOutlookTool: InternalToolConfig< CbInsightsListOutlookParams, CbInsightsOrgListResponse > = { @@ -35,20 +35,9 @@ export const cbinsightsListOutlookTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ orgs?: unknown }>( - params, - { - path: '/v2/outlook', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - }), - }, - (data) => ({ orgs: asArray(data.orgs) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_portfolio_exits.ts b/apps/sim/tools/cbinsights/list_portfolio_exits.ts index efb0f669559..2333028f76a 100644 --- a/apps/sim/tools/cbinsights/list_portfolio_exits.ts +++ b/apps/sim/tools/cbinsights/list_portfolio_exits.ts @@ -1,20 +1,13 @@ import type { CbInsightsListResponse, CbInsightsOrgListParams } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - requireOrgIds, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListPortfolioExitsParams extends CbInsightsOrgListParams { +export interface CbInsightsListPortfolioExitsParams extends CbInsightsOrgListParams { limit?: number | string nextPageToken?: string } -export const cbinsightsListPortfolioExitsTool: ToolConfig< +export const cbinsightsListPortfolioExitsTool: InternalToolConfig< CbInsightsListPortfolioExitsParams, CbInsightsListResponse > = { @@ -57,27 +50,9 @@ export const cbinsightsListPortfolioExitsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ - orgs?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: '/v2/financialtransactions/portfolioexits', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/list_revenue.ts b/apps/sim/tools/cbinsights/list_revenue.ts index aa51df39275..d900a89e7f3 100644 --- a/apps/sim/tools/cbinsights/list_revenue.ts +++ b/apps/sim/tools/cbinsights/list_revenue.ts @@ -1,10 +1,10 @@ import type { CbInsightsOrgListParams, CbInsightsOrgListResponse } from '@/tools/cbinsights/types' -import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsListRevenueParams extends CbInsightsOrgListParams {} +export interface CbInsightsListRevenueParams extends CbInsightsOrgListParams {} -export const cbinsightsListRevenueTool: ToolConfig< +export const cbinsightsListRevenueTool: InternalToolConfig< CbInsightsListRevenueParams, CbInsightsOrgListResponse > = { @@ -35,20 +35,9 @@ export const cbinsightsListRevenueTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => - cbInsightsRequest<{ orgs?: unknown }>( - params, - { - path: '/v2/revenuebyyear', - body: compactBody({ - orgIds: requireOrgIds(params.orgIds), - }), - }, - (data) => ({ orgs: asArray(data.orgs) }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { orgs: { diff --git a/apps/sim/tools/cbinsights/lookup_organizations.ts b/apps/sim/tools/cbinsights/lookup_organizations.ts index 4d3f7d597d6..9fc8118821a 100644 --- a/apps/sim/tools/cbinsights/lookup_organizations.ts +++ b/apps/sim/tools/cbinsights/lookup_organizations.ts @@ -1,15 +1,8 @@ import type { CbInsightsAuthParams, CbInsightsListResponse } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - parseStringListParam, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsLookupOrganizationsParams extends CbInsightsAuthParams { +export interface CbInsightsLookupOrganizationsParams extends CbInsightsAuthParams { names?: string[] | string urls?: string[] | string profileUrl?: string @@ -17,7 +10,7 @@ interface CbInsightsLookupOrganizationsParams extends CbInsightsAuthParams { nextPageToken?: string } -export const cbinsightsLookupOrganizationsTool: ToolConfig< +export const cbinsightsLookupOrganizationsTool: InternalToolConfig< CbInsightsLookupOrganizationsParams, CbInsightsListResponse > = { @@ -73,44 +66,8 @@ export const cbinsightsLookupOrganizationsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const names = parseStringListParam(params.names, 'names') - const urls = parseStringListParam(params.urls, 'urls') - const profileUrl = params.profileUrl?.trim() - - if (!names && !urls && !profileUrl) { - throw new Error( - 'CB Insights lookup requires at least one of "names", "urls", or "profileUrl"' - ) - } - if (profileUrl && (names || urls)) { - throw new Error( - 'CB Insights rejects "profileUrl" combined with "names" or "urls" — pass only one' - ) - } - - return cbInsightsRequest<{ - orgs?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { - path: '/v2/organizations', - body: compactBody({ - names, - urls, - profileUrl, - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - }, - (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/rag.ts b/apps/sim/tools/cbinsights/rag.ts index 4ee7fbc5f7c..7bd6caa2689 100644 --- a/apps/sim/tools/cbinsights/rag.ts +++ b/apps/sim/tools/cbinsights/rag.ts @@ -1,12 +1,12 @@ import type { CbInsightsAuthParams, CbInsightsRagResponse } from '@/tools/cbinsights/types' -import { asString, asStringArray, cbInsightsRequest } from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsRagParams extends CbInsightsAuthParams { +export interface CbInsightsRagParams extends CbInsightsAuthParams { message: string } -export const cbinsightsRagTool: ToolConfig = { +export const cbinsightsRagTool: InternalToolConfig = { id: 'cbinsights_rag', name: 'CB Insights Retrieve Context', description: @@ -34,36 +34,14 @@ export const cbinsightsRagTool: ToolConfig '', - method: 'POST', - headers: () => ({}), - /** - * The message is the query CB Insights feeds to its own retrieval model — - * the endpoint documentation states so directly — so an activated Sim - * secret is projected to its canonical label before it leaves Sim. - */ + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ message: params.message }), }, }, - directExecution: async (params, signal) => { - const message = params.message?.trim() - if (!message) throw new Error('CB Insights "message" is required') - if (message.length > 10_000) { - throw new Error('CB Insights "message" must be under 10,000 characters') - } - - return cbInsightsRequest<{ data?: unknown; guidance?: unknown }>( - params, - { path: '/v2/cbirag', body: { message } }, - (data) => ({ data: asString(data.data), guidance: asStringArray(data.guidance) }), - signal - ) - }, - outputs: { data: { type: 'string', diff --git a/apps/sim/tools/cbinsights/search_firmographics.ts b/apps/sim/tools/cbinsights/search_firmographics.ts index 422a222d8db..16c4b799c9e 100644 --- a/apps/sim/tools/cbinsights/search_firmographics.ts +++ b/apps/sim/tools/cbinsights/search_firmographics.ts @@ -1,19 +1,8 @@ import type { CbInsightsAuthParams, CbInsightsListResponse } from '@/tools/cbinsights/types' -import { - asArray, - cbInsightsRequest, - clampLimit, - compactBody, - pageInfo, - parseBooleanParam, - parseIdListParam, - parseIntegerParam, - parseNumberParam, - parseStringListParam, -} from '@/tools/cbinsights/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -interface CbInsightsFirmographicsParams extends CbInsightsAuthParams { +export interface CbInsightsFirmographicsParams extends CbInsightsAuthParams { keyword?: string orgIds?: number[] | string orgNames?: string[] | string @@ -60,7 +49,7 @@ interface CbInsightsFirmographicsParams extends CbInsightsAuthParams { * mistyped `"ascending"` would silently reverse the page and hand back the * bottom of the result set as though it were the top, on a metered search. */ -function sortDirection(value: unknown): 'asc' | 'desc' { +export function sortDirection(value: unknown): 'asc' | 'desc' { if (value === undefined || value === null) return 'desc' const normalized = String(value).trim().toLowerCase() if (normalized === '') return 'desc' @@ -70,7 +59,7 @@ function sortDirection(value: unknown): 'asc' | 'desc' { ) } -export const cbinsightsSearchFirmographicsTool: ToolConfig< +export const cbinsightsSearchFirmographicsTool: InternalToolConfig< CbInsightsFirmographicsParams, CbInsightsListResponse > = { @@ -321,99 +310,8 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig< }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - - directExecution: async (params, signal) => { - const filters = compactBody({ - keyword: params.keyword?.trim(), - orgIds: parseIdListParam(params.orgIds, 'orgIds'), - orgNames: parseStringListParam(params.orgNames, 'orgNames'), - urls: parseStringListParam(params.urls, 'urls'), - tickers: parseStringListParam(params.tickers, 'tickers'), - marketIds: parseIdListParam(params.marketIds, 'marketIds'), - marketNames: parseStringListParam(params.marketNames, 'marketNames'), - industryIds: parseIdListParam(params.industryIds, 'industryIds'), - sectorIds: parseIdListParam(params.sectorIds, 'sectorIds'), - subindustryIds: parseIdListParam(params.subindustryIds, 'subindustryIds'), - businessModelIds: parseIdListParam(params.businessModelIds, 'businessModelIds'), - technologyIds: parseIdListParam(params.technologyIds, 'technologyIds'), - collectionIds: parseIdListParam(params.collectionIds, 'collectionIds'), - countryIds: parseIdListParam(params.countryIds, 'countryIds'), - stateProvinceIds: parseIdListParam(params.stateProvinceIds, 'stateProvinceIds'), - cityIds: parseIdListParam(params.cityIds, 'cityIds'), - continentIds: parseIdListParam(params.continentIds, 'continentIds'), - regionIds: parseIdListParam(params.regionIds, 'regionIds'), - orgStatusIds: parseIdListParam(params.orgStatusIds, 'orgStatusIds'), - investorOrgIds: parseIdListParam(params.investorOrgIds, 'investorOrgIds'), - investorTypeIds: parseIdListParam(params.investorTypeIds, 'investorTypeIds'), - fundingInvestorTypeIds: parseIdListParam( - params.fundingInvestorTypeIds, - 'fundingInvestorTypeIds' - ), - lastFundingRoundIds: parseIdListParam(params.lastFundingRoundIds, 'lastFundingRoundIds'), - lastFundingRoundCategoryIds: parseIdListParam( - params.lastFundingRoundCategoryIds, - 'lastFundingRoundCategoryIds' - ), - minCurrentHeadcount: parseIntegerParam(params.minCurrentHeadcount, 'minCurrentHeadcount'), - maxCurrentHeadcount: parseIntegerParam(params.maxCurrentHeadcount, 'maxCurrentHeadcount'), - minTotalFundingInMillions: parseNumberParam( - params.minTotalFundingInMillions, - 'minTotalFundingInMillions' - ), - maxTotalFundingInMillions: parseNumberParam( - params.maxTotalFundingInMillions, - 'maxTotalFundingInMillions' - ), - minValuationInMillions: parseNumberParam( - params.minValuationInMillions, - 'minValuationInMillions' - ), - maxValuationInMillions: parseNumberParam( - params.maxValuationInMillions, - 'maxValuationInMillions' - ), - minLastFundingDate: params.minLastFundingDate?.trim(), - maxLastFundingDate: params.maxLastFundingDate?.trim(), - vcBacked: parseBooleanParam(params.vcBacked, 'vcBacked'), - }) - - /* - * The guard has to measure the *filters* alone. Folding limit, the page - * token, or the sort into the same object would let a request carrying only - * paging past it — which is an unfiltered search over the whole database, - * and it still spends credits. - */ - if (Object.keys(filters).length === 0) { - throw new Error('CB Insights firmographics search requires at least one search parameter') - } - - const body: Record = { - ...filters, - ...compactBody({ - limit: clampLimit(params.limit), - nextPageToken: params.nextPageToken?.trim(), - }), - } - - /* The API takes one sort object; the block exposes it as two plain fields - so neither has to be typed as JSON. */ - const sortField = params.sortField?.trim() - if (sortField) { - body.sort = { field: sortField, direction: sortDirection(params.sortDirection) } - } - - return cbInsightsRequest<{ - orgs?: unknown - nextPageToken?: unknown - totalHits?: unknown - totalHitsRelation?: unknown - }>( - params, - { path: '/v2/firmographics', body }, - (data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }), - signal - ) + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/cbinsights/utils.ts b/apps/sim/tools/cbinsights/utils.ts index 1b3704e375b..db88c45bb25 100644 --- a/apps/sim/tools/cbinsights/utils.ts +++ b/apps/sim/tools/cbinsights/utils.ts @@ -418,6 +418,25 @@ export function parseIdListParam(value: unknown, paramName: string): number[] | return toPositiveIntegers(entries, paramName) } +/** Parses an optional organization-ID filter while enforcing the shared request ceiling. */ +export function parseOptionalOrgIds(value: unknown): number[] | undefined { + const orgIds = parseIdListParam(value, 'orgIds') + if (orgIds && orgIds.length > MAX_ORG_IDS) { + throw new Error(`CB Insights accepts at most ${MAX_ORG_IDS} organization IDs per request`) + } + return orgIds +} + +/** Trims an optional text parameter and rejects non-text runtime values. */ +export function parseOptionalStringParam(value: unknown, paramName: string): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value !== 'string') { + throw new Error(`CB Insights "${paramName}" must be a string`) + } + const trimmed = value.trim() + return trimmed || undefined +} + /** * Parses a list of free-text values, rejecting an entry that is not text. * diff --git a/apps/sim/tools/cloudflare/cloudflare.test.ts b/apps/sim/tools/cloudflare/cloudflare.test.ts index 022ae323cc1..01e65b0a60d 100644 --- a/apps/sim/tools/cloudflare/cloudflare.test.ts +++ b/apps/sim/tools/cloudflare/cloudflare.test.ts @@ -11,6 +11,7 @@ * control is deliberately not last, so re-introducing a collision goes red. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { executeGetZoneSettingsOperation } from '@/lib/internal/cloudflare/operations/get-zone-settings' import { CloudflareBlock } from '@/blocks/blocks/cloudflare' import * as cloudflareTools from '@/tools/cloudflare' @@ -908,9 +909,8 @@ describe('zone settings are read through the endpoints Cloudflare still supports * endpoint instead. */ it('does not declare the deprecated batch settings endpoint', () => { - const declaredUrl = tool.request.url({ zoneId: 'z1', apiKey } as never) - expect(declaredUrl).not.toMatch(/\/zones\/z1\/settings$/) - expect(declaredUrl).toMatch(/\/zones\/z1\/settings\/[a-z0-9_]+$/) + expect(tool.operation).toBeDefined() + expect('request' in tool).toBe(false) }) it('issues one request per setting against the per-setting endpoint', async () => { @@ -918,7 +918,11 @@ describe('zone settings are read through the endpoints Cloudflare still supports .spyOn(globalThis, 'fetch') .mockImplementation(async (input) => settingEnvelope(String(input).split('/').pop()!, 'on')) - await tool.directExecution!({ zoneId: 'z1', apiKey, settingIds: 'ssl,http3' } as never) + await executeGetZoneSettingsOperation({ + zoneId: 'z1', + apiKey, + settingIds: 'ssl,http3', + } as never) expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ 'https://api.cloudflare.com/client/v4/zones/z1/settings/ssl', @@ -926,12 +930,60 @@ describe('zone settings are read through the endpoints Cloudflare still supports ]) }) + it('keeps a crafted zone ID inside the zone path segment', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => settingEnvelope(String(input).split('/').pop()!, 'on')) + + await executeGetZoneSettingsOperation({ + zoneId: '../accounts', + apiKey, + settingIds: 'ssl', + } as never) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.cloudflare.com/client/v4/zones/..%2Faccounts/settings/ssl', + expect.any(Object) + ) + }) + + it('does not let a dot-segment setting ID escape the settings endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + + const out = (await executeGetZoneSettingsOperation({ + zoneId: 'z1', + apiKey, + settingIds: '..', + } as never)) as { success: boolean; error?: string } + + expect(out.success).toBe(false) + expect(out.error).toMatch(/setting ID must identify a resource/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('propagates cancellation instead of returning partial settings', async () => { + const controller = new AbortController() + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const settingId = String(input).split('/').pop()! + if (settingId === 'ssl') return settingEnvelope(settingId, 'full') + controller.abort(new DOMException('cancelled', 'AbortError')) + throw controller.signal.reason + }) + + await expect( + executeGetZoneSettingsOperation( + { zoneId: 'z1', apiKey, settingIds: 'ssl,http3' } as never, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('returns each setting under the list shape the block already reads', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => settingEnvelope(String(input).split('/').pop()!, 'full') ) - const out = (await tool.directExecution!({ + const out = (await executeGetZoneSettingsOperation({ zoneId: 'z1', apiKey, settingIds: 'ssl', @@ -966,7 +1018,7 @@ describe('zone settings are read through the endpoints Cloudflare still supports return settingEnvelope(settingId, 'full') }) - const out = (await tool.directExecution!({ + const out = (await executeGetZoneSettingsOperation({ zoneId: 'z1', apiKey, settingIds: 'ssl,http3', @@ -988,7 +1040,7 @@ describe('zone settings are read through the endpoints Cloudflare still supports ) ) - const out = (await tool.directExecution!({ + const out = (await executeGetZoneSettingsOperation({ zoneId: 'z1', apiKey, settingIds: 'ssl', @@ -1001,7 +1053,7 @@ describe('zone settings are read through the endpoints Cloudflare still supports it('refuses an unbounded fan-out instead of issuing the requests', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') - const out = (await tool.directExecution!({ + const out = (await executeGetZoneSettingsOperation({ zoneId: 'z1', apiKey, settingIds: Array.from({ length: 41 }, (_, index) => `setting_${index}`).join(','), diff --git a/apps/sim/tools/cloudflare/get_zone_settings.ts b/apps/sim/tools/cloudflare/get_zone_settings.ts index a4db18488e3..11d3f8526aa 100644 --- a/apps/sim/tools/cloudflare/get_zone_settings.ts +++ b/apps/sim/tools/cloudflare/get_zone_settings.ts @@ -1,22 +1,23 @@ -import { getErrorMessage } from '@sim/utils/errors' import type { - CloudflareEnvelope, CloudflareGetZoneSettingsParams, CloudflareGetZoneSettingsResponse, CloudflareRawZoneSetting, } from '@/tools/cloudflare/types' -import { - cloudflareErrorMessage, - cloudflareHeaders, - DEFAULT_ZONE_SETTING_IDS, - MAX_ZONE_SETTING_IDS, - requestedZoneSettingIds, -} from '@/tools/cloudflare/utils' -import type { ToolConfig } from '@/tools/types' +import { DEFAULT_ZONE_SETTING_IDS, MAX_ZONE_SETTING_IDS } from '@/tools/cloudflare/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' + +function encodePathSegment(value: string, label: string): string { + const normalized = value.trim() + if (!normalized || normalized === '.' || normalized === '..') { + throw new Error(`${label} must identify a resource`) + } + return encodeURIComponent(normalized) +} /** Builds the per-setting endpoint Cloudflare directs integrations at. */ -function zoneSettingUrl(zoneId: string, settingId: string): string { - return `https://api.cloudflare.com/client/v4/zones/${zoneId}/settings/${encodeURIComponent(settingId)}` +export function zoneSettingUrl(zoneId: string, settingId: string): string { + return `https://api.cloudflare.com/client/v4/zones/${encodePathSegment(zoneId, 'Cloudflare zone ID')}/settings/${encodePathSegment(settingId, 'Cloudflare setting ID')}` } /** @@ -24,7 +25,7 @@ function zoneSettingUrl(zoneId: string, settingId: string): string { * (minify, security header, NEL) as objects, so those are JSON-stringified to * keep every entry in the list a string. */ -function mapZoneSetting(settingId: string, setting: CloudflareRawZoneSetting | undefined) { +export function mapZoneSetting(settingId: string, setting: CloudflareRawZoneSetting | undefined) { return { id: setting?.id ?? settingId, value: @@ -37,7 +38,7 @@ function mapZoneSetting(settingId: string, setting: CloudflareRawZoneSetting | u } } -export const getZoneSettingsTool: ToolConfig< +export const getZoneSettingsTool: InternalToolConfig< CloudflareGetZoneSettingsParams, CloudflareGetZoneSettingsResponse > = { @@ -67,77 +68,8 @@ export const getZoneSettingsTool: ToolConfig< }, }, - request: { - url: (params) => - zoneSettingUrl(params.zoneId.trim(), requestedZoneSettingIds(params.settingIds)[0]), - method: 'GET', - headers: (params) => cloudflareHeaders(params.apiKey), - }, - - /** - * Cloudflare deprecated the batch `GET /zones/{zone_id}/settings` endpoint, - * which reaches end of life on 2027-03-31, in favour of one request per - * setting. The reads are fanned out and gathered back into the single list - * this tool has always returned. - * - * A setting the zone's plan does not expose answers with an error rather than - * a value, so one refusal must not lose the settings that did come back. Those - * ids are reported in `unreadable` instead, and only a read where nothing at - * all was readable fails. - * https://developers.cloudflare.com/fundamentals/api/reference/deprecations/ - */ - directExecution: async (params, signal) => { - const settingIds = requestedZoneSettingIds(params.settingIds) - if (settingIds.length > MAX_ZONE_SETTING_IDS) { - return { - success: false, - output: { settings: [], unreadable: [] }, - error: `Too many settings requested: ${settingIds.length}. Cloudflare reads one setting per request, so at most ${MAX_ZONE_SETTING_IDS} can be read in a single call.`, - } - } - - const zoneId = params.zoneId.trim() - const headers = cloudflareHeaders(params.apiKey) - - const reads = await Promise.all( - settingIds.map(async (settingId) => { - try { - const response = await fetch(zoneSettingUrl(zoneId, settingId), { - method: 'GET', - headers, - signal, - }) - const data = (await response.json()) as CloudflareEnvelope - if (!data.success) { - return { - settingId, - error: cloudflareErrorMessage(data, `Failed to read zone setting ${settingId}`), - } - } - return { settingId, setting: mapZoneSetting(settingId, data.result) } - } catch (error) { - return { - settingId, - error: getErrorMessage(error, `Failed to read zone setting ${settingId}`), - } - } - }) - ) - - const settings = reads.flatMap((read) => (read.setting ? [read.setting] : [])) - const unreadable = reads.flatMap((read) => - read.error ? [{ id: read.settingId, error: read.error }] : [] - ) - - if (settings.length === 0) { - return { - success: false, - output: { settings, unreadable }, - error: unreadable[0]?.error ?? 'Failed to get zone settings', - } - } - - return { success: true, output: { settings, unreadable } } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/datadog/datadog.test.ts b/apps/sim/tools/datadog/datadog.test.ts index 620aa992f79..9fb3166d3cf 100644 --- a/apps/sim/tools/datadog/datadog.test.ts +++ b/apps/sim/tools/datadog/datadog.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeUpdateSloOperation } from '@/lib/internal/datadog/operations/update-slo' import { cancelDowntimeTool } from '@/tools/datadog/cancel_downtime' import { createDowntimeTool } from '@/tools/datadog/create_downtime' import { createEventTool } from '@/tools/datadog/create_event' @@ -19,7 +20,6 @@ import { sendLogsTool } from '@/tools/datadog/send_logs' import { submitMetricsTool } from '@/tools/datadog/submit_metrics' import { unmuteMonitorTool } from '@/tools/datadog/unmute_monitor' import { updateIncidentTool } from '@/tools/datadog/update_incident' -import { updateSloTool } from '@/tools/datadog/update_slo' import { buildSloPayload, datadogErrorMessage, @@ -168,7 +168,7 @@ describe('update_slo read-modify-write', () => { ) .mockResolvedValueOnce(jsonResponse({ data: [{ id: 'slo-1', name: 'New' }] })) - const result = await updateSloTool.directExecution!( + const result = await executeUpdateSloOperation( { ...auth, sloId: 'slo-1', name: 'New' } as any, undefined ) @@ -187,7 +187,7 @@ describe('update_slo read-modify-write', () => { .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(jsonResponse({ errors: ['SLO not found'] }, { status: 404 })) - const result = await updateSloTool.directExecution!( + const result = await executeUpdateSloOperation( { ...auth, sloId: 'missing', name: 'New' } as any, undefined ) diff --git a/apps/sim/tools/datadog/update_slo.ts b/apps/sim/tools/datadog/update_slo.ts index 4df6b11db55..5b17edce592 100644 --- a/apps/sim/tools/datadog/update_slo.ts +++ b/apps/sim/tools/datadog/update_slo.ts @@ -1,14 +1,8 @@ import type { UpdateSloParams, UpdateSloResponse } from '@/tools/datadog/types' -import { - datadogApiUrl, - datadogErrorMessage, - datadogHeaders, - datadogPathSegment, - mergeSloUpdatePayload, -} from '@/tools/datadog/utils' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const updateSloTool: ToolConfig = { +export const updateSloTool: InternalToolConfig = { id: 'datadog_update_slo', name: 'Datadog Update SLO', description: @@ -111,61 +105,8 @@ export const updateSloTool: ToolConfig = { }, }, - request: { - url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`), - method: 'PUT', - headers: datadogHeaders, - }, - - /** - * Datadog's SLO update is a full replacement, so the stored SLO is read first and - * the supplied edits are overlaid onto it. Sending only the filled-in fields would - * erase every field the caller left blank. - */ - directExecution: async (params, signal) => { - const url = datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`) - const headers = datadogHeaders(params) - - const existingResponse = await fetch(url, { method: 'GET', headers, signal }) - if (!existingResponse.ok) { - return { - success: false, - output: { slo: { id: '', name: '', type: '' } }, - error: `Could not load SLO ${params.sloId} before updating it: ${await datadogErrorMessage(existingResponse)}`, - } - } - - const existing = await existingResponse.json() - const stored = existing.data - if (!stored || typeof stored !== 'object') { - return { - success: false, - output: { slo: { id: '', name: '', type: '' } }, - error: `Datadog returned no SLO for id ${params.sloId}`, - } - } - - const response = await fetch(url, { - method: 'PUT', - headers, - body: JSON.stringify(mergeSloUpdatePayload(stored, params)), - signal, - }) - - if (!response.ok) { - return { - success: false, - output: { slo: { id: '', name: '', type: '' } }, - error: await datadogErrorMessage(response), - } - } - - const data = await response.json() - - return { - success: true, - output: { slo: data.data?.[0] ?? { id: '', name: '', type: '' } }, - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/extend/parser.ts b/apps/sim/tools/extend/parser.ts index 62e80b8126b..605ed04d288 100644 --- a/apps/sim/tools/extend/parser.ts +++ b/apps/sim/tools/extend/parser.ts @@ -183,7 +183,6 @@ export const extendParserV2Tool: InternalToolConfig extendParserTool.transformResponse!(response, params) diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index 2729b2cb205..053ce3c3372 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -13,7 +13,6 @@ describe('Function Execute Tool', () => { it('declares an in-process operation without HTTP-shaped configuration', () => { expect(functionExecuteTool.operation).toBeDefined() expect('request' in functionExecuteTool).toBe(false) - expect('directExecution' in functionExecuteTool).toBe(false) }) it('materializes the canonical operation input', () => { diff --git a/apps/sim/tools/github/comment.test.ts b/apps/sim/tools/github/comment.test.ts index e1888b26438..45b68e60eb8 100644 --- a/apps/sim/tools/github/comment.test.ts +++ b/apps/sim/tools/github/comment.test.ts @@ -2,6 +2,10 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + executeGitHubCommentOperation, + executeGitHubCommentV2Operation, +} from '@/lib/internal/github/operations' import { commentTool, commentV2Tool } from '@/tools/github/comment' import type { CreateCommentParams } from '@/tools/github/types' @@ -44,6 +48,15 @@ function createdCommentResponse(): Response { }) } +function createdReviewResponse(): Response { + return Response.json({ + id: 100, + body: 'Nice', + html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-100', + submitted_at: '2026-01-02T00:00:00Z', + }) +} + interface RecordedCall { url: string method: string @@ -75,7 +88,7 @@ describe('github_comment routing', () => { apiKey: 'ghp_test', } - await commentTool.directExecution!(params) + await executeGitHubCommentOperation(params) expect(calls()).toEqual([ { @@ -90,7 +103,7 @@ describe('github_comment routing', () => { it('leaves a general PR comment on the reviews endpoint', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ owner: 'octo', repo: 'demo', pullNumber: 7, @@ -117,7 +130,7 @@ describe('github_comment routing', () => { it('keeps a general PR comment carrying a path on the reviews endpoint', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ owner: 'octo', repo: 'demo', pullNumber: 7, @@ -140,7 +153,7 @@ describe('github_comment routing', () => { it('keeps a comment with no type carrying a path on the reviews endpoint', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ owner: 'octo', repo: 'demo', pullNumber: 7, @@ -163,13 +176,13 @@ describe('github_comment routing', () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) const { path, ...params } = FILE_COMMENT_PARAMS - await commentTool.directExecution!(params) + await executeGitHubCommentOperation(params) expect(calls()).toEqual([ { url: 'https://api.github.com/repos/octo/demo/pulls/7/reviews', method: 'POST', - body: { body: 'Looks good', line: 42, side: 'RIGHT' }, + body: { body: 'Looks good', event: 'COMMENT' }, signal: undefined, }, ]) @@ -180,7 +193,7 @@ describe('github_comment routing', () => { .mockResolvedValueOnce(pullRequestResponse()) .mockResolvedValueOnce(createdCommentResponse()) - const result = await commentTool.directExecution!(FILE_COMMENT_PARAMS) + const result = await executeGitHubCommentOperation(FILE_COMMENT_PARAMS) expect(calls()).toEqual([ { @@ -208,7 +221,7 @@ describe('github_comment routing', () => { it('posts directly when commitId is supplied', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA }) + await executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA }) expect(calls()).toEqual([ { @@ -226,12 +239,46 @@ describe('github_comment routing', () => { ]) }) + it('creates a file-level comment when no diff line is supplied', async () => { + secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) + + await executeGitHubCommentOperation({ + ...FILE_COMMENT_PARAMS, + commitId: OTHER_SHA, + line: undefined, + }) + + expect(calls()[0].body).toEqual({ + body: 'Looks good', + commit_id: OTHER_SHA, + path: 'src/main.ts', + subject_type: 'file', + }) + }) + + it('normalizes review submitted_at into the documented timestamps', async () => { + secureGitHubRequest.mockResolvedValueOnce(createdReviewResponse()) + + const result = await executeGitHubCommentV2Operation({ + owner: 'octo', + repo: 'demo', + pullNumber: 7, + body: 'Nice', + apiKey: 'ghp_test', + }) + + expect(result.output).toMatchObject({ + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }) + }) + it('resolves the head SHA for the v2 tool as well', async () => { secureGitHubRequest .mockResolvedValueOnce(pullRequestResponse()) .mockResolvedValueOnce(createdCommentResponse()) - const result = await commentV2Tool.directExecution!(FILE_COMMENT_PARAMS) + const result = await executeGitHubCommentV2Operation(FILE_COMMENT_PARAMS) expect(calls()[1].body).toMatchObject({ commit_id: HEAD_SHA }) expect(result.output.commit_id).toBe(HEAD_SHA) @@ -241,31 +288,11 @@ describe('github_comment routing', () => { expect(commentTool.params.position).toBeUndefined() }) - it('routes every comment type / path combination to the endpoint that accepts it', () => { - const url = commentTool.request.url as (params: CreateCommentParams) => string - const base = { owner: 'octo', repo: 'demo', pullNumber: 7, body: 'Nice', apiKey: 'ghp_test' } - const reviews = 'https://api.github.com/repos/octo/demo/pulls/7/reviews' - const comments = 'https://api.github.com/repos/octo/demo/pulls/7/comments' - - expect(url({ ...base, commitId: OTHER_SHA })).toBe(reviews) - expect(url({ ...base, commitId: OTHER_SHA, path: 'src/main.ts' })).toBe(reviews) - expect(url({ ...base, commitId: OTHER_SHA, commentType: 'pr_comment' })).toBe(reviews) - expect( - url({ ...base, commitId: OTHER_SHA, commentType: 'pr_comment', path: 'src/main.ts' }) - ).toBe(reviews) - expect(url({ ...base, commitId: OTHER_SHA, commentType: 'file_comment' })).toBe(reviews) - expect( - url({ ...base, commitId: OTHER_SHA, commentType: 'file_comment', path: 'src/main.ts' }) - ).toBe(comments) - }) - - it('keeps the declarative request in step with the executed routing', () => { - const url = commentTool.request.url as (params: CreateCommentParams) => string - const method = commentTool.request.method as (params: CreateCommentParams) => string - - expect(url(FILE_COMMENT_PARAMS)).toBe('https://api.github.com/repos/octo/demo/pulls/7') - expect(method(FILE_COMMENT_PARAMS)).toBe('GET') - expect(commentTool.request.body?.(FILE_COMMENT_PARAMS)).toBeUndefined() + it('declares both versions as registered operations without request transport metadata', () => { + expect(commentTool.operation.input(FILE_COMMENT_PARAMS)).toEqual(FILE_COMMENT_PARAMS) + expect(commentV2Tool.operation.input(FILE_COMMENT_PARAMS)).toEqual(FILE_COMMENT_PARAMS) + expect(commentTool).not.toHaveProperty('request') + expect(commentV2Tool).not.toHaveProperty('request') }) }) @@ -280,7 +307,7 @@ describe('github_comment cancellation', () => { .mockResolvedValueOnce(createdCommentResponse()) const controller = new AbortController() - await commentTool.directExecution!(FILE_COMMENT_PARAMS, controller.signal) + await executeGitHubCommentOperation(FILE_COMMENT_PARAMS, controller.signal) const recorded = calls() expect(recorded).toHaveLength(2) @@ -292,7 +319,7 @@ describe('github_comment cancellation', () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) const controller = new AbortController() - await commentTool.directExecution!( + await executeGitHubCommentOperation( { ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA }, controller.signal ) @@ -309,7 +336,7 @@ describe('github_comment line coercion', () => { it('coerces a line number typed into the short input to an integer', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA, line: '42' as unknown as number, @@ -323,7 +350,7 @@ describe('github_comment line coercion', () => { .mockResolvedValueOnce(pullRequestResponse()) .mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, line: '42' as unknown as number, }) @@ -334,7 +361,7 @@ describe('github_comment line coercion', () => { it('keeps an integer line typed with surrounding whitespace', async () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA, line: ' 42 ' as unknown as number, @@ -347,7 +374,7 @@ describe('github_comment line coercion', () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) await expect( - commentTool.directExecution!({ + executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA, line: 3.9, @@ -360,7 +387,7 @@ describe('github_comment line coercion', () => { secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) await expect( - commentTool.directExecution!({ + executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA, line: '3.9' as unknown as number, @@ -369,20 +396,43 @@ describe('github_comment line coercion', () => { expect(secureGitHubRequest).not.toHaveBeenCalled() }) - it('omits a blank or unparseable line rather than sending NaN', async () => { - for (const line of ['', ' ', 'abc', undefined, null]) { + it('creates a file-level comment for an omitted or blank line', async () => { + for (const line of ['', ' ', undefined, null]) { secureGitHubRequest.mockReset() secureGitHubRequest.mockResolvedValueOnce(createdCommentResponse()) - await commentTool.directExecution!({ + await executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA, line: line as unknown as number, }) + expect(calls()[0].body).toMatchObject({ subject_type: 'file' }) expect(calls()[0].body).not.toHaveProperty('line') } }) + + it('rejects an invalid nonnumeric line instead of changing it to a file-level comment', async () => { + await expect( + executeGitHubCommentOperation({ + ...FILE_COMMENT_PARAMS, + commitId: OTHER_SHA, + line: 'abc' as unknown as number, + }) + ).rejects.toThrow('GitHub line must be a valid number, but line was abc') + expect(secureGitHubRequest).not.toHaveBeenCalled() + }) + + it('rejects a non-positive line before provider work', async () => { + await expect( + executeGitHubCommentOperation({ + ...FILE_COMMENT_PARAMS, + commitId: OTHER_SHA, + line: 0, + }) + ).rejects.toThrow('GitHub line numbers must be positive integers') + expect(secureGitHubRequest).not.toHaveBeenCalled() + }) }) describe('github_comment errors', () => { @@ -393,7 +443,7 @@ describe('github_comment errors', () => { it('fails with an actionable error when the pull request has no head SHA', async () => { secureGitHubRequest.mockResolvedValueOnce(Response.json({ number: 7 })) - await expect(commentTool.directExecution!(FILE_COMMENT_PARAMS)).rejects.toThrow( + await expect(executeGitHubCommentOperation(FILE_COMMENT_PARAMS)).rejects.toThrow( /no head commit SHA for pull request octo\/demo#7/ ) expect(secureGitHubRequest).toHaveBeenCalledTimes(1) @@ -411,7 +461,7 @@ describe('github_comment errors', () => { ) await expect( - commentTool.directExecution!({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA }) + executeGitHubCommentOperation({ ...FILE_COMMENT_PARAMS, commitId: OTHER_SHA }) ).rejects.toThrow('Validation Failed: line: line must be part of the diff') }) @@ -420,7 +470,7 @@ describe('github_comment errors', () => { Response.json({ message: 'Not Found' }, { status: 404 }) ) - await expect(commentTool.directExecution!(FILE_COMMENT_PARAMS)).rejects.toMatchObject({ + await expect(executeGitHubCommentOperation(FILE_COMMENT_PARAMS)).rejects.toMatchObject({ message: 'Not Found', status: 404, }) diff --git a/apps/sim/tools/github/comment.ts b/apps/sim/tools/github/comment.ts index 67d1431e165..0f93a03d13d 100644 --- a/apps/sim/tools/github/comment.ts +++ b/apps/sim/tools/github/comment.ts @@ -1,234 +1,9 @@ -import { isRecordLike } from '@sim/utils/object' -import { formatGitHubErrorMessage } from '@/tools/github/response-parsers' import type { CreateCommentParams, CreateCommentResponse } from '@/tools/github/types' import { COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -const GITHUB_API_BASE = 'https://api.github.com' - -/** Body GitHub accepts on `POST /pulls/{n}/reviews`. */ -interface ReviewCommentBody { - body: string - event: 'COMMENT' -} - -/** Body GitHub accepts on `POST /pulls/{n}/comments`. */ -interface FileCommentBody { - body: string - commit_id: string | undefined - path: string | undefined - line: number | undefined - side: string -} - -/** The subset of a GitHub comment payload this tool reports. */ -interface GitHubCommentPayload { - id?: number - body?: string - html_url?: string - user?: unknown - path?: string - line?: number - position?: number - side?: string - commit_id?: string - created_at?: string - updated_at?: string -} - -function githubHeaders(apiKey: string): Record { - return { - Accept: 'application/vnd.github.v3+json', - Authorization: `Bearer ${apiKey}`, - 'X-GitHub-Api-Version': '2022-11-28', - } -} - -function pullRequestUrl(params: CreateCommentParams): string { - return `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}` -} - -/** - * Whether the request is headed for `POST /pulls/{n}/comments`. GitHub documents - * `body`, `commit_id` and `path` as required there, so only a file comment that - * actually carries a path can use it. `path` is optional on the block, so a file - * comment left without one falls back to `/pulls/{n}/reviews`, where GitHub creates - * a pending review and neither field is required. - */ -function isFileCommentRequest(params: CreateCommentParams): boolean { - return params.commentType === 'file_comment' && Boolean(params.path) -} - -/** - * GitHub requires `commit_id` on a pull request review comment. When the caller did - * not supply one, the pull request is fetched first so its head SHA can be used — - * mirroring how Jira resolves a missing `cloudId` from `domain`. - * - * The lookup is gated on the endpoint, because only `/comments` needs a commit SHA. - */ -function needsCommitLookup(params: CreateCommentParams): boolean { - return isFileCommentRequest(params) && !params.commitId -} - -/** - * The block renders `line` as a short input, so a typed line number reaches the tool - * as a string while GitHub types the field as an integer. Blank and unparseable input - * is omitted rather than sent as `NaN` — `line` is optional, and nothing usable was - * supplied. - * - * A fractional value is rejected instead of truncated. `3.9` is not the caller asking - * for line 3, and quietly posting the review comment on a different line of the diff - * than the one they named is the failure they would never think to look for. This - * fails the way a missing head commit SHA does: loudly, naming what to set. - */ -function toLineNumber(value: unknown): number | undefined { - let parsed: number - if (typeof value === 'number') { - parsed = value - } else { - if (typeof value !== 'string' || !value.trim()) return undefined - parsed = Number(value.trim()) - } - if (!Number.isFinite(parsed)) return undefined - if (!Number.isInteger(parsed)) { - throw new Error( - `GitHub line numbers are whole numbers, but line was ${parsed}. Set line to the integer line number in the diff.` - ) - } - return parsed -} - -function fileCommentBody( - params: CreateCommentParams, - commitId: string | undefined -): FileCommentBody { - return { - body: params.body, - commit_id: commitId, - path: params.path, - line: toLineNumber(params.line), - side: params.side || 'RIGHT', - } -} - -/** - * The endpoint the comment itself is posted to. The comment TYPE selects it, not the - * mere presence of `path`: a general PR comment sends `{body, event}`, which the - * review-comment endpoint rejects with a 422 for the missing `commit_id` and `path`, - * so a `pr_comment` that happens to name a file has to stay on `/reviews`. - */ -function commentEndpointUrl(params: CreateCommentParams): string { - return isFileCommentRequest(params) - ? `${pullRequestUrl(params)}/comments` - : `${pullRequestUrl(params)}/reviews` -} - -function commentRequestBody( - params: CreateCommentParams, - commitId: string | undefined -): FileCommentBody | ReviewCommentBody { - if (params.commentType === 'file_comment') return fileCommentBody(params, commitId) - return { body: params.body, event: 'COMMENT' } -} - -function readHeadSha(pullRequest: unknown): string | undefined { - if (!isRecordLike(pullRequest) || !isRecordLike(pullRequest.head)) return undefined - const sha = pullRequest.head.sha - return typeof sha === 'string' && sha ? sha : undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' ? value : undefined -} - -function readNumber(record: Record, key: string): number | undefined { - const value = record[key] - return typeof value === 'number' ? value : undefined -} - -function readCommentPayload(value: unknown): GitHubCommentPayload { - if (!isRecordLike(value)) return {} - return { - id: readNumber(value, 'id'), - body: readString(value, 'body'), - html_url: readString(value, 'html_url'), - user: value.user, - path: readString(value, 'path'), - line: readNumber(value, 'line'), - position: readNumber(value, 'position'), - side: readString(value, 'side'), - commit_id: readString(value, 'commit_id'), - created_at: readString(value, 'created_at'), - updated_at: readString(value, 'updated_at'), - } -} - -/** - * Projects a failed GitHub response the way the tool transport does: the thrown error - * carries `status`, `statusText`, and the parsed body on `data`, so callers that branch - * on a status (a 404 treated as a clean no-match, for one) keep working off this path. - */ -async function assertGitHubResponseOk(response: Response, fallback: string): Promise { - if (response.ok) return - - const text = await response.text().catch(() => '') - let data: unknown = text - try { - data = JSON.parse(text) - } catch { - data = text - } - - const error = new Error(formatGitHubErrorMessage(data) ?? `${fallback} (HTTP ${response.status})`) - Object.assign(error, { status: response.status, statusText: response.statusText, data }) - throw error -} - -/** - * Creates the comment, resolving the pull request head SHA first when a file comment - * needs one. Both requests run on the DNS-validated, IP-pinned GitHub transport and - * carry the execution's abort signal, so cancelling a workflow cancels the POST. - */ -async function createComment( - params: CreateCommentParams, - signal?: AbortSignal -): Promise { - const { secureGitHubRequest } = await import('@/tools/github/utils.server') - const headers = githubHeaders(params.apiKey) - - let commitId = params.commitId - if (needsCommitLookup(params)) { - const pullRequestResponse = await secureGitHubRequest(pullRequestUrl(params), { - headers, - signal, - }) - await assertGitHubResponseOk( - pullRequestResponse, - `Failed to load pull request ${params.owner}/${params.repo}#${params.pullNumber}` - ) - commitId = readHeadSha(await pullRequestResponse.json()) - if (!commitId) { - throw new Error( - `GitHub returned no head commit SHA for pull request ${params.owner}/${params.repo}#${params.pullNumber}. Set commitId to comment on a specific commit.` - ) - } - } - - const response = await secureGitHubRequest(commentEndpointUrl(params), { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(commentRequestBody(params, commitId)), - signal, - }) - await assertGitHubResponseOk(response, 'Failed to create comment') - - return readCommentPayload(await response.json()) -} - -const DIRECT_EXECUTION_ONLY_ERROR = 'GitHub comments require the two-phase direct execution path' - -export const commentTool: ToolConfig = { +export const commentTool: InternalToolConfig = { id: 'github_comment', name: 'GitHub PR Commenter', description: 'Create comments on GitHub PRs', @@ -298,46 +73,8 @@ export const commentTool: ToolConfig }, }, - directExecution: async (params, signal) => { - const data = await createComment(params, signal) - - return { - success: true, - output: { - content: `Comment created: "${data.body}"`, - metadata: { - id: data.id, - html_url: data.html_url, - created_at: data.created_at, - updated_at: data.updated_at, - path: data.path, - line: data.line || data.position, - side: data.side, - commit_id: data.commit_id, - }, - }, - } - }, - - request: { - url: (params) => { - if (needsCommitLookup(params)) { - return pullRequestUrl(params) - } - return commentEndpointUrl(params) - }, - method: (params) => (needsCommitLookup(params) ? 'GET' : 'POST'), - headers: (params) => githubHeaders(params.apiKey), - body: (params) => { - if (needsCommitLookup(params)) { - return undefined - } - return commentRequestBody(params, params.commitId) - }, - }, - - transformResponse: async () => { - throw new Error(DIRECT_EXECUTION_ONLY_ERROR) + operation: { + input: createInternalToolOperationInput, }, outputs: { @@ -349,33 +86,14 @@ export const commentTool: ToolConfig }, } -export const commentV2Tool: ToolConfig = { +export const commentV2Tool: InternalToolConfig = { id: 'github_comment_v2', name: commentTool.name, description: commentTool.description, version: '2.0.0', params: commentTool.params, - request: commentTool.request, - directExecution: async (params, signal) => { - const data = await createComment(params, signal) - return { - success: true, - output: { - id: data.id, - body: data.body, - html_url: data.html_url, - user: data.user, - path: data.path ?? null, - line: data.line ?? data.position ?? null, - side: data.side ?? null, - commit_id: data.commit_id ?? null, - created_at: data.created_at, - updated_at: data.updated_at, - }, - } - }, - transformResponse: async () => { - throw new Error(DIRECT_EXECUTION_ONLY_ERROR) + operation: { + input: createInternalToolOperationInput, }, outputs: { ...COMMENT_OUTPUT_PROPERTIES, diff --git a/apps/sim/tools/github/utils.server.ts b/apps/sim/tools/github/utils.server.ts index 562339e15ca..ad2c250ac0d 100644 --- a/apps/sim/tools/github/utils.server.ts +++ b/apps/sim/tools/github/utils.server.ts @@ -35,8 +35,8 @@ function withUserAgent(headers: Record): Record } /** - * Executes one DNS-validated, IP-pinned GitHub request for a tool that cannot use - * the declarative transport — a multi-phase tool running under `directExecution`. + * Executes one DNS-validated, IP-pinned GitHub request for a registered operation + * whose provider interaction spans multiple requests. * * This deliberately carries no retry loop: the tools on this path declare no * `request.retry`, so the transport retries them zero times today, and the second diff --git a/apps/sim/tools/google_drive/move.ts b/apps/sim/tools/google_drive/move.ts index 4358b7d29bc..6f025e47aa0 100644 --- a/apps/sim/tools/google_drive/move.ts +++ b/apps/sim/tools/google_drive/move.ts @@ -1,6 +1,5 @@ import type { GoogleDriveFile, GoogleDriveToolParams } from '@/tools/google_drive/types' -import { ALL_FILE_FIELDS } from '@/tools/google_drive/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' interface GoogleDriveMoveParams extends GoogleDriveToolParams { fileId: string @@ -14,7 +13,7 @@ interface GoogleDriveMoveResponse extends ToolResponse { } } -export const moveTool: ToolConfig = { +export const moveTool: InternalToolConfig = { id: 'google_drive_move', name: 'Move Google Drive File', description: 'Move a file or folder to a different folder in Google Drive', @@ -53,77 +52,15 @@ export const moveTool: ToolConfig ({ - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', + operation: { + input: (params) => ({ + accessToken: params.accessToken, + fileId: params.fileId, + destinationFolderId: params.destinationFolderId, + removeFromCurrent: params.removeFromCurrent, }), }, - directExecution: async (params) => { - const fileId = params.fileId?.trim() - const destinationFolderId = params.destinationFolderId?.trim() - const removeFromCurrent = params.removeFromCurrent !== false - - if (!fileId) { - throw new Error('fileId is required') - } - if (!destinationFolderId) { - throw new Error('destinationFolderId is required') - } - - const headers = { - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - } - - // Build the PATCH URL with addParents - const url = new URL(`https://www.googleapis.com/drive/v3/files/${fileId}`) - url.searchParams.append('addParents', destinationFolderId) - url.searchParams.append('fields', ALL_FILE_FIELDS) - url.searchParams.append('supportsAllDrives', 'true') - - if (removeFromCurrent) { - // Fetch current parents so we can remove them - const metadataUrl = new URL(`https://www.googleapis.com/drive/v3/files/${fileId}`) - metadataUrl.searchParams.append('fields', 'parents') - metadataUrl.searchParams.append('supportsAllDrives', 'true') - - const metadataResponse = await fetch(metadataUrl.toString(), { headers }) - - if (!metadataResponse.ok) { - const errorData = await metadataResponse.json() - throw new Error(errorData.error?.message || 'Failed to retrieve file metadata') - } - - const metadata = await metadataResponse.json() - if (metadata.parents && metadata.parents.length > 0) { - url.searchParams.append('removeParents', metadata.parents.join(',')) - } - } - - const response = await fetch(url.toString(), { - method: 'PATCH', - headers, - body: JSON.stringify({}), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error?.message || 'Failed to move Google Drive file') - } - - return { - success: true, - output: { - file: data, - }, - } - }, - outputs: { file: { type: 'json', diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 444681b6310..2ac6560c9c5 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -28,6 +28,7 @@ import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { executeBitbucketTool } from '@/lib/internal/bitbucket/execute-tool' import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, @@ -39,6 +40,7 @@ import { fileGetContentTool } from '@/tools/file/get' import { fileFetchTool } from '@/tools/file/parser' import { buildFunctionExecuteBody } from '@/tools/function/execute' import { memoryAddTool } from '@/tools/memory/add' +import { createInternalToolOperationInput } from '@/tools/operation-input' import { getCallerIdentityTool } from '@/tools/sts/get_caller_identity' import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows' import type { InternalToolConfig, ToolResponse } from '@/tools/types' @@ -1107,6 +1109,109 @@ describe('executeTool Function', () => { expect(fetchSpy).not.toHaveBeenCalled() }) + it('preserves a registered operation failure without turning it into success', async () => { + const mockTool = { + id: 'test_registered_operation_failure', + name: 'Test Registered Operation Failure', + description: 'Returns a typed operation failure', + version: '1.0.0', + params: {}, + operation: { input: createInternalToolOperationInput }, + } satisfies InternalToolConfig> + ;(tools as Record).test_registered_operation_failure = mockTool + mockExecuteInternalToolOperation.mockResolvedValueOnce( + Response.json({ + success: false, + output: { reason: 'provider rejected request' }, + error: 'Provider rejected request', + retryable: false, + }) + ) + + try { + const result = await executeTool( + 'test_registered_operation_failure', + {}, + { + executionContext: createToolExecutionContext({ + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }), + } + ) + + expect(result).toMatchObject({ + success: false, + output: { reason: 'provider rejected request' }, + error: 'Provider rejected request', + retryable: false, + }) + } finally { + Reflect.deleteProperty(tools, 'test_registered_operation_failure') + } + }) + + it('preserves actorless schedule authority for registered operations', async () => { + const mockTool = { + id: 'test_actorless_registered_operation', + name: 'Test Actorless Registered Operation', + description: 'Executes with schedule authority', + version: '1.0.0', + params: {}, + operation: { input: createInternalToolOperationInput }, + } satisfies InternalToolConfig> + ;(tools as Record).test_actorless_registered_operation = mockTool + mockExecuteInternalToolOperation.mockResolvedValueOnce( + Response.json({ success: true, output: { ok: true } }) + ) + const principal = { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + } + const executorDelegationOrigin = { + workflowId: 'workflow-1', + executionId: 'execution-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-version-1', + }, + principal, + } + + try { + const result = await executeTool( + 'test_actorless_registered_operation', + {}, + { + executionContext: createToolExecutionContext({ + userId: undefined, + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + principal, + executorDelegationOrigin, + }), + } + ) + + expect(result).toMatchObject({ success: true, output: { ok: true } }) + expect(mockExecuteInternalToolOperation).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + executorDelegationOrigin, + }), + }) + ) + expect(mockExecuteInternalToolOperation.mock.calls[0]?.[0].context.userId).toBeUndefined() + } finally { + Reflect.deleteProperty(tools, 'test_actorless_registered_operation') + } + }) + it('maps the public File Fetch URL before in-process dispatch', async () => { mockExecuteInternalToolOperation.mockResolvedValueOnce( Response.json({ success: true, output: { files: [], combinedContent: '' } }) @@ -1148,6 +1253,7 @@ describe('executeTool Function', () => { }) it('bounds ignored Bitbucket pipeline log ranges through the execution path', async () => { + mockExecuteInternalToolOperation.mockImplementationOnce(executeBitbucketTool) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' }) const log = 'line 1\nDONE\n' @@ -1179,9 +1285,14 @@ describe('executeTool Function', () => { } const accepted = await executeTool('bitbucket_get_pipeline_step_log', params, { skipPostProcess: true, + executionContext: createToolExecutionContext({ + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }), }) - expect(accepted).toMatchObject({ + expect(accepted, accepted.error).toMatchObject({ success: true, output: { log: 'DONE\n', @@ -3303,7 +3414,7 @@ describe('Internal Route Trust', () => { } }) - it('projects only selected model input before direct execution', async () => { + it('projects only selected model input before a registered operation', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'PROMPT_SECRET', @@ -3313,29 +3424,28 @@ describe('Internal Route Trust', () => { ]) registry.recordResolvedAtInputPath('PROMPT_SECRET', 'direct-secret', ['prompt']) registry.recordResolvedInputProjection(['prompt'], 'direct-secret', '{{PROMPT_SECRET}}') - const directExecution = vi.fn().mockResolvedValue({ success: true, output: { ok: true } }) + mockExecuteInternalToolOperation.mockResolvedValueOnce( + Response.json({ success: true, output: { ok: true } }) + ) const postProcess = vi.fn( async (result: { success: boolean; output: { ok: boolean } }) => result ) const mockTool = { id: 'test_direct_projected_model_tool', name: 'Test Direct Projected Model Tool', - description: 'Projects model-visible params before direct execution', + description: 'Projects model-visible params before registered operation execution', version: '1.0.0', params: { prompt: { type: 'string', required: true }, apiKey: { type: 'string', required: true }, }, - request: { - url: '', - method: 'POST' as const, - headers: () => ({}), + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project' as const, select: (params: { prompt: string }) => ({ prompt: params.prompt }), }, }, - directExecution, postProcess, } const params = { prompt: 'direct-secret', apiKey: 'direct-secret' } @@ -3345,16 +3455,23 @@ describe('Internal Route Trust', () => { try { const result = await executeTool('test_direct_projected_model_tool', params, { resolvedSecretTraceRegistry: registry, + executionContext: createToolExecutionContext({ + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }), }) expect(result.success).toBe(true) - expect(directExecution).toHaveBeenCalledWith( - { prompt: '{{PROMPT_SECRET}}', apiKey: 'direct-secret' }, - undefined + expect(mockExecuteInternalToolOperation).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: 'test_direct_projected_model_tool', + input: { prompt: '{{PROMPT_SECRET}}', apiKey: 'direct-secret' }, + }) ) expect(postProcess).toHaveBeenCalledWith( expect.any(Object), - { prompt: 'direct-secret', apiKey: 'direct-secret' }, + expect.objectContaining({ prompt: 'direct-secret', apiKey: 'direct-secret' }), expect.any(Function) ) expect(params).toEqual(originalParams) @@ -3380,12 +3497,7 @@ describe('Internal Route Trust', () => { description: 'Executes a registered internal operation from post-processing', version: '1.0.0', params: {}, - request: { - url: '', - method: 'POST' as const, - headers: () => ({}), - }, - directExecution: vi.fn().mockResolvedValue({ success: true, output: {} }), + operation: { input: (params: Record) => params }, postProcess: async ( _result: ToolResponse, _params: Record, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 641748fda7e..61ffc52d772 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -107,10 +107,6 @@ import { getToolAsync } from '@/tools/utils.server' const logger = createLogger('Tools') const PRIVATE_TOOL_METADATA_ERROR_MESSAGE = 'Internal tool response metadata could not be verified' -const PRIVATE_MODEL_INPUT_DIRECT_EXECUTION_ERROR_MESSAGE = - 'Private model input provenance is not supported by direct execution' -const PRIVATE_SECRET_PROVENANCE_DIRECT_EXECUTION_ERROR_MESSAGE = - 'Private secret provenance is not supported by direct execution' const INTERNAL_DATABASE_ERROR_MESSAGE = 'An internal error occurred while executing the tool. Please try again.' const PERMISSION_PREFLIGHT_MAX_ATTEMPTS = 3 @@ -1535,7 +1531,7 @@ export async function executeTool( return result } -/** Executes a tool through its declared in-process, direct, or external boundary. */ +/** Executes a tool through its declared in-process or external boundary. */ async function executeToolImplementation( toolId: string, params: Record, @@ -2011,80 +2007,6 @@ async function executeToolImplementation( } } - // Check for direct execution (no HTTP request needed) - if (tool.directExecution) { - logger.info(`[${requestId}] Using directExecution for ${toolId}`) - if ( - tool.request.modelInput?.mode === 'private-provenance' || - (tool.request.modelInput?.mode === 'project' && - tool.request.modelInput.privateInputPaths !== undefined) - ) { - throw new Error(PRIVATE_MODEL_INPUT_DIRECT_EXECUTION_ERROR_MESSAGE) - } - if (tool.request.secretProvenance) { - throw new Error(PRIVATE_SECRET_PROVENANCE_DIRECT_EXECUTION_ERROR_MESSAGE) - } - const directExecutionInput = projectToolModelInputParams( - tool, - contextParams, - resolvedSecretTraceRegistry - ) - const result = await tool.directExecution(directExecutionInput, effectiveSignal) - - // Apply post-processing if available and not skipped - let finalResult = result - if (tool.postProcess && result.success && !skipPostProcess) { - try { - finalResult = await tool.postProcess(result, contextParams, executeNestedTool) - } catch (error) { - const normalizedError = toError(error) - logger.error( - `[${requestId}] Post-processing error for ${toolId}:`, - projectToolLogMetadata( - { error: normalizedError.message }, - resolvedSecretTraceRegistry, - { errorName: normalizedError.name }, - structuralOnlyToolLogs - ) - ) - finalResult = result - } - } - - // Process file outputs if execution context is available - finalResult = await processFileOutputs(finalResult, tool, executionContext) - - // Add timing data to the result - const endTime = new Date() - const endTimeISO = endTime.toISOString() - const duration = endTime.getTime() - startTime.getTime() - - if (hostedKeyInfo.isUsingHostedKey && finalResult.success) { - await applyHostedKeyCostToResult( - finalResult, - tool, - contextParams, - executionContext, - requestId, - hostedKeyInfo.envVarName - ) - } else if (hostedKeyForMetrics) { - hostedKeyMetrics.recordFailed({ ...hostedKeyForMetrics, reason: 'other' }) - } - - const strippedOutput = postProcessToolOutput(normalizedToolId, finalResult.output ?? {}) - - return { - ...finalResult, - output: strippedOutput, - timing: { - startTime: startTimeISO, - endTime: endTimeISO, - duration, - }, - } - } - // Wrap external requests with hosted-key retry and reacquisition. const result = hostedKeyInfo.isUsingHostedKey ? await executeWithRetry( @@ -2457,6 +2379,10 @@ function isFunctionExecuteBody(value: unknown): value is FunctionExecuteBody { return isPlainRecord(value) && typeof value.code === 'string' } +function isToolResponse(value: unknown): value is ToolResponse { + return isRecordLike(value) && typeof value.success === 'boolean' && isRecordLike(value.output) +} + async function executeDeclaredInternalOperation({ toolId, tool, @@ -2468,7 +2394,10 @@ async function executeDeclaredInternalOperation({ resolvedSecretTraceRegistry, internalSandboxProfile, }: ExecuteDeclaredInternalOperationInput): Promise { - if (!context?.userId || !context.workspaceId) { + if ( + !context?.workspaceId || + (!context.executorDelegationOrigin && !context.userId && !context.copilotToolExecution) + ) { throw new Error('Internal tool execution requires trusted execution scope') } @@ -2625,6 +2554,7 @@ async function executeDeclaredInternalOperation({ if (tool.transformResponse) return tool.transformResponse(response, params) const responseData = await response.json() + if (isToolResponse(responseData)) return responseData return { success: true, output: diff --git a/apps/sim/tools/managed_agent/archive_session.ts b/apps/sim/tools/managed_agent/archive_session.ts index 396fe7197c9..fbdacbe34a5 100644 --- a/apps/sim/tools/managed_agent/archive_session.ts +++ b/apps/sim/tools/managed_agent/archive_session.ts @@ -1,17 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { archiveSession } from '@/lib/managed-agents/session-client' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentArchiveSessionParams, ManagedAgentArchiveSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Archives a session — it becomes read-only but keeps its full history. @@ -20,7 +17,8 @@ import type { ToolConfig } from '@/tools/types' * run leaves a live session behind in the Claude workspace forever. Archiving * is NOT reversible, and a `running` session is rejected — interrupt it first. */ -export const managedAgentArchiveSessionTool: ToolConfig< + +export const managedAgentArchiveSessionTool: InternalToolConfig< ManagedAgentArchiveSessionParams, ManagedAgentArchiveSessionResponse > = { @@ -35,28 +33,8 @@ export const managedAgentArchiveSessionTool: ToolConfig< sessionId: SESSION_ID_PARAM, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: { sessionId: '', archived: false }, error: target.error } - } - - try { - await archiveSession({ - apiKey: target.apiKey, - sessionId: target.sessionId, - ...(signal ? { signal } : {}), - }) - return { success: true, output: { sessionId: target.sessionId, archived: true } } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, archived: false }, - error: getErrorMessage(error, 'Failed to archive Managed Agent session'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/managed_agent/create_session.ts b/apps/sim/tools/managed_agent/create_session.ts index 480deac1e32..5bede2b413c 100644 --- a/apps/sim/tools/managed_agent/create_session.ts +++ b/apps/sim/tools/managed_agent/create_session.ts @@ -1,22 +1,10 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { - type CreateSessionInput, - createSession, - getEnvironmentType, -} from '@/lib/managed-agents/session-client' -import { - isTruthyAck, - normalizeFiles, - normalizeMemoryAccess, - normalizeSessionParameters, - normalizeStringList, -} from '@/tools/managed_agent/normalizers' -import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, UNUSED_REQUEST } from '@/tools/managed_agent/shared' +import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM } from '@/tools/managed_agent/shared' import type { ManagedAgentCreateSessionParams, ManagedAgentCreateSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Creates a Managed Agent session and returns its id WITHOUT waiting for the @@ -28,7 +16,8 @@ import type { ToolConfig } from '@/tools/types' * conversational or webhook-driven integration needs. Supplying a first message * seeds `initial_events`, so create-and-start is a single API call. */ -export const managedAgentCreateSessionTool: ToolConfig< + +export const managedAgentCreateSessionTool: InternalToolConfig< ManagedAgentCreateSessionParams, ManagedAgentCreateSessionResponse > = { @@ -109,8 +98,8 @@ export const managedAgentCreateSessionTool: ToolConfig< }, }, - request: { - ...UNUSED_REQUEST, + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ @@ -120,86 +109,6 @@ export const managedAgentCreateSessionTool: ToolConfig< }, }, - directExecution: async (params, signal): Promise => { - const apiKey = params.accessToken - if (!apiKey) { - return { - success: false, - output: { sessionId: '', started: false }, - error: 'No Claude Platform credential is selected, or it could not be resolved.', - } - } - - const agentId = params.agent?.trim() - const environmentId = params.environment?.trim() - if (!agentId || !environmentId) { - return { - success: false, - output: { sessionId: '', started: false }, - error: 'An agent and an environment are required.', - } - } - - const vaultIds = normalizeStringList(params.vaults) - if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { - return { - success: false, - output: { sessionId: '', started: false }, - error: - 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', - } - } - - const files = normalizeFiles(params.files) - const sessionParameters = normalizeSessionParameters(params.sessionParameters) - const memoryStoreId = params.memoryStoreId?.trim() || undefined - const memoryAccess = normalizeMemoryAccess(params.memoryAccess) - const memoryInstructions = params.memoryInstructions?.trim() || undefined - const initialMessage = (params.userMessage ?? '').toString().trim() || undefined - - const workflowId = params._context?.workflowId?.trim() - const title = workflowId ? `Sim workflow ${workflowId}` : undefined - - // Self-hosted environments reject `resources`, so the payload must know the - // execution model. The API is authoritative; the block's hint is a fallback. - const hinted = - params.environmentType === 'self_hosted' || params.environmentType === 'cloud' - ? params.environmentType - : undefined - const environmentType = - (await getEnvironmentType({ apiKey, environmentId, ...(signal ? { signal } : {}) })) ?? hinted - - const createInput: CreateSessionInput = { - apiKey, - agentId, - environmentId, - ...(environmentType ? { environmentType } : {}), - ...(title ? { title } : {}), - ...(vaultIds.length > 0 ? { vaultIds } : {}), - ...(memoryStoreId ? { memoryStoreId } : {}), - ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), - ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), - ...(files.length > 0 ? { files } : {}), - ...(sessionParameters ? { sessionParameters } : {}), - ...(initialMessage ? { initialMessage } : {}), - ...(signal ? { signal } : {}), - } - - try { - const session = await createSession(createInput) - return { - success: true, - output: { sessionId: session.id, started: Boolean(initialMessage) }, - } - } catch (error) { - return { - success: false, - output: { sessionId: '', started: false }, - error: getErrorMessage(error, 'Failed to create Managed Agent session'), - } - } - }, - outputs: { sessionId: { type: 'string', description: 'Anthropic session id (sesn_...).' }, started: { diff --git a/apps/sim/tools/managed_agent/delete_session.ts b/apps/sim/tools/managed_agent/delete_session.ts index d50fed72302..3945dacb6e0 100644 --- a/apps/sim/tools/managed_agent/delete_session.ts +++ b/apps/sim/tools/managed_agent/delete_session.ts @@ -1,17 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { deleteSession } from '@/lib/managed-agents/session-client' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentDeleteSessionParams, ManagedAgentDeleteSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Permanently deletes a session, its event history, and its sandbox. @@ -21,7 +18,8 @@ import type { ToolConfig } from '@/tools/types' * it first. Prefer archiving when the transcript still has value; this is the * right choice when the session held sensitive input that should not persist. */ -export const managedAgentDeleteSessionTool: ToolConfig< + +export const managedAgentDeleteSessionTool: InternalToolConfig< ManagedAgentDeleteSessionParams, ManagedAgentDeleteSessionResponse > = { @@ -37,28 +35,8 @@ export const managedAgentDeleteSessionTool: ToolConfig< sessionId: SESSION_ID_PARAM, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: { sessionId: '', deleted: false }, error: target.error } - } - - try { - await deleteSession({ - apiKey: target.apiKey, - sessionId: target.sessionId, - ...(signal ? { signal } : {}), - }) - return { success: true, output: { sessionId: target.sessionId, deleted: true } } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, deleted: false }, - error: getErrorMessage(error, 'Failed to delete Managed Agent session'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/managed_agent/get_session.test.ts b/apps/sim/tools/managed_agent/get_session.test.ts new file mode 100644 index 00000000000..9dc5bb3008f --- /dev/null +++ b/apps/sim/tools/managed_agent/get_session.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeManagedAgentGetSessionOperation } from '@/lib/internal/managed-agent/operations/get-session' + +const SESSION_INPUT = { + credential: 'credential-1', + accessToken: 'sk-ant-fake', + sessionId: 'sesn_1', +} + +describe('managed_agent_get_session', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('does not expose a stale requires-action gate after the session resumes', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ + status: 'running', + stop_reason: { type: 'requires_action', event_ids: ['sevt_1'] }, + }) + ) + + const result = await executeManagedAgentGetSessionOperation(SESSION_INPUT, undefined) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ + success: true, + output: { + status: 'running', + stopReason: 'requires_action', + requiresAction: false, + pendingTools: [], + }, + }) + }) + + it('resolves current requires-action gates while the session is idle', async () => { + fetchMock + .mockResolvedValueOnce( + Response.json({ + status: 'idle', + stop_reason: { type: 'requires_action', event_ids: ['sevt_1'] }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + data: [ + { + id: 'sevt_1', + type: 'agent.custom_tool_use', + name: 'lookup', + input: { query: 'test' }, + }, + ], + next_page: null, + }) + ) + + const result = await executeManagedAgentGetSessionOperation(SESSION_INPUT, undefined) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ + success: true, + output: { + status: 'idle', + requiresAction: true, + pendingTools: [ + { + id: 'sevt_1', + eventType: 'agent.custom_tool_use', + kind: 'custom_tool_result', + name: 'lookup', + input: { query: 'test' }, + }, + ], + }, + }) + }) +}) diff --git a/apps/sim/tools/managed_agent/get_session.ts b/apps/sim/tools/managed_agent/get_session.ts index ebca831595f..667b55c7247 100644 --- a/apps/sim/tools/managed_agent/get_session.ts +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -1,24 +1,14 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentGetSessionParams, ManagedAgentGetSessionResponse, - ManagedAgentPendingTool, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' - -const logger = createLogger('ManagedAgentGetSession') - -/** `stop_reason.type` meaning the session is parked awaiting a client response. */ -const REQUIRES_ACTION = 'requires_action' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Reads a Managed Agent session's current state, and — when it is blocked on an @@ -32,7 +22,8 @@ const REQUIRES_ACTION = 'requires_action' * blocking event ids come straight from `stop_reason.event_ids`; the tool * names are cross-referenced from the session's tool-use events. */ -export const managedAgentGetSessionTool: ToolConfig< + +export const managedAgentGetSessionTool: InternalToolConfig< ManagedAgentGetSessionParams, ManagedAgentGetSessionResponse > = { @@ -48,76 +39,8 @@ export const managedAgentGetSessionTool: ToolConfig< sessionId: SESSION_ID_PARAM, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const emptyOutput = { - sessionId: '', - status: '', - requiresAction: false, - pendingTools: [] as ManagedAgentPendingTool[], - } - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: emptyOutput, error: target.error } - } - - try { - const snapshot = await retrieveSession({ - apiKey: target.apiKey, - sessionId: target.sessionId, - ...(signal ? { signal } : {}), - }) - - const requiresAction = snapshot.stopReason?.type === REQUIRES_ACTION - const eventIds = snapshot.stopReason?.eventIds ?? [] - // Only pay for the events call when the session is actually blocked. - const pendingTools = - requiresAction && eventIds.length > 0 - ? await resolvePendingToolGates({ - apiKey: target.apiKey, - sessionId: target.sessionId, - eventIds, - ...(signal ? { signal } : {}), - }) - : [] - - // A blocked session that names no blocking events is an anomaly: it waits - // indefinitely, but nothing here can say for what. `requiresAction` stays - // true because that is the truth — reporting false would tell a workflow - // the session is fine while it is parked forever — so log it instead, so - // the dead end is visible rather than silent. - if (requiresAction && pendingTools.length === 0) { - logger.warn('Managed Agent session requires action but reported no blocking event ids', { - sessionId: target.sessionId, - }) - } - - return { - success: true, - output: { - sessionId: target.sessionId, - status: snapshot.status ?? '', - ...(snapshot.stopReason?.type ? { stopReason: snapshot.stopReason.type } : {}), - requiresAction, - pendingTools, - ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), - ...(snapshot.title ? { title: snapshot.title } : {}), - ...(snapshot.usage?.inputTokens !== undefined - ? { inputTokens: snapshot.usage.inputTokens } - : {}), - ...(snapshot.usage?.outputTokens !== undefined - ? { outputTokens: snapshot.usage.outputTokens } - : {}), - }, - } - } catch (error) { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId }, - error: getErrorMessage(error, 'Failed to read Managed Agent session'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/managed_agent/interrupt_session.ts b/apps/sim/tools/managed_agent/interrupt_session.ts index 0fd536e8225..bae6528e558 100644 --- a/apps/sim/tools/managed_agent/interrupt_session.ts +++ b/apps/sim/tools/managed_agent/interrupt_session.ts @@ -1,24 +1,21 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { sendSessionEvents } from '@/lib/managed-agents/session-client' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentInterruptSessionParams, ManagedAgentInterruptSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Upper bound on the interrupt request itself. Interrupting is the prerequisite * for archiving or deleting a running session, so it must fail fast and visibly * rather than hang on a stalled connection. */ -const INTERRUPT_TIMEOUT_MS = 15_000 +export const INTERRUPT_TIMEOUT_MS = 15_000 /** * Stops a running session at its next safe boundary. @@ -28,7 +25,8 @@ const INTERRUPT_TIMEOUT_MS = 15_000 * prerequisite for archiving or deleting a session that is still `running`, * since both of those reject a running session. */ -export const managedAgentInterruptSessionTool: ToolConfig< + +export const managedAgentInterruptSessionTool: InternalToolConfig< ManagedAgentInterruptSessionParams, ManagedAgentInterruptSessionResponse > = { @@ -43,34 +41,8 @@ export const managedAgentInterruptSessionTool: ToolConfig< sessionId: SESSION_ID_PARAM, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: { sessionId: '', interrupted: false }, error: target.error } - } - - try { - await sendSessionEvents({ - apiKey: target.apiKey, - sessionId: target.sessionId, - events: [{ type: 'user.interrupt' }], - // Bounded so a stalled connection can't hang the operation. The - // workflow's own signal still cancels earlier when present; `any` - // resolves on whichever fires first. - signal: signal - ? AbortSignal.any([signal, AbortSignal.timeout(INTERRUPT_TIMEOUT_MS)]) - : AbortSignal.timeout(INTERRUPT_TIMEOUT_MS), - }) - return { success: true, output: { sessionId: target.sessionId, interrupted: true } } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, interrupted: false }, - error: getErrorMessage(error, 'Failed to interrupt Managed Agent session'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/managed_agent/list_events.test.ts b/apps/sim/tools/managed_agent/list_events.test.ts index a0f9d88e0d7..e87311d684c 100644 --- a/apps/sim/tools/managed_agent/list_events.test.ts +++ b/apps/sim/tools/managed_agent/list_events.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { managedAgentListEventsTool } from '@/tools/managed_agent/list_events' +import { executeManagedAgentListEventsOperation } from '@/lib/internal/managed-agent/operations/list-events' const originalFetch = global.fetch afterEach(() => { @@ -24,7 +24,7 @@ const historyOf = (count: number) => ) as unknown as typeof fetch const run = (limit: unknown) => - managedAgentListEventsTool.directExecution!( + executeManagedAgentListEventsOperation( { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', limit } as never, undefined ) diff --git a/apps/sim/tools/managed_agent/list_events.ts b/apps/sim/tools/managed_agent/list_events.ts index d2858ddbd75..b235f3b1ff0 100644 --- a/apps/sim/tools/managed_agent/list_events.ts +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -1,18 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { listSessionEventsPage } from '@/lib/managed-agents/session-client' -import { normalizeStringList } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentListEventsParams, ManagedAgentListEventsResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Default cap on how many events land in the workflow output. @@ -22,7 +18,7 @@ import type { ToolConfig } from '@/tools/types' * put an unpredictable payload into the workflow. Callers that genuinely need * more can raise it. */ -const DEFAULT_EVENT_LIMIT = 500 +export const DEFAULT_EVENT_LIMIT = 500 /** * Reads a session's event history, oldest first. @@ -32,7 +28,8 @@ const DEFAULT_EVENT_LIMIT = 500 * only persisted (id-bearing) `agent.message` events count, since stream-only * previews are never deduped and would double the text. */ -export const managedAgentListEventsTool: ToolConfig< + +export const managedAgentListEventsTool: InternalToolConfig< ManagedAgentListEventsParams, ManagedAgentListEventsResponse > = { @@ -63,68 +60,8 @@ export const managedAgentListEventsTool: ToolConfig< }, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const emptyOutput = { - sessionId: '', - events: [] as unknown[], - count: 0, - assistantText: '', - truncated: false, - } - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: emptyOutput, error: target.error } - } - - const types = normalizeStringList(params.eventTypes) - // Floor BEFORE the positivity check: a fractional limit like 0.5 would pass - // `> 0` and then floor to 0, which reads as "no cap" downstream and returns - // the whole history. Anything that does not floor to a positive integer - // falls back to the default rather than silently becoming unbounded. - const requested = Math.floor(Number(params.limit)) - const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT - - try { - const { events, total } = await listSessionEventsPage({ - apiKey: target.apiKey, - sessionId: target.sessionId, - maxItems, - ...(types.length > 0 ? { types } : {}), - ...(signal ? { signal } : {}), - }) - - let assistantText = '' - for (const event of events) { - // Skip idless events: those are stream-only previews, and the persisted - // copy carrying the same text arrives separately. - if (event.type !== 'agent.message' || !event.id || !Array.isArray(event.content)) continue - for (const block of event.content) { - if (block?.type === 'text' && typeof block.text === 'string') assistantText += block.text - } - } - - return { - success: true, - output: { - sessionId: target.sessionId, - events, - count: events.length, - assistantText, - // Compared against the untrimmed history size, not the limit: a - // session holding exactly `maxItems` events dropped nothing and must - // not be reported as a partial read. - truncated: total > events.length, - }, - } - } catch (error) { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId }, - error: getErrorMessage(error, 'Failed to list Managed Agent session events'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/managed_agent/respond_custom_tool.ts b/apps/sim/tools/managed_agent/respond_custom_tool.ts index a6281769da7..872c2c8b01b 100644 --- a/apps/sim/tools/managed_agent/respond_custom_tool.ts +++ b/apps/sim/tools/managed_agent/respond_custom_tool.ts @@ -1,18 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { sendCustomToolResults } from '@/lib/managed-agents/session-client' -import { isTruthyAck } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentCustomToolResultParams, ManagedAgentCustomToolResultResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Returns the result of a client-side custom tool the agent invoked. @@ -28,7 +24,8 @@ import type { ToolConfig } from '@/tools/types' * share a single result — silently wrong whenever more than one is pending. * Answer several by iterating this operation over `pendingTools`. */ -export const managedAgentRespondCustomToolTool: ToolConfig< + +export const managedAgentRespondCustomToolTool: InternalToolConfig< ManagedAgentCustomToolResultParams, ManagedAgentCustomToolResultResponse > = { @@ -63,56 +60,14 @@ export const managedAgentRespondCustomToolTool: ToolConfig< }, }, - request: { - ...UNUSED_REQUEST, + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ result: params.result }), }, }, - directExecution: async (params, signal): Promise => { - const emptyOutput = { sessionId: '', answeredToolUseId: '' } - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: emptyOutput, error: target.error } - } - - const customToolUseId = params.customToolUseId?.trim() - if (!customToolUseId) { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId }, - error: - 'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.', - } - } - - // The result may legitimately be empty (a tool that returns nothing), so - // only the id is required — an absent result is sent as an empty string. - const result = (params.result ?? '').toString() - const isError = isTruthyAck(params.isError) - - try { - await sendCustomToolResults({ - apiKey: target.apiKey, - sessionId: target.sessionId, - results: [{ customToolUseId, content: result, isError }], - ...(signal ? { signal } : {}), - }) - return { - success: true, - output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId }, - } - } catch (error) { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId }, - error: getErrorMessage(error, 'Failed to send custom tool result'), - } - } - }, - outputs: { sessionId: { type: 'string', description: 'The session that was answered.' }, answeredToolUseId: { diff --git a/apps/sim/tools/managed_agent/respond_tool_confirmation.test.ts b/apps/sim/tools/managed_agent/respond_tool_confirmation.test.ts index c5b67887833..ff9fe26b7f4 100644 --- a/apps/sim/tools/managed_agent/respond_tool_confirmation.test.ts +++ b/apps/sim/tools/managed_agent/respond_tool_confirmation.test.ts @@ -11,7 +11,7 @@ vi.mock('@/lib/managed-agents/session-client', () => ({ sendToolConfirmations: mockSendToolConfirmations, })) -import { managedAgentRespondToolConfirmationTool } from '@/tools/managed_agent/respond_tool_confirmation' +import { executeManagedAgentRespondToolConfirmationOperation } from '@/lib/internal/managed-agent/operations/respond-tool-confirmation' describe('Managed Agent tool confirmations', () => { beforeEach(() => { @@ -20,7 +20,7 @@ describe('Managed Agent tool confirmations', () => { }) it('sends a denial message only for deny decisions', async () => { - await managedAgentRespondToolConfirmationTool.directExecution?.({ + await executeManagedAgentRespondToolConfirmationOperation({ accessToken: 'token', sessionId: 'session-1', toolUseIds: ['tool-use-1'], @@ -33,7 +33,7 @@ describe('Managed Agent tool confirmations', () => { confirmations: [{ toolUseId: 'tool-use-1', result: 'allow' }], }) - await managedAgentRespondToolConfirmationTool.directExecution?.({ + await executeManagedAgentRespondToolConfirmationOperation({ accessToken: 'token', sessionId: 'session-1', toolUseIds: ['tool-use-1'], diff --git a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts index 3f1a487ce30..d3d648cf0f2 100644 --- a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts +++ b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts @@ -1,18 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { sendToolConfirmations } from '@/lib/managed-agents/session-client' -import { normalizeStringList } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentToolConfirmationParams, ManagedAgentToolConfirmationResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Answers the `always_ask` permission gates blocking a session. @@ -26,7 +22,8 @@ import type { ToolConfig } from '@/tools/types' * All ids are answered in a single request: resolving only some of a turn's * gates leaves the session parked on the rest. */ -export const managedAgentRespondToolConfirmationTool: ToolConfig< + +export const managedAgentRespondToolConfirmationTool: InternalToolConfig< ManagedAgentToolConfirmationParams, ManagedAgentToolConfirmationResponse > = { @@ -61,8 +58,8 @@ export const managedAgentRespondToolConfirmationTool: ToolConfig< }, }, - request: { - ...UNUSED_REQUEST, + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => @@ -72,57 +69,6 @@ export const managedAgentRespondToolConfirmationTool: ToolConfig< }, }, - directExecution: async (params, signal): Promise => { - const emptyOutput = { sessionId: '', decision: '', confirmedToolUseIds: [] as string[] } - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: emptyOutput, error: target.error } - } - - const decision = (params.decision ?? '').toString().trim().toLowerCase() - if (decision !== 'allow' && decision !== 'deny') { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId }, - error: "Decision must be 'allow' or 'deny'.", - } - } - - const toolUseIds = normalizeStringList(params.toolUseIds) - if (toolUseIds.length === 0) { - return { - success: false, - output: { ...emptyOutput, sessionId: target.sessionId, decision }, - error: - 'At least one tool-use event id is required. Read them from Get Session pendingTools[].id.', - } - } - - const denyMessage = params.denyMessage?.trim() - try { - await sendToolConfirmations({ - apiKey: target.apiKey, - sessionId: target.sessionId, - confirmations: toolUseIds.map((toolUseId) => ({ - toolUseId, - result: decision, - ...(decision === 'deny' && denyMessage ? { denyMessage } : {}), - })), - ...(signal ? { signal } : {}), - }) - return { - success: true, - output: { sessionId: target.sessionId, decision, confirmedToolUseIds: toolUseIds }, - } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, decision, confirmedToolUseIds: [] }, - error: getErrorMessage(error, 'Failed to send tool confirmation'), - } - } - }, - outputs: { sessionId: { type: 'string', description: 'The session that was answered.' }, decision: { type: 'string', description: "The decision applied — 'allow' or 'deny'." }, diff --git a/apps/sim/tools/managed_agent/run_session.test.ts b/apps/sim/tools/managed_agent/run_session.test.ts index 991fbdc998c..c5fce02a580 100644 --- a/apps/sim/tools/managed_agent/run_session.test.ts +++ b/apps/sim/tools/managed_agent/run_session.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { executeManagedAgentRunSessionOperation } from '@/lib/internal/managed-agent/operations/run-session' import * as runSessionModule from '@/lib/managed-agents/run-session' -import { managedAgentRunSessionTool } from '@/tools/managed_agent/run_session' import type { ManagedAgentRunSessionParams } from '@/tools/managed_agent/types' /** @@ -18,7 +18,7 @@ afterAll(() => { }) const run = (params: Partial) => - managedAgentRunSessionTool.directExecution!({ + executeManagedAgentRunSessionOperation({ credential: 'cred_1', accessToken: 'sk-ant-fake', agent: 'agent_1', @@ -38,7 +38,7 @@ beforeEach(() => { }) }) -describe('managedAgentRunSessionTool.directExecution', () => { +describe('executeManagedAgentRunSessionOperation', () => { it('errors when no credential key was injected', async () => { const res = await run({ accessToken: undefined }) expect(res.success).toBe(false) diff --git a/apps/sim/tools/managed_agent/run_session.ts b/apps/sim/tools/managed_agent/run_session.ts index 9315a29cd98..491499ab515 100644 --- a/apps/sim/tools/managed_agent/run_session.ts +++ b/apps/sim/tools/managed_agent/run_session.ts @@ -1,16 +1,9 @@ -import { runManagedAgentSession } from '@/lib/managed-agents/run-session' -import { - isTruthyAck, - normalizeFiles, - normalizeMemoryAccess, - normalizeSessionParameters, - normalizeStringList, -} from '@/tools/managed_agent/normalizers' import type { ManagedAgentRunSessionParams, ManagedAgentRunSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Opens a Claude Platform Managed Agent session and returns the assistant @@ -18,11 +11,10 @@ import type { ToolConfig } from '@/tools/types' * * The block's `credential` picker supplies a Claude Platform service-account * credential; the executor resolves it to the workspace API key and injects - * `accessToken` before `directExecution` runs. The session lifecycle - * (`runManagedAgentSession`) is pure `fetch` with no server-only deps, so the - * tool module stays safe to import from the client registry. + * `accessToken` before the registered operation runs. */ -export const managedAgentRunSessionTool: ToolConfig< + +export const managedAgentRunSessionTool: InternalToolConfig< ManagedAgentRunSessionParams, ManagedAgentRunSessionResponse > = { @@ -114,13 +106,8 @@ export const managedAgentRunSessionTool: ToolConfig< description: 'Key/value session metadata forwarded to the session.', }, }, - - // Unused: `directExecution` runs the session and short-circuits the HTTP - // path, but `ToolConfig` requires a `request` shape. - request: { - url: () => '', - method: 'POST', - headers: () => ({}), + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ @@ -130,88 +117,6 @@ export const managedAgentRunSessionTool: ToolConfig< }, }, - directExecution: async (params, signal): Promise => { - const apiKey = params.accessToken - if (!apiKey) { - return { - success: false, - output: { content: '', sessionId: '' }, - error: 'No Claude Platform credential is selected, or it could not be resolved.', - } - } - - const agentId = params.agent?.trim() - const environmentId = params.environment?.trim() - if (!agentId || !environmentId) { - return { - success: false, - output: { content: '', sessionId: '' }, - error: 'An agent and an environment are required.', - } - } - - const vaultIds = normalizeStringList(params.vaults) - if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { - return { - success: false, - output: { content: '', sessionId: '' }, - error: - 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', - } - } - - const files = normalizeFiles(params.files) - const sessionParameters = normalizeSessionParameters(params.sessionParameters) - const memoryStoreId = params.memoryStoreId?.trim() || undefined - const memoryAccess = normalizeMemoryAccess(params.memoryAccess) - const memoryInstructions = params.memoryInstructions?.trim() || undefined - - // Title the Anthropic session so it is traceable to its Sim workflow from - // the Claude Platform console. Only the workflow id is available in the - // client-safe execution context (names would require a DB lookup). - const workflowId = params._context?.workflowId?.trim() - const title = workflowId ? `Sim workflow ${workflowId}` : undefined - - const environmentType = - params.environmentType === 'self_hosted' || params.environmentType === 'cloud' - ? params.environmentType - : undefined - - const result = await runManagedAgentSession({ - apiKey, - agentId, - environmentId, - userMessage: (params.userMessage ?? '').toString(), - ...(environmentType ? { environmentType } : {}), - ...(title ? { title } : {}), - ...(vaultIds.length > 0 ? { vaultIds } : {}), - ...(memoryStoreId ? { memoryStoreId } : {}), - ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), - ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), - ...(files.length > 0 ? { files } : {}), - ...(sessionParameters ? { sessionParameters } : {}), - ...(signal ? { signal } : {}), - }) - - if (!result.ok) { - return { - success: false, - output: { content: result.content, sessionId: result.sessionId ?? '' }, - error: result.error ?? 'Managed Agent session failed', - } - } - - return { - success: true, - output: { - content: result.content, - sessionId: result.sessionId ?? '', - ...(result.inputTokens !== undefined ? { inputTokens: result.inputTokens } : {}), - ...(result.outputTokens !== undefined ? { outputTokens: result.outputTokens } : {}), - }, - } - }, - outputs: { content: { type: 'string', diff --git a/apps/sim/tools/managed_agent/send_message.ts b/apps/sim/tools/managed_agent/send_message.ts index 956381cfdef..e3f808943cd 100644 --- a/apps/sim/tools/managed_agent/send_message.ts +++ b/apps/sim/tools/managed_agent/send_message.ts @@ -1,17 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { sendUserMessage } from '@/lib/managed-agents/session-client' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentSendMessageParams, ManagedAgentSendMessageResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Sends a user turn into an EXISTING Managed Agent session and returns as soon @@ -25,7 +22,8 @@ import type { ToolConfig } from '@/tools/types' * Events are queued server-side and processed in order, so there is no need to * wait for the agent to go idle before sending the next one. */ -export const managedAgentSendMessageTool: ToolConfig< + +export const managedAgentSendMessageTool: InternalToolConfig< ManagedAgentSendMessageParams, ManagedAgentSendMessageResponse > = { @@ -46,46 +44,14 @@ export const managedAgentSendMessageTool: ToolConfig< }, }, - request: { - ...UNUSED_REQUEST, + operation: { + input: createInternalToolOperationInput, modelInput: { mode: 'project', select: (params) => ({ userMessage: params.userMessage }), }, }, - directExecution: async (params, signal): Promise => { - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: { sessionId: '', sent: false }, error: target.error } - } - - const text = (params.userMessage ?? '').toString().trim() - if (!text) { - return { - success: false, - output: { sessionId: target.sessionId, sent: false }, - error: 'A user message is required.', - } - } - - try { - await sendUserMessage({ - apiKey: target.apiKey, - sessionId: target.sessionId, - text, - ...(signal ? { signal } : {}), - }) - return { success: true, output: { sessionId: target.sessionId, sent: true } } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, sent: false }, - error: getErrorMessage(error, 'Failed to send message to Managed Agent session'), - } - } - }, - outputs: { sessionId: { type: 'string', description: 'The session the message was sent to.' }, sent: { type: 'boolean', description: 'True when the event was accepted by the API.' }, diff --git a/apps/sim/tools/managed_agent/shared.ts b/apps/sim/tools/managed_agent/shared.ts index b291e07feca..53489dc7781 100644 --- a/apps/sim/tools/managed_agent/shared.ts +++ b/apps/sim/tools/managed_agent/shared.ts @@ -7,8 +7,6 @@ * the guard that reads them live here rather than being restated per tool. */ -import type { ToolConfig } from '@/tools/types' - /** Credential picker value; the executor swaps it for `accessToken` at run time. */ export const CREDENTIAL_PARAM = { type: 'string', @@ -32,17 +30,6 @@ export const SESSION_ID_PARAM = { description: 'Anthropic session id (sesn_...) to act on.', } as const -/** - * `ToolConfig` requires a `request` shape even when `directExecution` - * short-circuits the HTTP path, so every session-operation tool reuses this - * inert stub instead of repeating it. - */ -export const UNUSED_REQUEST: ToolConfig['request'] = { - url: () => '', - method: 'POST', - headers: () => ({}), -} - /** Params common to every session-operation tool. */ export interface ManagedAgentSessionParams { credential: string diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index d35e39d6ffc..118c9d53ba2 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -2,9 +2,9 @@ import type { ToolResponse } from '@/tools/types' /** * Params accepted by the `managed_agent_run_session` tool. Values come from - * the Managed Agent block's subblocks in their raw runtime shapes; the tool's - * `directExecution` normalizes them before running the session. `accessToken` - * is injected by the executor from the selected `credential`. + * the Managed Agent block's subblocks in their raw runtime shapes; the internal + * operation normalizes them before running the session. `accessToken` is injected + * by the executor from the selected `credential`. */ export interface ManagedAgentRunSessionParams { /** Claude Platform service-account credential id (block picker value). */ diff --git a/apps/sim/tools/managed_agent/update_session.test.ts b/apps/sim/tools/managed_agent/update_session.test.ts index ddb8486106d..2342c229796 100644 --- a/apps/sim/tools/managed_agent/update_session.test.ts +++ b/apps/sim/tools/managed_agent/update_session.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { managedAgentUpdateSessionTool } from '@/tools/managed_agent/update_session' +import { executeManagedAgentUpdateSessionOperation } from '@/lib/internal/managed-agent/operations/update-session' const originalFetch = global.fetch afterEach(() => { @@ -16,7 +16,7 @@ const capture = () => { } const run = (params: Record) => - managedAgentUpdateSessionTool.directExecution!( + executeManagedAgentUpdateSessionOperation( { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', ...params } as never, undefined ) diff --git a/apps/sim/tools/managed_agent/update_session.ts b/apps/sim/tools/managed_agent/update_session.ts index 2520405148a..9cc1fefa7b2 100644 --- a/apps/sim/tools/managed_agent/update_session.ts +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -1,18 +1,14 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { updateSession } from '@/lib/managed-agents/session-client' -import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, - resolveSessionTarget, SESSION_ID_PARAM, - UNUSED_REQUEST, } from '@/tools/managed_agent/shared' import type { ManagedAgentUpdateSessionParams, ManagedAgentUpdateSessionResponse, } from '@/tools/managed_agent/types' -import type { ToolConfig } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** * Updates an existing session's title and/or metadata. @@ -27,7 +23,8 @@ import type { ToolConfig } from '@/tools/types' * entirely takes an explicit `clearMetadata`, because an empty map is * indistinguishable from a field the author never filled in. */ -export const managedAgentUpdateSessionTool: ToolConfig< + +export const managedAgentUpdateSessionTool: InternalToolConfig< ManagedAgentUpdateSessionParams, ManagedAgentUpdateSessionResponse > = { @@ -62,58 +59,8 @@ export const managedAgentUpdateSessionTool: ToolConfig< }, }, - request: UNUSED_REQUEST, - - directExecution: async (params, signal): Promise => { - const target = resolveSessionTarget(params) - if (!target.ok) { - return { success: false, output: { sessionId: '', updated: false }, error: target.error } - } - - // A whitespace-only title is treated as "not provided", not as a request to - // blank the session's title — otherwise a stray space in the field would - // both slip past the guard below and silently clear an existing title. - const trimmedTitle = params.title?.trim() - const title = trimmedTitle ? trimmedTitle : undefined - - // Clearing metadata needs its own explicit signal. An empty metadata table - // cannot mean "clear": a table the author never touched is also empty, so - // inferring intent from emptiness would wipe a session's metadata on every - // title-only update. `{}` is only sent when the author asks for it. - const clearMetadata = isTruthyAck(params.clearMetadata) - const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters) - if (title === undefined && metadata === undefined) { - return { - success: false, - output: { sessionId: target.sessionId, updated: false }, - error: 'Provide a title or metadata to update, or check "Clear metadata".', - } - } - - try { - const snapshot = await updateSession({ - apiKey: target.apiKey, - sessionId: target.sessionId, - ...(title !== undefined ? { title } : {}), - ...(metadata !== undefined ? { metadata } : {}), - ...(signal ? { signal } : {}), - }) - return { - success: true, - output: { - sessionId: target.sessionId, - updated: true, - ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), - ...(snapshot.title ? { title: snapshot.title } : {}), - }, - } - } catch (error) { - return { - success: false, - output: { sessionId: target.sessionId, updated: false }, - error: getErrorMessage(error, 'Failed to update Managed Agent session'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/metadata.ts b/apps/sim/tools/metadata.ts index 712e0c5abf9..f30ae1f634f 100644 --- a/apps/sim/tools/metadata.ts +++ b/apps/sim/tools/metadata.ts @@ -7,7 +7,7 @@ import type { OAuthConfig, ToolConfig } from '@/tools/types' * Serializable tool metadata, read without importing the executable registry. * * `@/tools/registry` is a barrel over 4,300+ tools whose `ToolConfig`s carry - * closures (`request.headers`, `transformResponse`, `directExecution`), and + * closures (`request.headers`, `transformResponse`), and * those closures drag ~4,700 modules into any graph that reaches them. Callers * that only need to know a tool's shape — its params, its outputs, or whether it * exists — read it from here instead, and stay off the registry entirely. diff --git a/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.test.ts b/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.test.ts index da34089111b..6560c7e653c 100644 --- a/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.test.ts +++ b/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.test.ts @@ -2,18 +2,14 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { addUserAppRoleAssignmentTool } from '@/tools/microsoft_ad/add_user_app_role_assignment' +import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment' const OBJECT_ID = 'cde330e5-2150-4c11-9c5b-14bfdc948c79' const RESOURCE_ID = '8e881353-1735-45af-af21-ee1344582a4d' const APP_ROLE_ID = '00000000-0000-0000-0000-000000000000' const UPN = 'jdoe@contoso.com' -const buildBody = addUserAppRoleAssignmentTool.request.body as ( - params: Record -) => Record - -const run = addUserAppRoleAssignmentTool.directExecution! +const run = executeAddUserAppRoleAssignmentOperation function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }): Response { return { @@ -137,21 +133,26 @@ describe('addUserAppRoleAssignmentTool principalId', () => { ).rejects.toThrow('Invalid value specified.') }) - /** - * The declarative request is only reachable if direct execution is bypassed, and it cannot - * perform the lookup — so it must refuse a UPN instead of sending one Graph will reject. - */ - describe('declarative fallback body', () => { - it('refuses a user principal name instead of putting it in principalId', () => { - expect(() => - buildBody({ userId: UPN, resourceId: RESOURCE_ID, appRoleId: APP_ROLE_ID }) - ).toThrow(/must be an object ID \(GUID\)/) - }) + it('does not turn an aborted response-body read into a successful assignment', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => { + controller.abort(new DOMException('cancelled', 'AbortError')) + throw controller.signal.reason + }, + } as Response) - it('accepts an object ID', () => { - expect( - buildBody({ userId: OBJECT_ID, resourceId: RESOURCE_ID, appRoleId: APP_ROLE_ID }) - ).toEqual({ principalId: OBJECT_ID, resourceId: RESOURCE_ID, appRoleId: APP_ROLE_ID }) - }) + await expect( + run( + { + accessToken: 'token', + userId: OBJECT_ID, + resourceId: RESOURCE_ID, + appRoleId: APP_ROLE_ID, + }, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) }) }) diff --git a/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.ts b/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.ts index 9957a8d37db..127acca8b65 100644 --- a/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.ts +++ b/apps/sim/tools/microsoft_ad/add_user_app_role_assignment.ts @@ -3,15 +3,11 @@ import type { MicrosoftAdAddUserAppRoleAssignmentResponse, } from '@/tools/microsoft_ad/types' import { APP_ROLE_ASSIGNMENT_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types' -import { - extractGraphErrorMessage, - isGraphObjectId, - resolveGraphUserObjectId, -} from '@/tools/microsoft_ad/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' /** Projects a Microsoft Graph `appRoleAssignment` onto the documented subset of fields. */ -function mapAppRoleAssignment(assignment: Record): Record { +export function mapAppRoleAssignment(assignment: Record): Record { return { id: assignment.id ?? null, appRoleId: assignment.appRoleId ?? null, @@ -25,7 +21,7 @@ function mapAppRoleAssignment(assignment: Record): Record = { @@ -85,57 +81,8 @@ export const addUserAppRoleAssignmentTool: ToolConfig< * grant, so the UPN is resolved to its object ID first and only the GUID reaches the body. * @see https://learn.microsoft.com/en-us/graph/api/user-post-approleassignments */ - directExecution: async (params, signal): Promise => { - const { userId, resourceId, appRoleId } = readIdentifiers(params) - const principalId = await resolveGraphUserObjectId(userId, params.accessToken, signal) - - const response = await fetch( - `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/appRoleAssignments`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ principalId, resourceId, appRoleId }), - signal, - } - ) - const body = await response.json().catch(() => ({})) - if (!response.ok) { - throw new Error(extractGraphErrorMessage(body, 'Failed to grant the app role to the user')) - } - - return { success: true, output: { assignment: mapAppRoleAssignment(body) } } - }, - /** - * Declarative fallback. `directExecution` is the authoritative path; this cannot perform the - * userPrincipalName lookup, so it rejects anything but an object ID rather than sending a - * value Graph will refuse. - */ - request: { - url: (params) => { - const { userId } = readIdentifiers(params) - return `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/appRoleAssignments` - }, - method: 'POST', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - }), - body: (params) => { - const { userId, resourceId, appRoleId } = readIdentifiers(params) - if (!isGraphObjectId(userId)) { - throw new Error( - `User ID "${userId}" must be an object ID (GUID) to grant an app role directly. Use Get User to look it up from a user principal name.` - ) - } - return { principalId: userId, resourceId, appRoleId } - }, - }, - transformResponse: async (response: Response) => { - const assignment = await response.json() - return { success: true, output: { assignment: mapAppRoleAssignment(assignment) } } + operation: { + input: createInternalToolOperationInput, }, outputs: { assignment: { diff --git a/apps/sim/tools/netsuite/attach_record.ts b/apps/sim/tools/netsuite/attach_record.ts index b358e733916..1e55643613e 100644 --- a/apps/sim/tools/netsuite/attach_record.ts +++ b/apps/sim/tools/netsuite/attach_record.ts @@ -1,96 +1,62 @@ import type { NetSuiteAttachParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeRelatedType, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteAttachRecordTool: ToolConfig = { - id: 'netsuite_attach_record', - name: 'NetSuite Attach Record or File', - description: 'Attach a contact or file to another NetSuite record.', - version: '1.0.0', - params: { - ...netsuiteAuthParamFields, - recordType: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite REST record type script ID, such as customer or salesOrder', - }, - recordId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite internal ID or an external-ID reference beginning with eid:', - }, - relatedType: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Related resource type: contact or file', - }, - relatedId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Internal ID, or external ID prefixed with eid:, of the contact or file', - }, - roleId: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Optional contact role internal ID', +export const netsuiteAttachRecordTool: InternalToolConfig = + { + id: 'netsuite_attach_record', + name: 'NetSuite Attach Record or File', + description: 'Attach a contact or file to another NetSuite record.', + version: '1.0.0', + params: { + ...netsuiteAuthParamFields, + recordType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite REST record type script ID, such as customer or salesOrder', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite internal ID or an external-ID reference beginning with eid:', + }, + relatedType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Related resource type: contact or file', + }, + relatedId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Internal ID, or external ID prefixed with eid:, of the contact or file', + }, + roleId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional contact role internal ID', + }, + roleExternalId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional contact role external ID', + }, }, - roleExternalId: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Optional contact role external ID', + operation: { + input: createInternalToolOperationInput, }, - }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const relatedType = normalizeRelatedType(params.relatedType) - const roleId = optionalTrim(params.roleId) - const roleExternalId = optionalTrim(params.roleExternalId) - if (roleId && roleExternalId) { - throw new Error('Provide either a contact role ID or external ID, not both') - } - if (relatedType === 'file' && (roleId || roleExternalId)) { - throw new Error('Contact roles cannot be provided when attaching a file') - } - return { - method: 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' }, - { value: '!attach', label: 'Attach operation' }, - { value: relatedType, label: 'Related type' }, - { value: params.relatedId, label: 'Related ID' } - ), - success: { status: 204, body: 'none' }, - body: roleId - ? { role: { id: roleId } } - : roleExternalId - ? { role: { externalId: roleExternalId } } - : {}, - } + outputs: { + status: { type: 'number', description: 'HTTP status returned by NetSuite' }, + data: { + type: 'json', + description: 'Empty for the documented HTTP 204 No Content response', + nullable: true, }, - signal - ), - outputs: { - status: { type: 'number', description: 'HTTP status returned by NetSuite' }, - data: { - type: 'json', - description: 'Empty for the documented HTTP 204 No Content response', - nullable: true, }, - }, -} + } diff --git a/apps/sim/tools/netsuite/batch_create_records.ts b/apps/sim/tools/netsuite/batch_create_records.ts index 4366b548489..887b89ff40b 100644 --- a/apps/sim/tools/netsuite/batch_create_records.ts +++ b/apps/sim/tools/netsuite/batch_create_records.ts @@ -1,12 +1,9 @@ import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildBatchWriteRequest, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteBatchCreateRecordsTool: ToolConfig< +export const netsuiteBatchCreateRecordsTool: InternalToolConfig< NetSuiteBatchWriteParams, NetSuiteResponse > = { @@ -36,9 +33,9 @@ export const netsuiteBatchCreateRecordsTool: ToolConfig< description: 'Optional unique idempotency key for retrying the batch', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest(params, () => buildBatchWriteRequest('POST', params), signal), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/batch_delete_records.ts b/apps/sim/tools/netsuite/batch_delete_records.ts index 46a19504e19..64fc2120abd 100644 --- a/apps/sim/tools/netsuite/batch_delete_records.ts +++ b/apps/sim/tools/netsuite/batch_delete_records.ts @@ -1,14 +1,9 @@ import type { NetSuiteBatchDeleteParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeBatchIds, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteBatchDeleteRecordsTool: ToolConfig< +export const netsuiteBatchDeleteRecordsTool: InternalToolConfig< NetSuiteBatchDeleteParams, NetSuiteResponse > = { @@ -37,26 +32,9 @@ export const netsuiteBatchDeleteRecordsTool: ToolConfig< description: 'Optional unique idempotency key for retrying the batch', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key') - return { - method: 'DELETE', - path: buildRecordPath({ value: params.recordType, label: 'Record type' }), - success: { status: 202, body: 'none' }, - responseLocation: 'async-job', - query: { ids: normalizeBatchIds(params.ids) }, - headers: { - Prefer: 'respond-async', - ...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}), - }, - } - }, - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/batch_get_records.ts b/apps/sim/tools/netsuite/batch_get_records.ts index 3984bb61866..8c7e97e0ab2 100644 --- a/apps/sim/tools/netsuite/batch_get_records.ts +++ b/apps/sim/tools/netsuite/batch_get_records.ts @@ -1,15 +1,12 @@ import type { NetSuiteBatchGetParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeBatchIds, - normalizeOptionalBoolean, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteBatchGetRecordsTool: ToolConfig = { +export const netsuiteBatchGetRecordsTool: InternalToolConfig< + NetSuiteBatchGetParams, + NetSuiteResponse +> = { id: 'netsuite_batch_get_records', name: 'NetSuite Batch Get Records', description: 'Submit an asynchronous request to retrieve up to 100 records of one type.', @@ -53,35 +50,9 @@ export const netsuiteBatchGetRecordsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key') - return { - method: 'GET', - path: buildRecordPath({ value: params.recordType, label: 'Record type' }), - success: { status: 202, body: 'none' }, - responseLocation: 'async-job', - query: { - expandRecords: true, - ids: normalizeBatchIds(params.ids), - fields: optionalTrim(params.fields, 'Fields'), - expand: optionalTrim(params.expand, 'Expand'), - expandSubResources: normalizeOptionalBoolean( - params.expandSubResources, - 'Expand subresources' - ), - }, - headers: { - Prefer: 'respond-async', - ...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}), - }, - } - }, - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/batch_update_records.ts b/apps/sim/tools/netsuite/batch_update_records.ts index d8250038b45..1aa02390f48 100644 --- a/apps/sim/tools/netsuite/batch_update_records.ts +++ b/apps/sim/tools/netsuite/batch_update_records.ts @@ -1,12 +1,9 @@ import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildBatchWriteRequest, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteBatchUpdateRecordsTool: ToolConfig< +export const netsuiteBatchUpdateRecordsTool: InternalToolConfig< NetSuiteBatchWriteParams, NetSuiteResponse > = { @@ -36,9 +33,9 @@ export const netsuiteBatchUpdateRecordsTool: ToolConfig< description: 'Optional unique idempotency key for retrying the batch', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest(params, () => buildBatchWriteRequest('PATCH', params), signal), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/batch_upsert_records.ts b/apps/sim/tools/netsuite/batch_upsert_records.ts index d637cf995f7..8609fa2dec7 100644 --- a/apps/sim/tools/netsuite/batch_upsert_records.ts +++ b/apps/sim/tools/netsuite/batch_upsert_records.ts @@ -1,12 +1,9 @@ import type { NetSuiteBatchWriteParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildBatchWriteRequest, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteBatchUpsertRecordsTool: ToolConfig< +export const netsuiteBatchUpsertRecordsTool: InternalToolConfig< NetSuiteBatchWriteParams, NetSuiteResponse > = { @@ -37,9 +34,9 @@ export const netsuiteBatchUpsertRecordsTool: ToolConfig< description: 'Optional unique idempotency key for retrying the batch', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest(params, () => buildBatchWriteRequest('PUT', params), signal), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/create_record.ts b/apps/sim/tools/netsuite/create_record.ts index 11c4e288891..15302f970db 100644 --- a/apps/sim/tools/netsuite/create_record.ts +++ b/apps/sim/tools/netsuite/create_record.ts @@ -1,13 +1,12 @@ import type { NetSuiteCreateRecordParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteCreateRecordTool: ToolConfig = { +export const netsuiteCreateRecordTool: InternalToolConfig< + NetSuiteCreateRecordParams, + NetSuiteResponse +> = { id: 'netsuite_create_record', name: 'NetSuite Create Record', description: 'Create a NetSuite record using the account-specific record metadata schema.', @@ -33,23 +32,9 @@ export const netsuiteCreateRecordTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const replace = optionalTrim(params.replace, 'Replace sublists') - return { - method: 'POST', - path: buildRecordPath({ value: params.recordType, label: 'Record type' }), - success: replace ? { status: 201, body: 'object' } : { status: 204, body: 'none' }, - responseLocation: 'resource', - query: { replace }, - body: params.body, - } - }, - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/delete_record.ts b/apps/sim/tools/netsuite/delete_record.ts index 8c9c8ebd882..f28b4e1f4a8 100644 --- a/apps/sim/tools/netsuite/delete_record.ts +++ b/apps/sim/tools/netsuite/delete_record.ts @@ -1,12 +1,12 @@ import type { NetSuiteDeleteRecordParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteDeleteRecordTool: ToolConfig = { +export const netsuiteDeleteRecordTool: InternalToolConfig< + NetSuiteDeleteRecordParams, + NetSuiteResponse +> = { id: 'netsuite_delete_record', name: 'NetSuite Delete Record', description: 'Delete one NetSuite record by internal or external ID.', @@ -26,20 +26,9 @@ export const netsuiteDeleteRecordTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'DELETE', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' } - ), - success: { status: 204, body: 'none' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/detach_record.ts b/apps/sim/tools/netsuite/detach_record.ts index dd4cec6b817..3205a14d3f5 100644 --- a/apps/sim/tools/netsuite/detach_record.ts +++ b/apps/sim/tools/netsuite/detach_record.ts @@ -1,13 +1,12 @@ import type { NetSuiteRelationshipParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeRelatedType, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteDetachRecordTool: ToolConfig = { +export const netsuiteDetachRecordTool: InternalToolConfig< + NetSuiteRelationshipParams, + NetSuiteResponse +> = { id: 'netsuite_detach_record', name: 'NetSuite Detach Record or File', description: 'Detach a contact or file from another NetSuite record.', @@ -39,23 +38,9 @@ export const netsuiteDetachRecordTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' }, - { value: '!detach', label: 'Detach operation' }, - { value: normalizeRelatedType(params.relatedType), label: 'Related type' }, - { value: params.relatedId, label: 'Related ID' } - ), - success: { status: 204, body: 'none' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/execute_action.ts b/apps/sim/tools/netsuite/execute_action.ts index 3be86a258f7..f96c2d1e884 100644 --- a/apps/sim/tools/netsuite/execute_action.ts +++ b/apps/sim/tools/netsuite/execute_action.ts @@ -1,73 +1,58 @@ import type { NetSuiteExecuteActionParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - requiredTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteExecuteActionTool: ToolConfig = - { - id: 'netsuite_execute_action', - name: 'NetSuite Execute Record Action', - description: 'Execute a supported NetSuite record action such as approve, reject, or confirm.', - version: '1.0.0', - params: { - ...netsuiteAuthParamFields, - recordType: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite REST record type script ID, such as customer or salesOrder', - }, - recordId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite internal ID or an external-ID reference beginning with eid:', - }, - action: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite record action ID without the @ prefix', - }, - body: { - type: 'json', - required: false, - visibility: 'user-or-llm', - description: 'Parameters accepted by the selected NetSuite record action', - }, +export const netsuiteExecuteActionTool: InternalToolConfig< + NetSuiteExecuteActionParams, + NetSuiteResponse +> = { + id: 'netsuite_execute_action', + name: 'NetSuite Execute Record Action', + description: 'Execute a supported NetSuite record action such as approve, reject, or confirm.', + version: '1.0.0', + params: { + ...netsuiteAuthParamFields, + recordType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite REST record type script ID, such as customer or salesOrder', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite internal ID or an external-ID reference beginning with eid:', + }, + action: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite record action ID without the @ prefix', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Parameters accepted by the selected NetSuite record action', }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' }, - { value: `@${requiredTrim(params.action, 'Action')}`, label: 'Action' } - ), - success: { status: 200, body: 'object', validator: 'record-action' }, - body: params.body ?? {}, - }), - signal - ), - outputs: { - status: { type: 'number', description: 'HTTP status returned by NetSuite' }, - data: { - type: 'json', - description: 'Documented NetSuite record-action response', - nullable: true, - properties: { - result: { - type: 'boolean', - description: 'True when NetSuite completed the record action', - }, + }, + operation: { + input: createInternalToolOperationInput, + }, + outputs: { + status: { type: 'number', description: 'HTTP status returned by NetSuite' }, + data: { + type: 'json', + description: 'Documented NetSuite record-action response', + nullable: true, + properties: { + result: { + type: 'boolean', + description: 'True when NetSuite completed the record action', }, }, }, - } + }, +} diff --git a/apps/sim/tools/netsuite/execute_dataset.ts b/apps/sim/tools/netsuite/execute_dataset.ts index c1bdbc55c19..da44bffc13c 100644 --- a/apps/sim/tools/netsuite/execute_dataset.ts +++ b/apps/sim/tools/netsuite/execute_dataset.ts @@ -1,13 +1,9 @@ import type { NetSuiteExecuteDatasetParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - encodePathSegment, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizePagination, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteExecuteDatasetTool: ToolConfig< +export const netsuiteExecuteDatasetTool: InternalToolConfig< NetSuiteExecuteDatasetParams, NetSuiteResponse > = { @@ -39,18 +35,9 @@ export const netsuiteExecuteDatasetTool: ToolConfig< 'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: `/services/rest/query/v1/dataset/${encodePathSegment(params.datasetId, 'Dataset ID')}/result`, - success: { status: 200, body: 'object', validator: 'collection-page' }, - query: normalizePagination(params.limit, params.offset), - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/execute_suiteql.ts b/apps/sim/tools/netsuite/execute_suiteql.ts index a4dbc7021d8..841f1eb5bdd 100644 --- a/apps/sim/tools/netsuite/execute_suiteql.ts +++ b/apps/sim/tools/netsuite/execute_suiteql.ts @@ -1,13 +1,12 @@ import type { NetSuiteResponse, NetSuiteSuiteQLParams } from '@/tools/netsuite/types' -import { - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizePagination, - requiredTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteExecuteSuiteQLTool: ToolConfig = { +export const netsuiteExecuteSuiteQLTool: InternalToolConfig< + NetSuiteSuiteQLParams, + NetSuiteResponse +> = { id: 'netsuite_execute_suiteql', name: 'NetSuite Execute SuiteQL', description: 'Execute one page of a SuiteQL query through SuiteTalk REST web services.', @@ -37,20 +36,9 @@ export const netsuiteExecuteSuiteQLTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'POST', - path: '/services/rest/query/v1/suiteql', - success: { status: 200, body: 'object', validator: 'suiteql-page' }, - query: normalizePagination(params.limit, params.offset), - headers: { Prefer: 'transient' }, - body: { q: requiredTrim(params.query, 'SuiteQL query') }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_async_result.ts b/apps/sim/tools/netsuite/get_async_result.ts index a5890da5075..737444a4ad8 100644 --- a/apps/sim/tools/netsuite/get_async_result.ts +++ b/apps/sim/tools/netsuite/get_async_result.ts @@ -1,12 +1,9 @@ import type { NetSuiteGetAsyncResultParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - encodePathSegment, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetAsyncResultTool: ToolConfig< +export const netsuiteGetAsyncResultTool: InternalToolConfig< NetSuiteGetAsyncResultParams, NetSuiteResponse > = { @@ -29,17 +26,9 @@ export const netsuiteGetAsyncResultTool: ToolConfig< description: 'Task ID within the asynchronous job', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}/task/${encodePathSegment(params.taskId, 'Task ID')}/result`, - success: { status: 200, body: 'optional-object' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_async_status.ts b/apps/sim/tools/netsuite/get_async_status.ts index 5470d57eea2..609ff55cca1 100644 --- a/apps/sim/tools/netsuite/get_async_status.ts +++ b/apps/sim/tools/netsuite/get_async_status.ts @@ -1,13 +1,9 @@ import type { NetSuiteGetAsyncStatusParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - encodePathSegment, - executeNetSuiteRequest, - netsuiteAuthParamFields, - requiredTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetAsyncStatusTool: ToolConfig< +export const netsuiteGetAsyncStatusTool: InternalToolConfig< NetSuiteGetAsyncStatusParams, NetSuiteResponse > = { @@ -37,39 +33,9 @@ export const netsuiteGetAsyncStatusTool: ToolConfig< description: 'Task ID; required when view is task', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const view = params.view ?? 'job' - if (view !== 'job' && view !== 'tasks' && view !== 'task') { - throw new Error('Async status view must be job, tasks, or task') - } - const jobPath = `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}` - if (view === 'job') { - return { - method: 'GET', - path: jobPath, - success: { status: 200, body: 'object', validator: 'async-job' }, - } - } - const taskPath = - view === 'task' - ? `/${encodePathSegment(requiredTrim(params.taskId ?? '', 'Task ID'), 'Task ID')}` - : '' - return { - method: 'GET', - path: `${jobPath}/task${taskPath}`, - success: { - status: 200, - body: 'object', - validator: view === 'task' ? 'async-task' : 'async-task-collection', - }, - } - }, - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_governance_limits.ts b/apps/sim/tools/netsuite/get_governance_limits.ts index 93cf1779282..fd6a9953a48 100644 --- a/apps/sim/tools/netsuite/get_governance_limits.ts +++ b/apps/sim/tools/netsuite/get_governance_limits.ts @@ -1,8 +1,12 @@ import type { NetSuiteResponse, NetSuiteSystemParams } from '@/tools/netsuite/types' -import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetGovernanceLimitsTool: ToolConfig = { +export const netsuiteGetGovernanceLimitsTool: InternalToolConfig< + NetSuiteSystemParams, + NetSuiteResponse +> = { id: 'netsuite_get_governance_limits', name: 'NetSuite Get Governance Limits', description: @@ -11,17 +15,9 @@ export const netsuiteGetGovernanceLimitsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: '/services/rest/system/v1/governanceLimits', - success: { status: 200, body: 'object', validator: 'governance-limits' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_record.ts b/apps/sim/tools/netsuite/get_record.ts index 3ea719f5b1a..f03c49009b6 100644 --- a/apps/sim/tools/netsuite/get_record.ts +++ b/apps/sim/tools/netsuite/get_record.ts @@ -1,79 +1,56 @@ import type { NetSuiteGetRecordParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeOptionalBoolean, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetRecordTool: ToolConfig = { - id: 'netsuite_get_record', - name: 'NetSuite Get Record', - description: 'Retrieve one NetSuite record by internal or external ID.', - version: '1.0.0', - params: { - ...netsuiteAuthParamFields, - recordType: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite REST record type script ID, such as customer or salesOrder', +export const netsuiteGetRecordTool: InternalToolConfig = + { + id: 'netsuite_get_record', + name: 'NetSuite Get Record', + description: 'Retrieve one NetSuite record by internal or external ID.', + version: '1.0.0', + params: { + ...netsuiteAuthParamFields, + recordType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite REST record type script ID, such as customer or salesOrder', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite internal ID or an external-ID reference beginning with eid:', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated record fields to return', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated resources to expand when supported by the record metadata', + }, + expandSubResources: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to expand sublists and subrecords in the response', + }, }, - recordId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite internal ID or an external-ID reference beginning with eid:', + operation: { + input: createInternalToolOperationInput, }, - fields: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Comma-separated record fields to return', + outputs: { + status: { type: 'number', description: 'HTTP status returned by NetSuite' }, + data: { + type: 'json', + description: 'NetSuite response body; record fields are account-specific and dynamic', + nullable: true, + }, }, - expand: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Comma-separated resources to expand when supported by the record metadata', - }, - expandSubResources: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to expand sublists and subrecords in the response', - }, - }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' } - ), - success: { status: 200, body: 'object' }, - query: { - fields: optionalTrim(params.fields), - expand: optionalTrim(params.expand), - expandSubResources: normalizeOptionalBoolean( - params.expandSubResources, - 'Expand subresources' - ), - }, - }), - signal - ), - outputs: { - status: { type: 'number', description: 'HTTP status returned by NetSuite' }, - data: { - type: 'json', - description: 'NetSuite response body; record fields are account-specific and dynamic', - nullable: true, - }, - }, -} + } diff --git a/apps/sim/tools/netsuite/get_record_form.ts b/apps/sim/tools/netsuite/get_record_form.ts index a3d0523fdab..be7b96da1ff 100644 --- a/apps/sim/tools/netsuite/get_record_form.ts +++ b/apps/sim/tools/netsuite/get_record_form.ts @@ -1,93 +1,64 @@ import type { NetSuiteGetRecordFormParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizeOptionalBoolean, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetRecordFormTool: ToolConfig = - { - id: 'netsuite_get_record_form', - name: 'NetSuite Get Record Form', - description: 'Return a prepopulated create form, or an edit form when a record ID is supplied.', - version: '1.0.0', - params: { - ...netsuiteAuthParamFields, - recordType: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'NetSuite REST record type script ID, such as customer or salesOrder', - }, - recordId: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Existing record ID; omit to request a create form', - }, - body: { - type: 'json', - required: false, - visibility: 'user-or-llm', - description: 'Record fields matching the account-specific NetSuite metadata schema', - }, - fields: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Comma-separated record fields to return', - }, - expand: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Comma-separated resources to expand when supported by the record metadata', - }, - expandSubResources: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to expand sublists and subrecords in the response', - }, +export const netsuiteGetRecordFormTool: InternalToolConfig< + NetSuiteGetRecordFormParams, + NetSuiteResponse +> = { + id: 'netsuite_get_record_form', + name: 'NetSuite Get Record Form', + description: 'Return a prepopulated create form, or an edit form when a record ID is supplied.', + version: '1.0.0', + params: { + ...netsuiteAuthParamFields, + recordType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'NetSuite REST record type script ID, such as customer or salesOrder', }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const recordId = optionalTrim(params.recordId) - return { - method: recordId ? 'PATCH' : 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - ...(recordId ? [{ value: recordId, label: 'Record ID' }] : []) - ), - success: { status: 200, body: 'object' }, - query: { - fields: optionalTrim(params.fields), - expand: optionalTrim(params.expand), - expandSubResources: normalizeOptionalBoolean( - params.expandSubResources, - 'Expand subresources' - ), - }, - headers: { - Accept: `application/vnd.oracle.resource+json; type=${recordId ? 'edit-form' : 'create-form'}`, - }, - body: params.body ?? {}, - } - }, - signal - ), - outputs: { - status: { type: 'number', description: 'HTTP status returned by NetSuite' }, - data: { - type: 'json', - description: 'NetSuite response body; record fields are account-specific and dynamic', - nullable: true, - }, + recordId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Existing record ID; omit to request a create form', }, - } + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Record fields matching the account-specific NetSuite metadata schema', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated record fields to return', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated resources to expand when supported by the record metadata', + }, + expandSubResources: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to expand sublists and subrecords in the response', + }, + }, + operation: { + input: createInternalToolOperationInput, + }, + outputs: { + status: { type: 'number', description: 'HTTP status returned by NetSuite' }, + data: { + type: 'json', + description: 'NetSuite response body; record fields are account-specific and dynamic', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/netsuite/get_record_metadata.ts b/apps/sim/tools/netsuite/get_record_metadata.ts index 3d62eea0cff..292b911c51c 100644 --- a/apps/sim/tools/netsuite/get_record_metadata.ts +++ b/apps/sim/tools/netsuite/get_record_metadata.ts @@ -1,10 +1,7 @@ import type { NetSuiteGetRecordMetadataParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - encodePathSegment, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' const METADATA_ACCEPT = { default: 'application/json', @@ -12,13 +9,13 @@ const METADATA_ACCEPT = { json_schema: 'application/schema+json', } as const -function getMetadataAccept(format: NetSuiteGetRecordMetadataParams['format']): string { +export function getMetadataAccept(format: NetSuiteGetRecordMetadataParams['format']): string { const accept = METADATA_ACCEPT[format ?? 'default'] if (!accept) throw new Error('Metadata format must be default, openapi, or json_schema') return accept } -export const netsuiteGetRecordMetadataTool: ToolConfig< +export const netsuiteGetRecordMetadataTool: InternalToolConfig< NetSuiteGetRecordMetadataParams, NetSuiteResponse > = { @@ -42,18 +39,9 @@ export const netsuiteGetRecordMetadataTool: ToolConfig< description: 'Metadata representation: default, openapi, or json_schema', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: `/services/rest/record/v1/metadata-catalog/${encodePathSegment(params.recordType, 'Record type')}`, - success: { status: 200, body: 'object' }, - headers: { Accept: getMetadataAccept(params.format) }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_select_options.ts b/apps/sim/tools/netsuite/get_select_options.ts index bbd39a36f0e..747ec93531b 100644 --- a/apps/sim/tools/netsuite/get_select_options.ts +++ b/apps/sim/tools/netsuite/get_select_options.ts @@ -1,15 +1,9 @@ import type { NetSuiteGetSelectOptionsParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizePagination, - optionalTrim, - requiredTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetSelectOptionsTool: ToolConfig< +export const netsuiteGetSelectOptionsTool: InternalToolConfig< NetSuiteGetSelectOptionsParams, NetSuiteResponse > = { @@ -65,31 +59,9 @@ export const netsuiteGetSelectOptionsTool: ToolConfig< 'Zero-based result offset; must be divisible by limit and stay within the first 100,000 results and 1,000 pages', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => { - const recordId = optionalTrim(params.recordId) - const pagination = normalizePagination(params.limit, params.offset) - return { - method: recordId ? 'PATCH' : 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - ...(recordId ? [{ value: recordId, label: 'Record ID' }] : []) - ), - success: { status: 200, body: 'object' }, - query: { - ...pagination, - fields: requiredTrim(params.fields, 'Fields'), - q: optionalTrim(params.q), - }, - headers: { Accept: 'application/vnd.oracle.resource+json; type=select-options' }, - body: params.body ?? {}, - } - }, - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/get_server_time.ts b/apps/sim/tools/netsuite/get_server_time.ts index 6bab5611b1b..03f4fb6e4ba 100644 --- a/apps/sim/tools/netsuite/get_server_time.ts +++ b/apps/sim/tools/netsuite/get_server_time.ts @@ -1,35 +1,29 @@ import type { NetSuiteResponse, NetSuiteSystemParams } from '@/tools/netsuite/types' -import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetServerTimeTool: ToolConfig = { - id: 'netsuite_get_server_time', - name: 'NetSuite Get Server Time', - description: 'Retrieve the current UTC time from the NetSuite server.', - version: '1.0.0', - params: { - ...netsuiteAuthParamFields, - }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: '/services/rest/system/v1/serverTime', - success: { status: 200, body: 'object', validator: 'server-time' }, - }), - signal - ), - outputs: { - status: { type: 'number', description: 'HTTP status returned by NetSuite' }, - data: { - type: 'json', - description: 'NetSuite server time response', - nullable: true, - properties: { - serverTime: { type: 'string', description: 'Current NetSuite server time in UTC' }, +export const netsuiteGetServerTimeTool: InternalToolConfig = + { + id: 'netsuite_get_server_time', + name: 'NetSuite Get Server Time', + description: 'Retrieve the current UTC time from the NetSuite server.', + version: '1.0.0', + params: { + ...netsuiteAuthParamFields, + }, + operation: { + input: createInternalToolOperationInput, + }, + outputs: { + status: { type: 'number', description: 'HTTP status returned by NetSuite' }, + data: { + type: 'json', + description: 'NetSuite server time response', + nullable: true, + properties: { + serverTime: { type: 'string', description: 'Current NetSuite server time in UTC' }, + }, }, }, - }, -} + } diff --git a/apps/sim/tools/netsuite/get_subresource.ts b/apps/sim/tools/netsuite/get_subresource.ts index 738d4c5c69c..88e79467720 100644 --- a/apps/sim/tools/netsuite/get_subresource.ts +++ b/apps/sim/tools/netsuite/get_subresource.ts @@ -1,13 +1,9 @@ import type { NetSuiteGetSubresourceParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - buildSubresourcePath, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteGetSubresourceTool: ToolConfig< +export const netsuiteGetSubresourceTool: InternalToolConfig< NetSuiteGetSubresourceParams, NetSuiteResponse > = { @@ -36,21 +32,9 @@ export const netsuiteGetSubresourceTool: ToolConfig< description: 'Slash-separated subresource path, such as item or item/1/inventoryDetail', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' }, - ...buildSubresourcePath(params.subresourcePath) - ), - success: { status: 200, body: 'object' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/list_datasets.ts b/apps/sim/tools/netsuite/list_datasets.ts index e6b9028e196..cc4325bd051 100644 --- a/apps/sim/tools/netsuite/list_datasets.ts +++ b/apps/sim/tools/netsuite/list_datasets.ts @@ -1,12 +1,12 @@ import type { NetSuiteListDatasetsParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizePagination, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteListDatasetsTool: ToolConfig = { +export const netsuiteListDatasetsTool: InternalToolConfig< + NetSuiteListDatasetsParams, + NetSuiteResponse +> = { id: 'netsuite_list_datasets', name: 'NetSuite List SuiteAnalytics Datasets', description: @@ -30,18 +30,9 @@ export const netsuiteListDatasetsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: '/services/rest/query/v1/dataset/', - success: { status: 200, body: 'object', validator: 'collection-page' }, - query: normalizePagination(params.limit, params.offset), - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/list_record_types.ts b/apps/sim/tools/netsuite/list_record_types.ts index 50fb0f73272..c39b5554a45 100644 --- a/apps/sim/tools/netsuite/list_record_types.ts +++ b/apps/sim/tools/netsuite/list_record_types.ts @@ -1,8 +1,9 @@ import type { NetSuiteListRecordTypesParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { executeNetSuiteRequest, netsuiteAuthParamFields } from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteListRecordTypesTool: ToolConfig< +export const netsuiteListRecordTypesTool: InternalToolConfig< NetSuiteListRecordTypesParams, NetSuiteResponse > = { @@ -13,17 +14,9 @@ export const netsuiteListRecordTypesTool: ToolConfig< params: { ...netsuiteAuthParamFields, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: '/services/rest/record/v1/metadata-catalog', - success: { status: 200, body: 'object', validator: 'metadata-catalog' }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/list_records.ts b/apps/sim/tools/netsuite/list_records.ts index f78d98a049e..fd3a7f2d0b2 100644 --- a/apps/sim/tools/netsuite/list_records.ts +++ b/apps/sim/tools/netsuite/list_records.ts @@ -1,14 +1,12 @@ import type { NetSuiteListRecordsParams, NetSuiteResponse } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - normalizePagination, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteListRecordsTool: ToolConfig = { +export const netsuiteListRecordsTool: InternalToolConfig< + NetSuiteListRecordsParams, + NetSuiteResponse +> = { id: 'netsuite_list_records', name: 'NetSuite List/Search Records', description: @@ -44,21 +42,9 @@ export const netsuiteListRecordsTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'GET', - path: buildRecordPath({ value: params.recordType, label: 'Record type' }), - success: { status: 200, body: 'object', validator: 'collection-page' }, - query: { - ...normalizePagination(params.limit, params.offset), - q: optionalTrim(params.q, 'Filter'), - }, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/netsuite.test.ts b/apps/sim/tools/netsuite/netsuite.test.ts index 1008555a23e..6e7232897fe 100644 --- a/apps/sim/tools/netsuite/netsuite.test.ts +++ b/apps/sim/tools/netsuite/netsuite.test.ts @@ -7,6 +7,10 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' vi.unmock('@/tools/registry') +import { executeNetsuiteTool } from '@/lib/internal/netsuite/execute-tool' +import { executeNetsuiteAttachRecordOperation } from '@/lib/internal/netsuite/operations/attach-record' +import { executeNetsuiteGetSelectOptionsOperation } from '@/lib/internal/netsuite/operations/get-select-options' +import { executeNetsuiteGetSubresourceOperation } from '@/lib/internal/netsuite/operations/get-subresource' import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' import { NetSuiteBlock } from '@/blocks/blocks/netsuite' import type { SubBlockConfig } from '@/blocks/types' @@ -44,7 +48,7 @@ import { import type { NetSuiteAuthParams } from '@/tools/netsuite/types' import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' import { tools } from '@/tools/registry' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { InternalToolConfig, ToolResponse } from '@/tools/types' const ORIGIN = 'https://1234567.suitetalk.api.netsuite.com' const AUTH: NetSuiteAuthParams = { @@ -53,15 +57,24 @@ const AUTH: NetSuiteAuthParams = { instanceUrl: ORIGIN, } -type ExecutableTool

= Pick, 'directExecution'> - function invoke

( - tool: ExecutableTool

, + tool: InternalToolConfig

, params: Omit ): () => Promise { - return () => { - if (!tool.directExecution) throw new Error('NetSuite tool is missing direct execution') - return tool.directExecution({ ...AUTH, ...params } as P) + return async () => { + const input = tool.operation.input({ ...AUTH, ...params } as P) + const response = await executeNetsuiteTool({ + toolId: tool.id, + input, + headers: new Headers(), + context: { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + requestId: 'request-1', + }) + return (await response.json()) as ToolResponse } } @@ -95,15 +108,15 @@ const NETSUITE_TOOLS = [ netsuiteGetGovernanceLimitsTool, ] as const -function importedNetSuiteTools(): ToolConfig[] { +function importedNetSuiteTools(): InternalToolConfig[] { return Object.values(netsuiteToolExports).filter( - (value): value is ToolConfig => + (value): value is InternalToolConfig => typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string' && value.id.startsWith('netsuite_') && - 'request' in value + 'operation' in value ) } @@ -1009,7 +1022,7 @@ describe('NetSuite operation contracts', () => { }) expect(mapped.expand).toBeUndefined() expect(mapped.expandSubResources).toBeUndefined() - const execute = netsuiteGetSelectOptionsTool.directExecution + const execute = executeNetsuiteGetSelectOptionsOperation if (!execute) throw new Error('NetSuite select-options tool is missing direct execution') const result = await execute(mapped as never) expect(result.success).toBe(true) @@ -1348,7 +1361,7 @@ describe('NetSuite operation contracts', () => { it('rejects dot path segments before authentication or SuiteTalk traffic', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - const execute = netsuiteGetSubresourceTool.directExecution + const execute = executeNetsuiteGetSubresourceOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ @@ -1367,7 +1380,7 @@ describe('NetSuite operation contracts', () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - const execute = netsuiteAttachRecordTool.directExecution + const execute = executeNetsuiteAttachRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ ...AUTH, diff --git a/apps/sim/tools/netsuite/transform_record.ts b/apps/sim/tools/netsuite/transform_record.ts index 2a36997457b..6dcac3b6675 100644 --- a/apps/sim/tools/netsuite/transform_record.ts +++ b/apps/sim/tools/netsuite/transform_record.ts @@ -1,12 +1,9 @@ import type { NetSuiteResponse, NetSuiteTransformRecordParams } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteTransformRecordTool: ToolConfig< +export const netsuiteTransformRecordTool: InternalToolConfig< NetSuiteTransformRecordParams, NetSuiteResponse > = { @@ -41,24 +38,9 @@ export const netsuiteTransformRecordTool: ToolConfig< description: 'Record fields matching the account-specific NetSuite metadata schema', }, }, - request: { url: () => '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'POST', - path: buildRecordPath( - { value: params.recordType, label: 'Source record type' }, - { value: params.recordId, label: 'Record ID' }, - { value: '!transform', label: 'Transform operation' }, - { value: params.targetRecordType, label: 'Target record type' } - ), - success: { status: 204, body: 'none' }, - responseLocation: 'resource-optional', - body: params.body ?? {}, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/update_record.ts b/apps/sim/tools/netsuite/update_record.ts index d6403fbee4c..e51cb0dc5b6 100644 --- a/apps/sim/tools/netsuite/update_record.ts +++ b/apps/sim/tools/netsuite/update_record.ts @@ -1,13 +1,12 @@ import type { NetSuiteResponse, NetSuiteUpdateRecordParams } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - optionalTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteUpdateRecordTool: ToolConfig = { +export const netsuiteUpdateRecordTool: InternalToolConfig< + NetSuiteUpdateRecordParams, + NetSuiteResponse +> = { id: 'netsuite_update_record', name: 'NetSuite Update Record', description: 'Update fields on an existing NetSuite record with PATCH.', @@ -39,23 +38,9 @@ export const netsuiteUpdateRecordTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'PATCH', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: params.recordId, label: 'Record ID' } - ), - success: { status: 204, body: 'none' }, - responseLocation: 'resource', - query: { replace: optionalTrim(params.replace, 'Replace sublists') }, - body: params.body, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/upsert_record.ts b/apps/sim/tools/netsuite/upsert_record.ts index 478eb2aa0ab..06807c7e6b7 100644 --- a/apps/sim/tools/netsuite/upsert_record.ts +++ b/apps/sim/tools/netsuite/upsert_record.ts @@ -1,13 +1,12 @@ import type { NetSuiteResponse, NetSuiteUpsertRecordParams } from '@/tools/netsuite/types' -import { - buildRecordPath, - executeNetSuiteRequest, - netsuiteAuthParamFields, - requiredTrim, -} from '@/tools/netsuite/utils' -import type { ToolConfig } from '@/tools/types' +import { netsuiteAuthParamFields } from '@/tools/netsuite/utils' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -export const netsuiteUpsertRecordTool: ToolConfig = { +export const netsuiteUpsertRecordTool: InternalToolConfig< + NetSuiteUpsertRecordParams, + NetSuiteResponse +> = { id: 'netsuite_upsert_record', name: 'NetSuite Upsert Record', description: 'Create or update a NetSuite record by external ID with PUT.', @@ -33,22 +32,9 @@ export const netsuiteUpsertRecordTool: ToolConfig '', method: 'POST', headers: () => ({}) }, - directExecution: (params, signal) => - executeNetSuiteRequest( - params, - () => ({ - method: 'PUT', - path: buildRecordPath( - { value: params.recordType, label: 'Record type' }, - { value: `eid:${requiredTrim(params.externalId, 'External ID')}`, label: 'External ID' } - ), - success: { status: 204, body: 'none' }, - responseLocation: 'resource-optional', - body: params.body, - }), - signal - ), + operation: { + input: createInternalToolOperationInput, + }, outputs: { status: { type: 'number', description: 'HTTP status returned by NetSuite' }, data: { diff --git a/apps/sim/tools/netsuite/utils.test.ts b/apps/sim/tools/netsuite/utils.test.ts index 52bbc314b75..dbfe47ae257 100644 --- a/apps/sim/tools/netsuite/utils.test.ts +++ b/apps/sim/tools/netsuite/utils.test.ts @@ -2,10 +2,10 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { netsuiteBatchCreateRecordsTool } from '@/tools/netsuite/batch_create_records' -import { netsuiteCreateRecordTool } from '@/tools/netsuite/create_record' -import { netsuiteGetRecordTool } from '@/tools/netsuite/get_record' -import { netsuiteGetServerTimeTool } from '@/tools/netsuite/get_server_time' +import { executeNetsuiteBatchCreateRecordsOperation } from '@/lib/internal/netsuite/operations/batch-create-records' +import { executeNetsuiteCreateRecordOperation } from '@/lib/internal/netsuite/operations/create-record' +import { executeNetsuiteGetRecordOperation } from '@/lib/internal/netsuite/operations/get-record' +import { executeNetsuiteGetServerTimeOperation } from '@/lib/internal/netsuite/operations/get-server-time' import type { NetSuiteAuthParams } from '@/tools/netsuite/types' import { buildBatchWriteRequest, @@ -50,7 +50,7 @@ function installFetch( } async function executeServerTime(auth: NetSuiteAuthParams = AUTH, signal?: AbortSignal) { - const execute = netsuiteGetServerTimeTool.directExecution + const execute = executeNetsuiteGetServerTimeOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') return execute(auth, signal) } @@ -96,7 +96,7 @@ describe('NetSuite shared executor', () => { headers: { Location: '/services/rest/record/v1/customer/647' }, }), ]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ @@ -217,7 +217,7 @@ describe('NetSuite shared executor', () => { 'https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/job/456' installFetch([new Response(null, { status: 204, headers: { Location: location } })]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ ...AUTH, @@ -240,7 +240,7 @@ describe('NetSuite shared executor', () => { headers: { Location: 'https://evil.example/services/rest/record/v1/customer/648' }, }), ]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const relative = await execute({ @@ -267,7 +267,7 @@ describe('NetSuite shared executor', () => { it('accepts the documented replacement-create 201 post-state with Location', async () => { const location = '/services/rest/record/v1/customer/647' installFetch([jsonResponse({ id: '647', companyName: 'Acme' }, 201, { Location: location })]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ @@ -304,7 +304,7 @@ describe('NetSuite shared executor', () => { it('rejects oversized request bodies before SuiteTalk traffic', async () => { const { calls } = installFetch() - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ @@ -330,7 +330,7 @@ describe('NetSuite shared executor', () => { return 'Acme' }, }) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const accessorResult = await execute({ @@ -358,7 +358,7 @@ describe('NetSuite shared executor', () => { const cyclic: Record = {} cyclic.self = cyclic const custom = { companyName: 'Acme', toJSON: () => ({ companyName: 'Other' }) } - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const cyclicResult = await execute({ ...AUTH, recordType: 'customer', body: cyclic }) @@ -387,7 +387,7 @@ describe('NetSuite shared executor', () => { left: shared, right: shared, } - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ ...AUTH, recordType: 'customer', body }) @@ -403,7 +403,7 @@ describe('NetSuite shared executor', () => { headers: { Location: '/services/rest/record/v1/customer/647' }, }), ]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const admittedBody = { values: Array.from({ length: 99_998 }, () => null) } const rejectedBody = { values: Array.from({ length: 99_999 }, () => null) } @@ -424,7 +424,7 @@ describe('NetSuite shared executor', () => { headers: { Location: '/services/rest/record/v1/customer/647' }, }), ]) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const nested = (depth: number): Record => { let value: Record = {} @@ -453,7 +453,7 @@ describe('NetSuite shared executor', () => { return 'too late' }, }) - const execute = netsuiteCreateRecordTool.directExecution + const execute = executeNetsuiteCreateRecordOperation if (!execute) throw new Error('NetSuite tool is missing direct execution') const result = await execute({ ...AUTH, recordType: 'customer', body }) @@ -570,7 +570,7 @@ describe('NetSuite shared executor', () => { ), ]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -609,7 +609,7 @@ describe('NetSuite shared executor', () => { ), ]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -635,7 +635,7 @@ describe('NetSuite shared executor', () => { const location = '/services/rest/async/v1/job/job-relative' installFetch([new Response(null, { status: 202, headers: { Location: location } })]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -667,7 +667,7 @@ describe('NetSuite shared executor', () => { ), ]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -692,7 +692,7 @@ describe('NetSuite shared executor', () => { }), ]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -710,7 +710,7 @@ describe('NetSuite shared executor', () => { it('rejects async batch responses that do not include a pollable job location', async () => { installFetch([new Response(null, { status: 202 })]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -729,7 +729,7 @@ describe('NetSuite shared executor', () => { const location = '/services/rest/async/v1/job/job-wrong-status' installFetch([new Response(null, { status: 204, headers: { Location: location } })]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -763,7 +763,7 @@ describe('NetSuite shared executor', () => { ), ]) - const execute = netsuiteBatchCreateRecordsTool.directExecution + const execute = executeNetsuiteBatchCreateRecordsOperation if (!execute) throw new Error('NetSuite batch tool is missing direct execution') const result = await execute({ ...AUTH, @@ -831,7 +831,7 @@ describe('NetSuite shared executor', () => { it('omits null optional booleans and rejects other direct boolean values', async () => { const valid = installFetch([jsonResponse({ id: '7' })]) - const execute = netsuiteGetRecordTool.directExecution + const execute = executeNetsuiteGetRecordOperation if (!execute) throw new Error('NetSuite get-record tool is missing direct execution') const omitted = await execute({ @@ -862,6 +862,10 @@ describe('NetSuite shared executor', () => { describe('NetSuite request bounds', () => { it("applies Sim's page-size default within Oracle's paging limits", () => { expect(normalizePagination()).toEqual({ limit: 100, offset: 0 }) + expect(normalizePagination('', '')).toEqual({ limit: 100, offset: 0 }) + expect(() => normalizePagination(' ', 0)).toThrow('Limit') + expect(() => normalizePagination(100, ' ')).toThrow('Offset') + expect(() => normalizePagination(0, 0)).toThrow('Limit') expect(normalizePagination(1_000, 2_000)).toEqual({ limit: 1_000, offset: 2_000 }) expect(normalizePagination(1_000, 99_000)).toEqual({ limit: 1_000, offset: 99_000 }) expect(normalizePagination(1, 999)).toEqual({ limit: 1, offset: 999 }) diff --git a/apps/sim/tools/netsuite/utils.ts b/apps/sim/tools/netsuite/utils.ts index 78df7109210..b215f1b38a6 100644 --- a/apps/sim/tools/netsuite/utils.ts +++ b/apps/sim/tools/netsuite/utils.ts @@ -113,15 +113,27 @@ export function buildSubresourcePath(value: string): Array<{ value: string; labe } export function normalizePagination( - requestedLimit?: number, - requestedOffset?: number + requestedLimit?: unknown, + requestedOffset?: unknown ): { limit: number; offset: number } { - const limit = requestedLimit ?? DEFAULT_PAGE_LIMIT - const offset = requestedOffset ?? 0 - if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT) { + const limitInput = requestedLimit === '' ? undefined : requestedLimit + const offsetInput = requestedOffset === '' ? undefined : requestedOffset + const limit = limitInput ?? DEFAULT_PAGE_LIMIT + const offset = offsetInput ?? 0 + if ( + typeof limit !== 'number' || + !Number.isInteger(limit) || + limit < 1 || + limit > MAX_PAGE_LIMIT + ) { throw new Error(`Limit must be an integer between 1 and ${MAX_PAGE_LIMIT}`) } - if (!Number.isInteger(offset) || offset < 0 || offset % limit !== 0) { + if ( + typeof offset !== 'number' || + !Number.isInteger(offset) || + offset < 0 || + offset % limit !== 0 + ) { throw new Error('Offset must be a non-negative integer divisible by limit') } if (offset + limit > MAX_RESULT_COUNT) { diff --git a/apps/sim/tools/okta/tools.test.ts b/apps/sim/tools/okta/tools.test.ts index 172417c7c29..0e20d51f42c 100644 --- a/apps/sim/tools/okta/tools.test.ts +++ b/apps/sim/tools/okta/tools.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { executeOktaUpdateGroupOperation } from '@/lib/internal/okta/operations/update-group' import { OktaBlock } from '@/blocks/blocks/okta' import { oktaActivateUserTool } from '@/tools/okta/activate_user' import { oktaAssignUserRoleTool } from '@/tools/okta/assign_user_role' @@ -123,7 +124,7 @@ describe('okta update_group profile merge', () => { ) ) - const result = await oktaUpdateGroupTool.directExecution!({ + const result = await executeOktaUpdateGroupOperation({ ...AUTH, groupId: '00g1', name: 'Engineering EMEA', @@ -429,17 +430,10 @@ describe('okta query-string flags are coerced rather than interpolated raw', () }) }) -describe('okta update_group declarative fallback', () => { - /** - * `PUT /api/v1/groups/{groupId}` replaces an extensible profile wholesale, so - * a body built without first reading the stored profile would erase the - * description on a rename plus every org-defined custom attribute. Unreachable - * today, but it must fail loudly rather than truncate silently. - */ - it('refuses to build a body instead of sending a truncated profile', () => { - expect(() => - oktaUpdateGroupTool.request.body!({ ...AUTH, groupId: '00g1', name: 'Engineering EMEA' }) - ).toThrow(/direct execution/i) +describe('okta update_group operation boundary', () => { + it('has no declarative HTTP fallback that could truncate the stored profile', () => { + expect(oktaUpdateGroupTool.operation).toBeDefined() + expect('request' in oktaUpdateGroupTool).toBe(false) }) }) diff --git a/apps/sim/tools/okta/update_group.ts b/apps/sim/tools/okta/update_group.ts index 35612634efd..d7af674e064 100644 --- a/apps/sim/tools/okta/update_group.ts +++ b/apps/sim/tools/okta/update_group.ts @@ -1,34 +1,11 @@ -import { createLogger } from '@sim/logger' -import { validateOktaDomain } from '@/lib/core/security/input-validation' -import type { OktaGroup, OktaUpdateGroupParams, OktaUpdateGroupResponse } from '@/tools/okta/types' -import { mergeOktaGroupProfile, oktaHeaders, throwOktaError } from '@/tools/okta/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { OktaUpdateGroupParams, OktaUpdateGroupResponse } from '@/tools/okta/types' +import { createInternalToolOperationInput } from '@/tools/operation-input' +import type { InternalToolConfig } from '@/tools/types' -const logger = createLogger('OktaUpdateGroup') - -/** Shared by the direct-execution and declarative paths so both emit one shape. */ -async function transformUpdateGroupResponse(response: Response): Promise { - if (!response.ok) { - await throwOktaError(response, logger, 'Failed to update group in Okta') - } - - const group: OktaGroup = await response.json() - return { - success: true, - output: { - id: group.id, - name: group.profile?.name ?? '', - description: group.profile?.description ?? null, - type: group.type, - created: group.created, - lastUpdated: group.lastUpdated, - lastMembershipUpdated: group.lastMembershipUpdated ?? null, - success: true, - }, - } -} - -export const oktaUpdateGroupTool: ToolConfig = { +export const oktaUpdateGroupTool: InternalToolConfig< + OktaUpdateGroupParams, + OktaUpdateGroupResponse +> = { id: 'okta_update_group', name: 'Update Group in Okta', description: @@ -78,53 +55,10 @@ export const oktaUpdateGroupTool: ToolConfig => { - const domain = validateOktaDomain(params.domain) - const url = `https://${domain}/api/v1/groups/${encodeURIComponent(params.groupId.trim())}` - const headers = oktaHeaders(params.apiKey) - - const readResponse = await fetch(url, { headers, signal }) - if (!readResponse.ok) { - await throwOktaError(readResponse, logger, 'Failed to load group for update in Okta') - } - const existing: OktaGroup = await readResponse.json() - - const writeResponse = await fetch(url, { - method: 'PUT', - headers, - body: JSON.stringify({ profile: mergeOktaGroupProfile(existing.profile, params) }), - signal, - }) - - return transformUpdateGroupResponse(writeResponse) - }, - - /** - * Unreachable fallback, kept only because `ToolConfig` requires a `request`. - * - * The executor always prefers `directExecution` for this tool. If that ever - * changed, this path could not read the stored profile first, so the `PUT` - * would replace an extensible profile with the two fields the caller - * supplied — erasing the stored description on a rename and every org-defined - * custom attribute. Failing loudly is the only safe behavior; silently - * truncating the profile is not. - */ - request: { - url: (params) => { - const domain = validateOktaDomain(params.domain) - return `https://${domain}/api/v1/groups/${encodeURIComponent(params.groupId.trim())}` - }, - method: 'PUT', - headers: (params) => oktaHeaders(params.apiKey), - body: () => { - throw new Error( - 'Okta update_group requires direct execution: replacing a group profile without reading it first would erase the stored description and every custom attribute' - ) - }, + operation: { + input: createInternalToolOperationInput, }, - transformResponse: (response: Response) => transformUpdateGroupResponse(response), - outputs: { id: { type: 'string', description: 'Group ID' }, name: { type: 'string', description: 'Group name' }, diff --git a/apps/sim/tools/operation-input.test.ts b/apps/sim/tools/operation-input.test.ts new file mode 100644 index 00000000000..e3d237ccdc1 --- /dev/null +++ b/apps/sim/tools/operation-input.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createInternalToolOperationInput } from '@/tools/operation-input' + +describe('createInternalToolOperationInput', () => { + it('keeps resolved tool values and removes executor context from semantic input', () => { + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + } + const params = { + prompt: 'resolved secret and ', + nested: { value: 42 }, + _context: context, + } + + expect(createInternalToolOperationInput(params)).toEqual({ + prompt: 'resolved secret and ', + nested: { value: 42 }, + }) + expect(params._context).toBe(context) + }) +}) diff --git a/apps/sim/tools/operation-input.ts b/apps/sim/tools/operation-input.ts new file mode 100644 index 00000000000..0d5bf4f65c3 --- /dev/null +++ b/apps/sim/tools/operation-input.ts @@ -0,0 +1,8 @@ +import { omit } from '@sim/utils/object' + +/** Removes executor-only scope before a tool's semantic input crosses the operation boundary. */ +export function createInternalToolOperationInput( + params: Params +): Omit { + return omit(params as Params & { _context?: unknown }, ['_context']) +} diff --git a/apps/sim/tools/direct-execution-model-input.test.ts b/apps/sim/tools/operation-model-input.test.ts similarity index 95% rename from apps/sim/tools/direct-execution-model-input.test.ts rename to apps/sim/tools/operation-model-input.test.ts index ae05d9b200d..8d6d2335dd5 100644 --- a/apps/sim/tools/direct-execution-model-input.test.ts +++ b/apps/sim/tools/operation-model-input.test.ts @@ -8,19 +8,19 @@ import { managedAgentRespondCustomToolTool } from '@/tools/managed_agent/respond import { managedAgentRespondToolConfirmationTool } from '@/tools/managed_agent/respond_tool_confirmation' import { managedAgentRunSessionTool } from '@/tools/managed_agent/run_session' import { managedAgentSendMessageTool } from '@/tools/managed_agent/send_message' -import type { ToolConfig } from '@/tools/types' +import type { InternalToolConfig } from '@/tools/types' function selectModelInput( - tool: ToolConfig, + tool: InternalToolConfig, params: Record ): Record { - const modelInput = tool.request.modelInput + const modelInput = tool.operation.modelInput expect(modelInput?.mode).toBe('project') if (modelInput?.mode !== 'project') throw new Error(`Expected ${tool.id} to project model input`) return modelInput.select(params) } -describe('direct-execution model-input selectors', () => { +describe('operation model-input selectors', () => { it('selects only Browser Use fields that are supplied to its agent model', () => { expect( selectModelInput(runTaskTool, { diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index 1f84b990f70..d4d893adf1f 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -252,7 +252,6 @@ export const pulseParserV2Tool: InternalToolConfig pulseParserTool.transformResponse!(response, params) diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts index 2115df9d217..a5e3ac98b94 100644 --- a/apps/sim/tools/reducto/parser.ts +++ b/apps/sim/tools/reducto/parser.ts @@ -179,7 +179,6 @@ export const reductoParserV2Tool: InternalToolConfig reductoParserTool.transformResponse!(response, params) diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index a60f3512293..77a7c9ceefe 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -10,9 +10,7 @@ vi.unmock('@/tools/registry') const requestTools = Object.entries(tools).filter( (entry): entry is [string, ToolConfig] => !isInternalToolConfig(entry[1]) ) -const dynamicRouteTools = requestTools.filter( - ([, tool]) => typeof tool.request.url === 'function' && !tool.directExecution -) +const dynamicRouteTools = requestTools.filter(([, tool]) => typeof tool.request.url === 'function') const PROBE_CONTEXT = { workflowId: 'workflow-probe', workspaceId: 'workspace-probe', diff --git a/apps/sim/tools/salesforce/update_custom_field.ts b/apps/sim/tools/salesforce/update_custom_field.ts index 29cee2b07ad..1b54996be4b 100644 --- a/apps/sim/tools/salesforce/update_custom_field.ts +++ b/apps/sim/tools/salesforce/update_custom_field.ts @@ -1,18 +1,10 @@ -import { createLogger } from '@sim/logger' +import { createInternalToolOperationInput } from '@/tools/operation-input' import type { SalesforceUpdateCustomFieldParams, SalesforceUpdateCustomFieldResponse, } from '@/tools/salesforce/types' import { CUSTOM_FIELD_UPDATE_OUTPUT_PROPERTIES } from '@/tools/salesforce/types' -import { - extractErrorMessage, - getInstanceUrl, - mergeCustomFieldMetadata, - requireId, -} from '@/tools/salesforce/utils' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -const logger = createLogger('SalesforceUpdateCustomField') +import type { InternalToolConfig } from '@/tools/types' /** * Update an existing custom field via the Tooling API. @@ -24,12 +16,13 @@ const logger = createLogger('SalesforceUpdateCustomField') * * The Tooling API PATCH replaces the field's entire `Metadata` compound, so a * naive partial PATCH would wipe any property the caller omits. To avoid that, - * this tool performs a read-modify-write in `directExecution`: it GETs the + * this tool performs a read-modify-write in one registered operation: it GETs the * field's current metadata, overlays only the provided changes, then PATCHes the * merged result. Unspecified properties (type, length, etc.) are preserved. * @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm */ -export const salesforceUpdateCustomFieldTool: ToolConfig< + +export const salesforceUpdateCustomFieldTool: InternalToolConfig< SalesforceUpdateCustomFieldParams, SalesforceUpdateCustomFieldResponse > = { @@ -133,96 +126,8 @@ export const salesforceUpdateCustomFieldTool: ToolConfig< * Read-modify-write so omitted properties are preserved rather than reset by * the Tooling API's full-metadata PATCH semantics. */ - directExecution: async (params): Promise => { - const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) - const fieldId = requireId(params.fieldId, 'Field ID') - const url = `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}` - const headers = { - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - } - - const readResponse = await fetch(url, { headers }) - const existing = await readResponse.json().catch(() => ({})) - if (!readResponse.ok) { - const errorMessage = extractErrorMessage( - existing, - readResponse.status, - 'Failed to load custom field for update' - ) - logger.error('Failed to read custom field metadata', { status: readResponse.status }) - throw new Error(errorMessage) - } - - const metadata = mergeCustomFieldMetadata(existing?.Metadata, params) - - const patchResponse = await fetch(url, { - method: 'PATCH', - headers, - body: JSON.stringify({ Metadata: metadata }), - }) - if (!patchResponse.ok) { - const errorData = await patchResponse.json().catch(() => ({})) - const errorMessage = extractErrorMessage( - errorData, - patchResponse.status, - 'Failed to update custom field in Salesforce' - ) - logger.error('Failed to update custom field', { status: patchResponse.status }) - throw new Error(errorMessage) - } - - return { - success: true, - output: { - id: fieldId, - updated: true, - }, - } - }, - - /** - * Declarative fallback. `directExecution` is the authoritative path and handles - * the read-modify-write; this is only used if direct execution is bypassed. - */ - request: { - url: (params) => { - const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) - const fieldId = requireId(params.fieldId, 'Field ID') - return `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}` - }, - method: 'PATCH', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - return { - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - } - }, - body: (params) => ({ Metadata: mergeCustomFieldMetadata(undefined, params) }), - }, - - transformResponse: async (response: Response, params) => { - if (!response.ok) { - const data = await response.json().catch(() => ({})) - const errorMessage = extractErrorMessage( - data, - response.status, - 'Failed to update custom field in Salesforce' - ) - logger.error('Failed to update custom field', { status: response.status }) - throw new Error(errorMessage) - } - - return { - success: true, - output: { - id: params?.fieldId?.trim() ?? '', - updated: true, - }, - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/slack/get_channel_history.ts b/apps/sim/tools/slack/get_channel_history.ts index f63dd0146c9..f262acc4c92 100644 --- a/apps/sim/tools/slack/get_channel_history.ts +++ b/apps/sim/tools/slack/get_channel_history.ts @@ -1,15 +1,15 @@ +import { createInternalToolOperationInput } from '@/tools/operation-input' import type { SlackGetChannelHistoryParams, SlackGetChannelHistoryResponse, } from '@/tools/slack/types' import { MESSAGE_OUTPUT_PROPERTIES } from '@/tools/slack/types' -import { fetchSlackMessagesPaginated, resolvePositiveInt } from '@/tools/slack/utils' -import type { ToolConfig } from '@/tools/types' +import type { InternalToolConfig } from '@/tools/types' /** Default cap on pages fetched per invocation. */ -const DEFAULT_MAX_PAGES = 10 +export const DEFAULT_MAX_PAGES = 10 -export const slackGetChannelHistoryTool: ToolConfig< +export const slackGetChannelHistoryTool: InternalToolConfig< SlackGetChannelHistoryParams, SlackGetChannelHistoryResponse > = { @@ -87,45 +87,8 @@ export const slackGetChannelHistoryTool: ToolConfig< }, }, - request: { - url: () => 'https://slack.com/api/conversations.history', - method: 'GET', - headers: (params: SlackGetChannelHistoryParams) => ({ - Authorization: `Bearer ${params.accessToken || params.botToken}`, - }), - }, - - directExecution: async (params: SlackGetChannelHistoryParams) => { - const token = params.accessToken || params.botToken - if (!token) { - throw new Error('Missing Slack credentials. Provide an OAuth connection or a bot token.') - } - - const result = await fetchSlackMessagesPaginated({ - token, - method: 'conversations.history', - baseParams: { - channel: params.channel, - oldest: params.oldest, - latest: params.latest, - inclusive: params.inclusive ? 'true' : undefined, - }, - limit: resolvePositiveInt(params.limit, 200), - cursor: params.cursor, - maxPages: resolvePositiveInt(params.maxPages, DEFAULT_MAX_PAGES), - missingScopeHint: 'channels:history, groups:history, im:history, mpim:history', - }) - - return { - success: true, - output: { - messages: result.messages, - count: result.messages.length, - hasMore: result.hasMore, - nextCursor: result.nextCursor, - pages: result.pages, - }, - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/slack/get_thread_replies.ts b/apps/sim/tools/slack/get_thread_replies.ts index 5744007b307..711b38503a3 100644 --- a/apps/sim/tools/slack/get_thread_replies.ts +++ b/apps/sim/tools/slack/get_thread_replies.ts @@ -1,15 +1,15 @@ +import { createInternalToolOperationInput } from '@/tools/operation-input' import type { SlackGetThreadRepliesParams, SlackGetThreadRepliesResponse, } from '@/tools/slack/types' import { MESSAGE_OUTPUT_PROPERTIES } from '@/tools/slack/types' -import { fetchSlackMessagesPaginated, resolvePositiveInt } from '@/tools/slack/utils' -import type { ToolConfig } from '@/tools/types' +import type { InternalToolConfig } from '@/tools/types' /** Default cap on pages fetched per invocation. */ -const DEFAULT_MAX_PAGES = 10 +export const DEFAULT_MAX_PAGES = 10 -export const slackGetThreadRepliesTool: ToolConfig< +export const slackGetThreadRepliesTool: InternalToolConfig< SlackGetThreadRepliesParams, SlackGetThreadRepliesResponse > = { @@ -93,53 +93,8 @@ export const slackGetThreadRepliesTool: ToolConfig< }, }, - request: { - url: () => 'https://slack.com/api/conversations.replies', - method: 'GET', - headers: (params: SlackGetThreadRepliesParams) => ({ - Authorization: `Bearer ${params.accessToken || params.botToken}`, - }), - }, - - directExecution: async (params: SlackGetThreadRepliesParams) => { - const token = params.accessToken || params.botToken - if (!token) { - throw new Error('Missing Slack credentials. Provide an OAuth connection or a bot token.') - } - - const result = await fetchSlackMessagesPaginated({ - token, - method: 'conversations.replies', - baseParams: { - channel: params.channel, - ts: params.threadTs, - oldest: params.oldest, - latest: params.latest, - inclusive: params.inclusive ? 'true' : undefined, - }, - limit: resolvePositiveInt(params.limit, 200), - cursor: params.cursor, - maxPages: resolvePositiveInt(params.maxPages, DEFAULT_MAX_PAGES), - missingScopeHint: 'channels:history, groups:history, im:history, mpim:history', - }) - - const messages = result.messages - const threadTs = params.threadTs?.trim() - const parentMessage = messages.find((msg) => msg.ts === threadTs) ?? null - const replies = parentMessage ? messages.filter((msg) => msg !== parentMessage) : messages - - return { - success: true, - output: { - parentMessage, - replies, - messages, - replyCount: replies.length, - hasMore: result.hasMore, - nextCursor: result.nextCursor, - pages: result.pages, - }, - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/slack/utils.ts b/apps/sim/tools/slack/utils.ts index 91707156fc6..c1fb9f97bf1 100644 --- a/apps/sim/tools/slack/utils.ts +++ b/apps/sim/tools/slack/utils.ts @@ -1,4 +1,4 @@ -import { sleep } from '@sim/utils/helpers' +import { interruptibleSleep } from '@sim/utils/helpers' import { parseRetryAfter } from '@sim/utils/retry' import type { SlackCanvasFile } from '@/tools/slack/types' @@ -103,6 +103,8 @@ export interface SlackPaginateOptions { maxPages: number /** Human-readable scope hint surfaced on `missing_scope`. */ missingScopeHint: string + /** Cancels provider requests and rate-limit waits. */ + signal?: AbortSignal } export interface SlackPaginateResult { @@ -144,18 +146,21 @@ export async function fetchSlackMessagesPaginated( response = await fetch(url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}` }, + signal: opts.signal, }) if (response.status === 429 && attempt < SLACK_RATE_LIMIT_MAX_RETRIES) { attempt += 1 const retryAfter = parseRetryAfter(response.headers.get('retry-after')) ?? 1000 - await sleep(retryAfter) + await interruptibleSleep(retryAfter, opts.signal) + opts.signal?.throwIfAborted() continue } break } const data = await response.json() + opts.signal?.throwIfAborted() if (!data.ok) { if (data.error === 'missing_scope') { diff --git a/apps/sim/tools/supabase/storage_get_public_url.ts b/apps/sim/tools/supabase/storage_get_public_url.ts index b189f9bd543..c80ecaea099 100644 --- a/apps/sim/tools/supabase/storage_get_public_url.ts +++ b/apps/sim/tools/supabase/storage_get_public_url.ts @@ -1,11 +1,11 @@ +import { createInternalToolOperationInput } from '@/tools/operation-input' import type { SupabaseStorageGetPublicUrlParams, SupabaseStorageGetPublicUrlResponse, } from '@/tools/supabase/types' -import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' -import type { ToolConfig } from '@/tools/types' +import type { InternalToolConfig } from '@/tools/types' -export const storageGetPublicUrlTool: ToolConfig< +export const storageGetPublicUrlTool: InternalToolConfig< SupabaseStorageGetPublicUrlParams, SupabaseStorageGetPublicUrlResponse > = { @@ -41,43 +41,8 @@ export const storageGetPublicUrlTool: ToolConfig< }, }, - /** - * Public URLs are deterministic and built entirely from the project ID, - * bucket, and path — no network request is required. `directExecution` - * short-circuits the HTTP request so we never hit the API just to discard - * its response. - */ - directExecution: async (params: SupabaseStorageGetPublicUrlParams) => { - const bucket = encodeStorageSegment(params.bucket) - const path = encodeStoragePath(params.path) - let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` - - if (params.download) { - // Supabase's `download` query param is a filename override, not a - // boolean flag — an empty value forces a download while preserving - // the original filename. Sending the literal string "true" would - // instead rename the downloaded file to "true". - publicUrl += '?download=' - } - - return { - success: true, - output: { - message: 'Successfully generated public URL', - publicUrl, - }, - error: undefined, - } - }, - - request: { - url: (params) => { - const bucket = encodeStorageSegment(params.bucket) - const path = encodeStoragePath(params.path) - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` - }, - method: 'GET', - headers: () => ({}), + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/supabase/storage_update_bucket.ts b/apps/sim/tools/supabase/storage_update_bucket.ts index 005c835efd3..da9fe6d2753 100644 --- a/apps/sim/tools/supabase/storage_update_bucket.ts +++ b/apps/sim/tools/supabase/storage_update_bucket.ts @@ -1,13 +1,12 @@ -import { getErrorMessage } from '@sim/utils/errors' +import { createInternalToolOperationInput } from '@/tools/operation-input' import { STORAGE_MESSAGE_OUTPUT_PROPERTIES, type SupabaseStorageUpdateBucketParams, type SupabaseStorageUpdateBucketResponse, } from '@/tools/supabase/types' -import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' -import type { ToolConfig } from '@/tools/types' +import type { InternalToolConfig } from '@/tools/types' -export const storageUpdateBucketTool: ToolConfig< +export const storageUpdateBucketTool: InternalToolConfig< SupabaseStorageUpdateBucketParams, SupabaseStorageUpdateBucketResponse > = { @@ -57,106 +56,8 @@ export const storageUpdateBucketTool: ToolConfig< }, }, - /** - * Unreachable: `directExecution` below always handles this tool because - * the update must first read the bucket's current configuration (the - * Storage API's update-bucket endpoint is a full-replace PUT, not a - * partial patch). Declared only to satisfy `ToolConfig`'s required - * `request` field. - */ - request: { - url: (params) => - `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`, - method: 'PUT', - headers: (params) => ({ - apikey: params.apiKey, - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), - }, - - /** - * The Storage API's update-bucket endpoint is a full-replace PUT - * (`{id, name, public, file_size_limit?, allowed_mime_types?}`), not a - * partial patch. Fetching the bucket's current configuration first lets - * unset params fall back to their existing value instead of silently - * resetting to a default (e.g. flipping a public bucket private just - * because `isPublic` wasn't provided). - */ - directExecution: async ( - params: SupabaseStorageUpdateBucketParams - ): Promise => { - const baseUrl = supabaseBaseUrl(params.projectId) - const bucket = encodeStorageSegment(params.bucket) - const headers = { - apikey: params.apiKey, - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - } - - try { - const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { - method: 'GET', - headers, - }) - - if (!currentResponse.ok) { - const errorText = await currentResponse.text() - throw new Error(`Failed to read current bucket configuration: ${errorText}`) - } - - const current = await currentResponse.json() - - // Block subBlocks for a shared field can forward an empty string - // (e.g. an untouched short-input) rather than omitting the key - // entirely — treat that the same as "not provided" so it falls - // back to the bucket's current value instead of coercing to 0/false. - const hasValue = (value: unknown): boolean => - value !== undefined && value !== null && value !== '' - - const payload: any = { - id: params.bucket, - name: params.bucket, - public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public), - file_size_limit: hasValue(params.fileSizeLimit) - ? Number(params.fileSizeLimit) - : (current.file_size_limit ?? null), - allowed_mime_types: hasValue(params.allowedMimeTypes) - ? params.allowedMimeTypes - : (current.allowed_mime_types ?? null), - } - - const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { - method: 'PUT', - headers, - body: JSON.stringify(payload), - }) - - if (!updateResponse.ok) { - const errorText = await updateResponse.text() - throw new Error(`Failed to update bucket: ${errorText}`) - } - - const data = await updateResponse.json() - - return { - success: true, - output: { - message: 'Successfully updated storage bucket', - results: data, - }, - error: undefined, - } - } catch (error) { - return { - success: false, - output: { - message: 'Failed to update storage bucket', - results: {}, - }, - error: getErrorMessage(error, 'Unknown error occurred'), - } - } + operation: { + input: createInternalToolOperationInput, }, outputs: { diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 151c4baedff..afcd582eb31 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -262,14 +262,6 @@ export interface ToolConfig

{ // Response handling transformResponse?: (response: Response, params?: P) => Promise - /** - * Direct execution function for tools that don't need HTTP requests. - * If provided, this will be called instead of making an HTTP request. - * Receives the workflow execution's abort signal (when one is active) so - * long-running direct executions can propagate cancellation. - */ - directExecution?: (params: P, signal?: AbortSignal) => Promise - /** * Optional dynamic schema enrichment for specific params. * Maps param IDs to their enrichment configuration. @@ -466,10 +458,7 @@ export interface InternalToolOperationConfig

{ } /** Tool metadata shared by network-backed and in-process tools. */ -export type ToolDefinition

= Omit< - ToolConfig, - 'request' | 'directExecution' | 'operation' -> +export type ToolDefinition

= Omit, 'request' | 'operation'> /** * In-process tool definition. Internal operations deliberately have no URL, HTTP method, or @@ -478,7 +467,6 @@ export type ToolDefinition

= Omit< export type InternalToolConfig

= ToolDefinition & { operation: InternalToolOperationConfig

request?: never - directExecution?: never } export type ExecutableToolConfig

= ToolConfig | InternalToolConfig diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index cd4e0371e60..34c38e86d06 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -83,16 +83,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3000, + "modules": 2882, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 318, + "apps/sim/lib/auth/index.ts": 238, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/error.tsx": { @@ -173,16 +173,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3000, + "modules": 2882, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 318, + "apps/sim/lib/auth/index.ts": 238, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { @@ -542,16 +542,16 @@ } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2225, + "modules": 1803, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 574, - "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/auth/index.ts": 344, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 322, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 277, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 273, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 241 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1659, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 332, + "apps/sim/blocks/registry.ts": 319, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 249, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 238 } }, "app/workspace/[workspaceId]/tables/error.tsx": { @@ -595,42 +595,38 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2195, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2194, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 556, - "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 187, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 144, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, + "apps/sim/hooks/queries/copilot-feedback.ts": 70 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2226, + "modules": 2053, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2225, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2052, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 231, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 181, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 179 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 344, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 307, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 245, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 144 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2195, + "modules": 2035, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 981, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 818, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 542, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 168, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 146 + "apps/sim/blocks/registry.ts": 338, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 310, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 153, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 146, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 140 } }, "app/workspace/layout.tsx": { diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index 95fc53c8a51..544dca12c82 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -4,7 +4,7 @@ * entry's module graph grows past its recorded baseline. * * `@/tools/registry` is a barrel over 4,300+ tools whose `ToolConfig`s hold - * closures (`request.headers`, `transformResponse`, `directExecution`). Those + * closures (`request.headers`, `transformResponse`). Those * closures reach every integration's SDK client and parser, so reaching the * barrel costs ~4,700 modules — it was 71-82% of every workspace route's module * graph until those edges were cut. diff --git a/scripts/check-tool-request-boundary.test.ts b/scripts/check-tool-request-boundary.test.ts index 36a2000e862..5291d3154f7 100644 --- a/scripts/check-tool-request-boundary.test.ts +++ b/scripts/check-tool-request-boundary.test.ts @@ -15,6 +15,42 @@ function auditRequest(request: string) { } describe('tool self-hop audit', () => { + it('rejects the retired direct execution property', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + directExecution: async () => ({ success: true, output: {} }), + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ reason: 'retired-direct-execution' }), + ]) + }) + + it('rejects the retired direct execution method signature', () => { + const audit = auditToolSelfHops(` + interface LegacyTool { + directExecution(params: unknown): Promise + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ reason: 'retired-direct-execution' }), + ]) + }) + + it('allows ordinary operation implementations', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + operation: { input: (params) => params }, + } + `) + + expect(audit.violations).toEqual([]) + }) + it('allows an absolute external provider URL', () => { const audit = auditRequest( "url: 'https://api.example.com/v1/items', method: 'GET', headers: () => ({})" diff --git a/scripts/check-tool-request-boundary.ts b/scripts/check-tool-request-boundary.ts index 1faaaa9b373..dc2f7d2ce1f 100644 --- a/scripts/check-tool-request-boundary.ts +++ b/scripts/check-tool-request-boundary.ts @@ -44,6 +44,7 @@ export interface ToolSelfHopViolation { reason: | 'same-origin-tool-request' | 'legacy-internal-policy' + | 'retired-direct-execution' | 'unresolved-request-policy' | 'unapproved-same-origin-policy' } @@ -1601,8 +1602,25 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH let detectedSelfHops = 0 let legacyInternalPolicies = 0 const resolver = createSelfHopResolver(program, file) + const retiredDirectExecutionLocations = new Set() const visit = (node: SyntaxNode) => { + if ( + ['ObjectProperty', 'ObjectMethod', 'TSPropertySignature', 'TSMethodSignature'].includes( + node.type + ) && + getStaticPropertyName(node) === 'directExecution' + ) { + const location = node.start ?? node.loc?.start.line ?? -1 + if (!retiredDirectExecutionLocations.has(location)) { + retiredDirectExecutionLocations.add(location) + violations.push({ + file, + line: node.loc?.start.line ?? 1, + reason: 'retired-direct-execution', + }) + } + } if (node.type === 'ObjectExpression') { const idProperty = getObjectProperty(node, 'id') const toolId = idProperty ? getToolId(node, resolver) : undefined @@ -1921,7 +1939,9 @@ function main(): void { ? 'replace the /api self-hop with InternalToolConfig.operation and a registered server handler' : violation.reason === 'legacy-internal-policy' ? 'request.internal is obsolete; use InternalToolConfig.operation for in-process work' - : 'request configuration could not be audited; keep it in a statically resolvable local helper' + : violation.reason === 'retired-direct-execution' + ? 'directExecution is retired; use InternalToolConfig.operation and a registered server handler' + : 'request configuration could not be audited; keep it in a statically resolvable local helper' console.error( ` ${relative(ROOT, violation.file)}:${violation.line} ${violation.toolId ?? 'unknown tool'}: ${description}` ) diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index a6ef7b90815..af9c8e15c0e 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -161,7 +161,7 @@ describe('documentation input parameter parsing', () => { }) }) - it('stops at legacy request metadata after a comment', () => { + it('stops at operation metadata after a comment', () => { const tool = extractToolInfo( 'example_send', ` @@ -175,16 +175,13 @@ describe('documentation input parameter parsing', () => { description: 'The message', }, }, - // Direct execution short-circuits this legacy request descriptor. - request: { - url: () => '', - method: 'POST', + operation: { + input: (params) => params, modelInput: { mode: 'project', select: (params) => ({ message: params.message }), }, }, - directExecution: async () => ({ success: true }), outputs: {}, } ` @@ -718,7 +715,7 @@ describe('a source the scanner cannot get through is reported, not swallowed', ( it('still reports null with no parseError for a spread-only subBlocks array', () => { const supplied = extractBlockSuppliedParamIds( - "subBlocks: [...NotionBlock.subBlocks], tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },", + 'subBlocks: [...NotionBlock.subBlocks], tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },', 'SpreadBlock' ) diff --git a/scripts/sync-tool-metadata.ts b/scripts/sync-tool-metadata.ts index b15d331c7b6..1f7a751bd35 100644 --- a/scripts/sync-tool-metadata.ts +++ b/scripts/sync-tool-metadata.ts @@ -4,7 +4,7 @@ * * `apps/sim/tools/registry.ts` is a ~9,000-line barrel importing all 4,300+ * tools. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with - * closures (`request.headers`, `transformResponse`, `directExecution`, + * closures (`request.headers`, `transformResponse`, * `postProcess`), and it is those closures — and the SDK clients and API * helpers they reach — that make the barrel cost ~4,700 modules to compile. * @@ -52,7 +52,7 @@ const OUTPUTS_PATH = resolve(GENERATED_DIR, 'tool-outputs.ts') /** * Fields copied into `tool-metadata.ts`. Every one must be plain data. * - * Deliberately excluded: `request`, `transformResponse`, `directExecution`, + * Deliberately excluded: `request`, `transformResponse`, * `postProcess` (closures, and the whole reason the registry is expensive); * `hosting` and `schemaEnrichment` (contain predicates/`enrichSchema`, and are * only consumed server-side); `outputs` (emitted separately).