improvement(tools): retire direct execution (#7207)

* improvement(tools): retire direct execution

* fix(tools): update operation model input test

* fix(browser-use): validate operation payloads

* fix(tools): address provider operation review

* fix(tools): harden provider operation contracts

* fix(tools): close operation lifecycle gaps

* fix(tools): update supabase buckets atomically
This commit is contained in:
Vikhyath Mondreti
2026-08-28 10:49:31 -07:00
committed by GitHub
parent cb28b11759
commit 9f594c9116
238 changed files with 6890 additions and 4473 deletions
+4
View File
@@ -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
+3 -3
View File
@@ -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`
+2 -1
View File
@@ -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`
+2 -1
View File
@@ -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
@@ -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.
+3 -2
View File
@@ -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
@@ -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'
@@ -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
)
}
}
}
@@ -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,
@@ -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 }
)
}
}
@@ -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,
},
}
}
@@ -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) }
}
@@ -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)
}
@@ -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),
}
}
@@ -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'
@@ -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 }
)
}
}
@@ -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<typeof fetch>()
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) })
)
})
})
@@ -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<BrowserUseTaskStep> = 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<string, unknown>
metadata?: Record<string, string>
}
interface BrowserUseFetchOptions {
method?: 'GET' | 'POST' | 'PATCH'
body?: unknown
signal?: AbortSignal
}
async function fetchBrowserUse(
path: string,
apiKey: string,
options: BrowserUseFetchOptions = {}
): Promise<Response> {
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<void> {
if (!signal) {
await sleep(POLL_INTERVAL_MS)
return
}
signal.throwIfAborted()
let abortHandler: (() => void) | undefined
const aborted = new Promise<never>((_, 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<void> {
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<string, unknown> {
const secrets: Record<string, unknown> = {}
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<string, unknown>)
: 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<typeof taskStatusResponseSchema> } | { 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<PollResult> {
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<string | null> {
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<BrowserUseRunTaskResponse> => {
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<string>()
if (profileSessionId) sessionsToStop.add(profileSessionId)
if (!taskEnded && taskSessionId) sessionsToStop.add(taskSessionId)
for (const sessionId of sessionsToStop) {
await stopSession(sessionId, params.apiKey)
}
}
}
@@ -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 }
)
}
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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'
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
}
@@ -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
)
}
@@ -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<string, unknown> = {
...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
)
}
@@ -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 }
)
}
}
@@ -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<CloudflareRawZoneSetting>
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 } }
}
@@ -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 }
)
}
}
@@ -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: '' } },
}
}
@@ -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) => {
+30 -19
View File
@@ -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)
+261 -1
View File
@@ -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<string, string> {
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<string, unknown>, key: string): string | undefined {
const value = record[key]
return typeof value === 'string' ? value : undefined
}
function readNumber(record: Record<string, unknown>, 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<void> {
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<GitHubCommentPayload> {
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<unknown>(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<unknown>(response, {
maxBytes: MAX_COMMIT_RESPONSE_BYTES,
label: 'GitHub comment response',
signal,
})
)
}
export async function executeGitHubCommentOperation(
params: CreateCommentParams,
signal?: AbortSignal
): Promise<ToolResponse> {
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<ToolResponse> {
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,
@@ -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'))
@@ -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 })
@@ -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<typeof googleDriveUploadInputSchema>
export type GoogleDriveDownloadInput = z.output<typeof googleDriveDownloadInputSchema>
export type GoogleDriveExportInput = z.output<typeof googleDriveExportInputSchema>
export type GoogleDriveMoveInput = z.output<typeof googleDriveMoveInputSchema>
@@ -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,
@@ -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 }
)
}
}
@@ -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<ManagedAgentArchiveSessionResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentCreateSessionResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentDeleteSessionResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentGetSessionResponse> => {
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'),
}
}
}
@@ -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'
@@ -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<ManagedAgentInterruptSessionResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentListEventsResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentCustomToolResultResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentToolConfirmationResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentRunSessionResponse> => {
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 } : {}),
},
}
}
@@ -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<ManagedAgentSendMessageResponse> => {
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'),
}
}
}
@@ -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<ManagedAgentUpdateSessionResponse> => {
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'),
}
}
}
@@ -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 }
)
}
}
@@ -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'
)
})
})
@@ -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<MicrosoftAdAddUserAppRoleAssignmentResponse> => {
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) } }
}
@@ -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 }
)
}
}
@@ -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
)
@@ -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)
@@ -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
)
@@ -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
)
@@ -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)
@@ -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)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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'
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)

Some files were not shown because too many files have changed in this diff Show More