Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 352 additions & 9 deletions apps/docs/openapi-v2-workflows.json

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { MAX_ID_LENGTH } from '@/lib/api/contracts/primitives'
import {
MAX_AGENT_TOOLS_PER_BLOCK,
v2AgentToolInputSchema,
v2ApplyWorkflowOperationsBodySchema,
} from '@/lib/api/contracts/v2/workflows'
import { MAX_MCP_TOOL_NAME_BYTES } from '@/lib/mcp/constants'

describe('v2AgentToolInputSchema', () => {
it('accepts catalog integration, custom-tool reference, and MCP tool shapes', () => {
const tools = [
{
type: 'cloudwatch',
operation: 'describe_alarm_history',
usageControl: 'auto',
params: { region: 'us-east-1' },
},
{
type: 'custom-tool',
customToolId: 'cst_123',
usageControl: 'force',
},
{
type: 'mcp',
params: { serverId: 'mcp_123', toolName: 'search_docs', collection: 'incidents' },
usageControl: 'none',
},
]

expect(v2AgentToolInputSchema.parse(tools)).toEqual(tools)
})

it('keeps the legacy inline custom-tool shape available for workflow round trips', () => {
const tools = [
{
type: 'custom-tool',
schema: {
type: 'function',
function: {
name: 'lookup_incident',
description: 'Look up an incident.',
parameters: { type: 'object', properties: { id: { type: 'string' } } },
},
},
code: 'return params.id',
},
]

expect(v2AgentToolInputSchema.parse(tools)).toEqual(tools)
})

it.each([
[{ type: 'custom-tool', usageControl: 'auto' }],
[{ type: 'mcp', params: { serverId: 'mcp_123' }, usageControl: 'auto' }],
[{ type: 'slack', operation: 'send', usageControl: 'sometimes' }],
])('rejects a malformed reserved tool shape', (tools) => {
expect(v2AgentToolInputSchema.safeParse(tools).success).toBe(false)
})

it('rejects a tool list above the workflow-operation ceiling', () => {
const tools = Array.from({ length: MAX_AGENT_TOOLS_PER_BLOCK + 1 }, (_, index) => ({
type: `integration-${index}`,
}))

expect(v2AgentToolInputSchema.safeParse(tools).success).toBe(false)
})

it.each([
[
'inline function name',
{
type: 'custom-tool',
schema: {
type: 'function',
function: { name: 'a'.repeat(65), parameters: { type: 'object' } },
},
code: 'return null',
},
],
[
'MCP server id',
{
type: 'mcp',
params: { serverId: 'a'.repeat(MAX_ID_LENGTH + 1), toolName: 'search_docs' },
},
],
[
'MCP tool name',
{
type: 'mcp',
params: {
serverId: 'mcp_123',
toolName: 'a'.repeat(MAX_MCP_TOOL_NAME_BYTES + 1),
},
},
],
[
'MCP multibyte tool name',
{
type: 'mcp',
params: {
serverId: 'mcp_123',
toolName: '💡'.repeat(Math.floor(MAX_MCP_TOOL_NAME_BYTES / 4) + 1),
},
},
],
])('rejects an overlong %s', (_label, tool) => {
expect(v2AgentToolInputSchema.safeParse([tool]).success).toBe(false)
})
})

describe('workflow operation Agent tools contract', () => {
it('publishes and validates tools under params.inputs without closing other catalog inputs', () => {
const body = {
operations: [
{
operation_type: 'add',
block_id: 'triage',
params: {
type: 'agent',
name: 'Triage',
inputs: {
model: 'gpt-5',
tools: [
{
type: 'cloudwatch',
operation: 'describe_alarms',
params: { region: 'us-west-2' },
usageControl: 'auto',
futureMetadata: { preserved: true },
},
],
},
futureOperationSetting: true,
},
},
],
}

expect(v2ApplyWorkflowOperationsBodySchema.parse(body)).toEqual({
...body,
atomic: false,
layout: 'targeted',
})
})

it('rejects malformed Agent tools before the edit engine runs', () => {
const parsed = v2ApplyWorkflowOperationsBodySchema.safeParse({
operations: [
{
operation_type: 'add',
block_id: 'triage',
params: {
type: 'agent',
name: 'Triage',
inputs: { tools: [{ type: 'mcp', params: { serverId: 'mcp_123' } }] },
},
},
],
})

expect(parsed.success).toBe(false)
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/openapi/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ const declaredRoutes = [
workflowOperation({
operationId: 'applyWorkflowOperations',
summary: 'Apply Workflow Operations',
description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`,
description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — but \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are necessarily absent.`,
errors: RESOURCE_MUTATION_ERRORS,
success: jsonSuccess('The batch was applied.'),
}),
Expand Down
Loading
Loading