mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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:
@@ -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.'),
|
||||
}),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
Reference in New Issue
Block a user