feat(api): publish agent tool input schema (#7109)

* feat(api): publish agent tool input schema

* fix(api): bound agent tool inputs
This commit is contained in:
Theodore Li
2026-08-26 14:37:50 -04:00
committed by GitHub
parent d1f50ecf61
commit d86fdc152f
6 changed files with 905 additions and 15 deletions
+352 -9
View File
@@ -437,7 +437,7 @@
"post": {
"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. A workspace API key is rejected with `403`; use a personal API key.\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. A workspace API key is rejected with `403`; use a personal API key.\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.",
"tags": ["Workflows"],
"parameters": [
{
@@ -5972,6 +5972,32 @@
"type": "string",
"minLength": 1,
"description": "Block display name."
},
"inputs": {
"allOf": [
{
"type": "object",
"properties": {
"tools": {
"description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.",
"$ref": "#/components/schemas/AgentToolInput"
}
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
}
],
"description": "Block configuration keyed by sub-block id."
}
},
"required": ["type", "name"],
@@ -5998,13 +6024,51 @@
"description": "Block the operation targets. For `add`, the id the new block will be given."
},
"params": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One operation parameter; see the description for the accepted keys."
},
"allOf": [
{
"type": "object",
"properties": {
"inputs": {
"allOf": [
{
"type": "object",
"properties": {
"tools": {
"description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.",
"$ref": "#/components/schemas/AgentToolInput"
}
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
}
],
"description": "Block configuration keyed by sub-block id."
}
},
"additionalProperties": {
"description": "One operation parameter; see the description for the accepted keys."
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One operation parameter; see the description for the accepted keys."
}
}
],
"description": "Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle. Re-sending `connections` replaces that block's outgoing edges, so use `removeEdges` — `[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop one edge without restating the rest."
}
},
@@ -6058,6 +6122,32 @@
"type": "string",
"minLength": 1,
"description": "Block display name."
},
"inputs": {
"allOf": [
{
"type": "object",
"properties": {
"tools": {
"description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.",
"$ref": "#/components/schemas/AgentToolInput"
}
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One block-specific input whose accepted shape is published by the block catalog."
}
}
],
"description": "Block configuration keyed by sub-block id."
}
},
"required": ["subflowId", "type", "name"],
@@ -6106,6 +6196,249 @@
"title": "Workflow edit operation",
"description": "One semantic edit against a workflow graph."
},
"AgentToolInput": {
"maxItems": 100,
"type": "array",
"items": {
"$ref": "#/components/schemas/AgentTool"
},
"description": "The complete value stored in an Agent block’s `tools` input.",
"title": "Agent tools input"
},
"AgentTool": {
"oneOf": [
{
"$ref": "#/components/schemas/AgentIntegrationTool"
},
{
"$ref": "#/components/schemas/AgentCustomTool"
},
{
"$ref": "#/components/schemas/AgentMcpTool"
}
],
"title": "Agent tool",
"description": "A catalog integration operation, workspace custom tool, or MCP tool available to an Agent."
},
"AgentIntegrationTool": {
"type": "object",
"properties": {
"type": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^(?!(?:custom-tool|mcp)$).+$",
"description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id."
},
"operation": {
"description": "Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"usageControl": {
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
},
"params": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One tool parameter value."
},
"description": "Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them."
}
},
"required": ["type"],
"additionalProperties": {
"description": "Forward-compatible integration tool metadata preserved by the workflow editor."
},
"title": "Agent integration tool",
"description": "A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.",
"examples": [
{
"type": "cloudwatch",
"operation": "describe_alarm_history",
"usageControl": "auto",
"params": {}
}
]
},
"AgentCustomTool": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "custom-tool",
"description": "Custom-tool discriminator."
},
"customToolId": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"description": "Custom tool id returned by `GET /api/v2/custom-tools`."
},
"usageControl": {
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
}
},
"required": ["type", "customToolId"],
"additionalProperties": {
"description": "Forward-compatible custom tool metadata preserved by the workflow editor."
}
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "custom-tool",
"description": "Custom-tool discriminator."
},
"schema": {
"type": "object",
"properties": {
"type": {
"description": "Function declaration discriminator.",
"type": "string",
"const": "function"
},
"function": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Function name presented to the model."
},
"description": {
"description": "What the inline custom tool does.",
"type": "string"
},
"parameters": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One JSON Schema keyword on the function parameters."
},
"description": "JSON Schema describing the function arguments."
}
},
"required": ["name", "parameters"],
"additionalProperties": {
"description": "Additional function declaration metadata."
},
"description": "OpenAI-style function definition."
}
},
"required": ["function"],
"additionalProperties": {
"description": "Additional custom tool declaration metadata."
},
"description": "Inline OpenAI-style function declaration."
},
"code": {
"type": "string",
"description": "Inline tool implementation executed by the Function runtime."
},
"usageControl": {
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
}
},
"required": ["type", "schema", "code"],
"additionalProperties": {
"description": "Forward-compatible custom tool metadata preserved by the workflow editor."
}
}
],
"title": "Agent custom tool",
"description": "A workspace custom tool. Reference `customToolId` is the preferred shape; the inline declaration is retained for legacy workflow round trips.",
"examples": [
{
"type": "custom-tool",
"customToolId": "cst_01J9X2ABCDEF",
"usageControl": "auto"
}
]
},
"AgentMcpTool": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp",
"description": "MCP-tool discriminator."
},
"params": {
"allOf": [
{
"type": "object",
"properties": {
"serverId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "MCP server id returned by `GET /api/v2/mcp-servers`."
},
"toolName": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Tool name returned by the MCP server’s tools endpoint."
}
},
"required": ["serverId", "toolName"],
"additionalProperties": {
"description": "One parameter fixed by the workflow author."
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"description": "One parameter fixed by the workflow author."
}
}
],
"description": "MCP server and tool identity plus any tool arguments fixed by the workflow author."
},
"usageControl": {
"type": "string",
"enum": ["auto", "force", "none"],
"description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`."
}
},
"required": ["type", "params"],
"additionalProperties": {
"description": "Forward-compatible MCP tool metadata preserved by the workflow editor."
},
"title": "Agent MCP tool",
"description": "One tool discovered from a workspace MCP server.",
"examples": [
{
"type": "mcp",
"params": {
"serverId": "mcp_01J9X2ABCDEF",
"toolName": "search_docs"
},
"usageControl": "auto"
}
]
},
"ApplyWorkflowOperationsRequest": {
"type": "object",
"properties": {
@@ -6163,7 +6496,17 @@
"block_id": "agent-1",
"params": {
"type": "agent",
"name": "Triage"
"name": "Triage",
"inputs": {
"tools": [
{
"type": "cloudwatch",
"operation": "describe_alarm_history",
"usageControl": "auto",
"params": {}
}
]
}
}
}
]
@@ -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)
})
})
@@ -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.'),
}),
+271 -4
View File
@@ -17,6 +17,7 @@ import {
} from '@/lib/api/contracts/deployments'
import {
booleanQueryFlagSchema,
MAX_ID_LENGTH,
missingFieldError,
noInputSchema,
runIdSchema,
@@ -58,6 +59,7 @@ import {
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
import { MAX_MCP_TOOL_NAME_BYTES } from '@/lib/mcp/constants'
import { WORKFLOW_SKIPPED_ITEM_TYPES } from '@/lib/workflows/editing/types'
export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id'
@@ -2053,6 +2055,8 @@ export const MAX_WORKFLOW_GRAPH_BLOCKS = 2000
export const MAX_WORKFLOW_GRAPH_EDGES = 10_000
/** Ceiling on one `POST /operations` batch. */
export const MAX_WORKFLOW_EDIT_OPERATIONS = 200
/** Ceiling on the complete tool list assigned to one Agent block. */
export const MAX_AGENT_TOOLS_PER_BLOCK = 100
/** Ceiling on one `PATCH /variables` batch; mirrors the application use case's own cap. */
export const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100
/** Ceiling on one bulk move; mirrors the application use case's own cap. */
@@ -2639,6 +2643,241 @@ const WORKFLOW_OPERATION_PARAM_ENVELOPE =
'block id, `{ block, handle }`, or an array of either; `success` is accepted as an ' +
'alias for the `source` handle.'
const v2AgentToolUsageControlSchema = z
.enum(['auto', 'force', 'none'])
.describe(
'When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`.'
)
const v2AgentToolParamsSchema = z
.record(z.string(), z.unknown().describe('One tool parameter value.'))
.describe(
'Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them.'
)
/** A catalog integration attached directly to an Agent block. */
export const v2AgentIntegrationToolSchema = z
.object({
type: z
.string()
.trim()
.min(1, 'Agent integration tool type cannot be empty')
.max(255, 'Agent integration tool type must be at most 255 characters')
.regex(
/^(?!(?:custom-tool|mcp)$).+$/,
'Agent integration tool type must be a catalog block id, not `custom-tool` or `mcp`'
)
.describe(
'Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id.'
),
operation: z
.string()
.trim()
.min(1, 'Agent integration tool operation cannot be empty')
.max(255, 'Agent integration tool operation must be at most 255 characters')
.optional()
.describe(
'Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.'
),
usageControl: v2AgentToolUsageControlSchema.optional(),
params: v2AgentToolParamsSchema.optional(),
})
.catchall(
z
.unknown()
.describe('Forward-compatible integration tool metadata preserved by the workflow editor.')
)
.meta({
id: 'AgentIntegrationTool',
title: 'Agent integration tool',
description:
'A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.',
examples: [
{
type: 'cloudwatch',
operation: 'describe_alarm_history',
usageControl: 'auto',
params: {},
},
],
})
const v2AgentCustomToolReferenceSchema = z
.object({
type: z.literal('custom-tool').describe('Custom-tool discriminator.'),
customToolId: z
.string()
.trim()
.min(1, 'Agent customToolId cannot be empty')
.max(255, 'Agent customToolId must be at most 255 characters')
.describe('Custom tool id returned by `GET /api/v2/custom-tools`.'),
usageControl: v2AgentToolUsageControlSchema.optional(),
})
.catchall(
z
.unknown()
.describe('Forward-compatible custom tool metadata preserved by the workflow editor.')
)
const v2AgentInlineCustomToolSchema = z
.object({
type: z.literal('custom-tool').describe('Custom-tool discriminator.'),
schema: z
.object({
type: z.literal('function').optional().describe('Function declaration discriminator.'),
function: z
.object({
name: z
.string()
.trim()
.min(1, 'Inline custom tool function name cannot be empty')
.max(64, 'Inline custom tool function name must be at most 64 characters')
.describe('Function name presented to the model.'),
description: z.string().optional().describe('What the inline custom tool does.'),
parameters: z
.record(
z.string(),
z.unknown().describe('One JSON Schema keyword on the function parameters.')
)
.describe('JSON Schema describing the function arguments.'),
})
.catchall(z.unknown().describe('Additional function declaration metadata.'))
.describe('OpenAI-style function definition.'),
})
.catchall(z.unknown().describe('Additional custom tool declaration metadata.'))
.describe('Inline OpenAI-style function declaration.'),
code: z.string().describe('Inline tool implementation executed by the Function runtime.'),
usageControl: v2AgentToolUsageControlSchema.optional(),
})
.catchall(
z
.unknown()
.describe('Forward-compatible custom tool metadata preserved by the workflow editor.')
)
/** A workspace custom tool attached directly to an Agent block. */
export const v2AgentCustomToolSchema = z
.union([v2AgentCustomToolReferenceSchema, v2AgentInlineCustomToolSchema])
.meta({
id: 'AgentCustomTool',
title: 'Agent custom tool',
description:
'A workspace custom tool. Reference `customToolId` is the preferred shape; the inline declaration is retained for legacy workflow round trips.',
examples: [
{
type: 'custom-tool',
customToolId: 'cst_01J9X2ABCDEF',
usageControl: 'auto',
},
],
})
/** An MCP server tool attached directly to an Agent block. */
export const v2AgentMcpToolSchema = z
.object({
type: z.literal('mcp').describe('MCP-tool discriminator.'),
params: z
.intersection(
z
.object({
serverId: z
.string()
.trim()
.min(1, 'Agent MCP serverId cannot be empty')
.max(MAX_ID_LENGTH, `Agent MCP serverId must be at most ${MAX_ID_LENGTH} characters`)
.describe('MCP server id returned by `GET /api/v2/mcp-servers`.'),
toolName: z
.string()
.trim()
.min(1, 'Agent MCP toolName cannot be empty')
.max(
MAX_MCP_TOOL_NAME_BYTES,
`Agent MCP toolName must be at most ${MAX_MCP_TOOL_NAME_BYTES} characters`
)
.refine(
(toolName) =>
new TextEncoder().encode(toolName).byteLength <= MAX_MCP_TOOL_NAME_BYTES,
`Agent MCP toolName must be at most ${MAX_MCP_TOOL_NAME_BYTES} bytes`
)
.describe('Tool name returned by the MCP server’s tools endpoint.'),
})
.catchall(z.unknown().describe('One parameter fixed by the workflow author.')),
z.record(z.string(), z.unknown().describe('One parameter fixed by the workflow author.'))
)
.describe(
'MCP server and tool identity plus any tool arguments fixed by the workflow author.'
),
usageControl: v2AgentToolUsageControlSchema.optional(),
})
.catchall(
z.unknown().describe('Forward-compatible MCP tool metadata preserved by the workflow editor.')
)
.meta({
id: 'AgentMcpTool',
title: 'Agent MCP tool',
description: 'One tool discovered from a workspace MCP server.',
examples: [
{
type: 'mcp',
params: { serverId: 'mcp_01J9X2ABCDEF', toolName: 'search_docs' },
usageControl: 'auto',
},
],
})
/** One callable tool attached directly to an Agent block. */
export const v2AgentToolSchema = z
.xor([v2AgentIntegrationToolSchema, v2AgentCustomToolSchema, v2AgentMcpToolSchema])
.meta({
id: 'AgentTool',
title: 'Agent tool',
description:
'A catalog integration operation, workspace custom tool, or MCP tool available to an Agent.',
})
export type V2AgentTool = z.input<typeof v2AgentToolSchema>
/** The stored value of an Agent block's `tools` input. */
export const v2AgentToolInputSchema = z
.array(v2AgentToolSchema)
.max(MAX_AGENT_TOOLS_PER_BLOCK, `Agent tools cannot exceed ${MAX_AGENT_TOOLS_PER_BLOCK} entries`)
.describe(
'Tools the Agent may call. Integration `type` and `operation` values come from `GET /api/v2/blocks/{blockId}`; custom and MCP identifiers come from their workspace catalog endpoints.'
)
.meta({
id: 'AgentToolInput',
title: 'Agent tools input',
description: 'The complete value stored in an Agent block’s `tools` input.',
})
export type V2AgentToolInput = z.input<typeof v2AgentToolInputSchema>
const v2WorkflowOperationInputsSchema = z
.intersection(
z
.object({
tools: v2AgentToolInputSchema
.optional()
.describe(
'Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.'
),
})
.catchall(
z
.unknown()
.describe(
'One block-specific input whose accepted shape is published by the block catalog.'
)
),
z.record(
z.string(),
z
.unknown()
.describe(
'One block-specific input whose accepted shape is published by the block catalog.'
)
)
)
.describe('Block configuration keyed by sub-block id.')
/**
* The keys `edit` accepts. Open because the per-block input set is defined by
* the block registry rather than by this contract, but the envelope around it
@@ -2646,9 +2885,18 @@ const WORKFLOW_OPERATION_PARAM_ENVELOPE =
* rename a block and nothing else.
*/
const v2WorkflowOperationParamsSchema = z
.record(
z.string(),
z.unknown().describe('One operation parameter; see the description for the accepted keys.')
.intersection(
z
.object({
inputs: v2WorkflowOperationInputsSchema.optional(),
})
.catchall(
z.unknown().describe('One operation parameter; see the description for the accepted keys.')
),
z.record(
z.string(),
z.unknown().describe('One operation parameter; see the description for the accepted keys.')
)
)
.describe(
'Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, ' +
@@ -2669,6 +2917,7 @@ const v2AddWorkflowBlockParamsSchema = z
.string()
.min(1, 'params.name is required to add a block')
.describe('Block display name.'),
inputs: v2WorkflowOperationInputsSchema.optional(),
})
.catchall(z.unknown().describe('One block-specific input or connection descriptor.'))
.describe(
@@ -2701,6 +2950,7 @@ const v2InsertIntoSubflowParamsSchema = z
.string()
.min(1, 'params.name is required to insert a block')
.describe('Block display name.'),
inputs: v2WorkflowOperationInputsSchema.optional(),
})
.catchall(z.unknown().describe('One block-specific input or connection descriptor.'))
.describe(
@@ -2821,7 +3071,24 @@ export const v2ApplyWorkflowOperationsBodySchema = z
examples: [
{
operations: [
{ operation_type: 'add', block_id: 'agent-1', params: { type: 'agent', name: 'Triage' } },
{
operation_type: 'add',
block_id: 'agent-1',
params: {
type: 'agent',
name: 'Triage',
inputs: {
tools: [
{
type: 'cloudwatch',
operation: 'describe_alarm_history',
usageControl: 'auto',
params: {},
},
],
},
},
},
],
},
],
+54 -1
View File
@@ -345,12 +345,19 @@ type ApplyWorkflowOperationsBodyRef0 =
params: {
type: string
name: string
inputs?: {
tools?: ApplyWorkflowOperationsBodyRef1
} & Record<string, unknown>
}
}
| {
operation_type: 'edit'
block_id: string
params: Record<string, unknown>
params: {
inputs?: {
tools?: ApplyWorkflowOperationsBodyRef1
} & Record<string, unknown>
} & Record<string, unknown>
}
| {
operation_type: 'delete'
@@ -363,6 +370,9 @@ type ApplyWorkflowOperationsBodyRef0 =
subflowId: string
type: string
name: string
inputs?: {
tools?: ApplyWorkflowOperationsBodyRef1
} & Record<string, unknown>
}
}
| {
@@ -373,6 +383,49 @@ type ApplyWorkflowOperationsBodyRef0 =
}
}
type ApplyWorkflowOperationsBodyRef1 = Array<ApplyWorkflowOperationsBodyRef2>
type ApplyWorkflowOperationsBodyRef2 =
| ApplyWorkflowOperationsBodyRef3
| ApplyWorkflowOperationsBodyRef4
| ApplyWorkflowOperationsBodyRef5
type ApplyWorkflowOperationsBodyRef3 = {
type: string
operation?: string
usageControl?: 'auto' | 'force' | 'none'
params?: Record<string, unknown>
}
type ApplyWorkflowOperationsBodyRef4 =
| {
type: 'custom-tool'
customToolId: string
usageControl?: 'auto' | 'force' | 'none'
}
| {
type: 'custom-tool'
schema: {
type?: 'function'
function: {
name: string
description?: string
parameters: Record<string, unknown>
}
}
code: string
usageControl?: 'auto' | 'force' | 'none'
}
type ApplyWorkflowOperationsBodyRef5 = {
type: 'mcp'
params: {
serverId: string
toolName: string
} & Record<string, unknown>
usageControl?: 'auto' | 'force' | 'none'
}
export type ApplyWorkflowOperationsBody = {
operations: Array<ApplyWorkflowOperationsBodyRef0>
atomic?: boolean
+59
View File
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { MAX_ID_LENGTH } from '../../apps/sim/lib/api/contracts/primitives'
import { billingOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/billing'
import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/files-audit'
import { knowledgeOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/knowledge'
@@ -14,6 +15,8 @@ import {
} from '../../apps/sim/lib/api/contracts/v2/openapi/shared'
import { tablesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/tables'
import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows'
import { MAX_AGENT_TOOLS_PER_BLOCK } from '../../apps/sim/lib/api/contracts/v2/workflows'
import { MAX_MCP_TOOL_NAME_BYTES } from '../../apps/sim/lib/mcp/constants'
import { generateOpenApiDocument, serializeOpenApiDocument } from './generator'
type JsonObject = Record<string, unknown>
@@ -293,6 +296,62 @@ describe('generated OpenAPI documents', () => {
)
})
it('publishes Agent tools as named integration, custom, and MCP schemas', () => {
const workflowsSpec = generateOpenApiDocument(workflowsOpenApiDocument)
const schemas = (workflowsSpec.components as JsonObject).schemas as JsonObject
const agentToolInput = schemas.AgentToolInput as JsonObject
const agentTool = schemas.AgentTool as JsonObject
const agentToolVariants = agentTool.oneOf as JsonObject[]
const integrationTool = schemas.AgentIntegrationTool as JsonObject
const integrationProperties = integrationTool.properties as JsonObject
const customTool = schemas.AgentCustomTool as JsonObject
const customToolVariants = customTool.anyOf as JsonObject[]
const inlineCustomToolProperties = customToolVariants[1].properties as JsonObject
const inlineCustomToolSchema = inlineCustomToolProperties.schema as JsonObject
const inlineCustomToolSchemaProperties = inlineCustomToolSchema.properties as JsonObject
const inlineFunction = inlineCustomToolSchemaProperties.function as JsonObject
const inlineFunctionProperties = inlineFunction.properties as JsonObject
const mcpTool = schemas.AgentMcpTool as JsonObject
const mcpProperties = mcpTool.properties as JsonObject
const mcpParams = mcpProperties.params as JsonObject
const mcpParamIdentity = (mcpParams.allOf as JsonObject[])[0]
const mcpParamProperties = mcpParamIdentity.properties as JsonObject
expect(agentToolVariants).toEqual([
{ $ref: '#/components/schemas/AgentIntegrationTool' },
{ $ref: '#/components/schemas/AgentCustomTool' },
{ $ref: '#/components/schemas/AgentMcpTool' },
])
expect(agentToolInput).toEqual(
expect.objectContaining({ type: 'array', maxItems: MAX_AGENT_TOOLS_PER_BLOCK })
)
expect(integrationProperties).toEqual(
expect.objectContaining({
type: expect.objectContaining({ type: 'string', pattern: expect.any(String) }),
operation: expect.objectContaining({ type: 'string' }),
usageControl: expect.objectContaining({ enum: ['auto', 'force', 'none'] }),
params: expect.objectContaining({ type: 'object' }),
})
)
expect(customTool).toHaveProperty('anyOf')
expect(inlineFunctionProperties.name).toEqual(
expect.objectContaining({ type: 'string', maxLength: 64 })
)
expect((mcpProperties.type as JsonObject).const).toBe('mcp')
expect(mcpParamProperties).toEqual(
expect.objectContaining({
serverId: expect.objectContaining({ type: 'string', maxLength: MAX_ID_LENGTH }),
toolName: expect.objectContaining({
type: 'string',
maxLength: MAX_MCP_TOOL_NAME_BYTES,
}),
})
)
expect(JSON.stringify(schemas.WorkflowEditOperation)).toContain(
'#/components/schemas/AgentToolInput'
)
})
it('uses named schemas for top-level response objects and list items', () => {
for (const document of DOCUMENTS) {
expect(anonymousTopLevelResponseObjects(generateOpenApiDocument(document))).toEqual([])