fix(hitl): add missing fields to block configs (#3027)

* fix(hitl): add missing fields to block configs

* update copilot paths

* one more case

* update helper usage
This commit is contained in:
Vikhyath Mondreti
2026-01-27 14:35:37 -08:00
committed by GitHub
parent 6b412c578d
commit 089427822e
17 changed files with 172 additions and 149 deletions

View File

@@ -6,7 +6,6 @@ import { useRouter } from 'next/navigation'
import {
Badge,
Button,
Code,
Input,
Label,
Table,
@@ -777,15 +776,6 @@ export default function ResumeExecutionPage({
refreshSelectedDetail,
])
const pauseResponsePreview = useMemo(() => {
if (!selectedDetail?.pausePoint.response?.data) return '{}'
try {
return JSON.stringify(selectedDetail.pausePoint.response.data, null, 2)
} catch {
return String(selectedDetail.pausePoint.response.data)
}
}, [selectedDetail])
const isFormComplete = useMemo(() => {
if (!isHumanMode || !hasInputFormat) return true
return inputFormatFields.every((field) => {
@@ -1155,10 +1145,12 @@ export default function ResumeExecutionPage({
borderBottom: '1px solid var(--border)',
}}
>
<Label>Pause Data</Label>
<Label>Display Data</Label>
</div>
<div style={{ padding: '16px' }}>
<Code.Viewer code={pauseResponsePreview} language='json' />
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
No display data configured
</p>
</div>
</div>
)}

View File

@@ -1183,19 +1183,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (sourceBlock.type === 'approval') {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const isSelfReference = activeSourceBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
} else {
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
}
} else if (sourceBlock.type === 'human_in_the_loop') {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
@@ -1400,13 +1387,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (!accessibleBlock) continue
// Skip the current block - blocks cannot reference their own outputs
// Exception: approval and human_in_the_loop blocks can reference their own outputs
if (
accessibleBlockId === blockId &&
accessibleBlock.type !== 'approval' &&
accessibleBlock.type !== 'human_in_the_loop'
)
continue
// Exception: human_in_the_loop blocks can reference their own outputs (url, resumeEndpoint)
if (accessibleBlockId === blockId && accessibleBlock.type !== 'human_in_the_loop') continue
const blockConfig = getBlock(accessibleBlock.type)
@@ -1520,19 +1502,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (accessibleBlock.type === 'approval') {
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
const isSelfReference = accessibleBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
} else {
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
}
} else if (accessibleBlock.type === 'human_in_the_loop') {
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)

View File

@@ -769,7 +769,13 @@ Example 3 (Array Input):
outputs: {
content: { type: 'string', description: 'Generated response content' },
model: { type: 'string', description: 'Model used for generation' },
tokens: { type: 'any', description: 'Token usage statistics' },
toolCalls: { type: 'any', description: 'Tool calls made' },
tokens: { type: 'json', description: 'Token usage statistics' },
toolCalls: { type: 'json', description: 'Tool calls made' },
providerTiming: {
type: 'json',
description: 'Provider timing information',
hiddenFromDisplay: true,
},
cost: { type: 'number', description: 'Cost of the API call', hiddenFromDisplay: true },
},
}

View File

@@ -162,5 +162,21 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
type: 'string',
description: 'Resume API endpoint URL for direct curl requests',
},
response: {
type: 'json',
description: 'Display data shown to the approver',
hiddenFromDisplay: true,
},
submission: {
type: 'json',
description: 'Form submission data from the approver',
hiddenFromDisplay: true,
},
resumeInput: {
type: 'json',
description: 'Raw input data submitted when resuming',
hiddenFromDisplay: true,
},
submittedAt: { type: 'string', description: 'ISO timestamp when the workflow was resumed' },
},
}

View File

@@ -247,6 +247,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
tokens: { type: 'json', description: 'Token usage' },
cost: { type: 'json', description: 'Cost information' },
selectedPath: { type: 'json', description: 'Selected routing path' },
selectedRoute: { type: 'string', description: 'Selected route ID' },
},
}

View File

@@ -44,6 +44,11 @@ export const WorkflowBlock: BlockConfig = {
childWorkflowName: { type: 'string', description: 'Child workflow name' },
result: { type: 'json', description: 'Workflow execution result' },
error: { type: 'string', description: 'Error message' },
childTraceSpans: {
type: 'json',
description: 'Child workflow trace spans',
hiddenFromDisplay: true,
},
},
hideFromToolbar: true,
}

View File

@@ -43,5 +43,10 @@ export const WorkflowInputBlock: BlockConfig = {
childWorkflowName: { type: 'string', description: 'Child workflow name' },
result: { type: 'json', description: 'Workflow execution result' },
error: { type: 'string', description: 'Error message' },
childTraceSpans: {
type: 'json',
description: 'Child workflow trace spans',
hiddenFromDisplay: true,
},
},
}

View File

@@ -157,8 +157,19 @@ export type OutputFieldDefinition =
* Uses the same condition format as subBlocks.
*/
condition?: OutputCondition
/**
* If true, this output is hidden from display in the tag dropdown and logs,
* but still available for resolution and execution.
*/
hiddenFromDisplay?: boolean
}
export function isHiddenFromDisplay(def: unknown): boolean {
return Boolean(
def && typeof def === 'object' && 'hiddenFromDisplay' in def && def.hiddenFromDisplay
)
}
export interface ParamConfig {
type: ParamType
description?: string

View File

@@ -1,5 +1,16 @@
import type { LoopType, ParallelType } from '@/lib/workflows/types'
/**
* Runtime-injected keys for trigger blocks that should be hidden from logs/display.
* These are added during execution but aren't part of the block's static output schema.
*/
export const TRIGGER_INTERNAL_KEYS = ['webhook', 'workflowId'] as const
export type TriggerInternalKey = (typeof TRIGGER_INTERNAL_KEYS)[number]
export function isTriggerInternalKey(key: string): key is TriggerInternalKey {
return TRIGGER_INTERNAL_KEYS.includes(key as TriggerInternalKey)
}
export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',

View File

@@ -11,8 +11,6 @@ import {
DEFAULTS,
EDGE,
isSentinelBlockType,
isTriggerBehavior,
isWorkflowBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
@@ -30,6 +28,7 @@ import type {
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
@@ -149,13 +148,15 @@ export class BlockExecutor {
blockLog.endedAt = new Date().toISOString()
blockLog.durationMs = duration
blockLog.success = true
blockLog.output = this.filterOutputForLog(block, normalizedOutput)
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
}
this.state.setBlockOutput(node.id, normalizedOutput, duration)
if (!isSentinel) {
const displayOutput = this.filterOutputForDisplay(block, normalizedOutput)
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
this.callOnBlockComplete(ctx, node, block, resolvedInputs, displayOutput, duration)
}
@@ -233,7 +234,7 @@ export class BlockExecutor {
blockLog.success = false
blockLog.error = errorMessage
blockLog.input = input
blockLog.output = this.filterOutputForLog(block, errorOutput)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
}
logger.error(
@@ -246,7 +247,7 @@ export class BlockExecutor {
)
if (!isSentinel) {
const displayOutput = this.filterOutputForDisplay(block, errorOutput)
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.callOnBlockComplete(ctx, node, block, input, displayOutput, duration)
}
@@ -335,51 +336,6 @@ export class BlockExecutor {
return { result: output }
}
private filterOutputForLog(
block: SerializedBlock,
output: NormalizedBlockOutput
): NormalizedBlockOutput {
const blockType = block.metadata?.id
if (blockType === BlockType.HUMAN_IN_THE_LOOP) {
const filtered: NormalizedBlockOutput = {}
for (const [key, value] of Object.entries(output)) {
if (key.startsWith('_')) continue
if (key === 'response') continue
filtered[key] = value
}
return filtered
}
if (isTriggerBehavior(block)) {
const filtered: NormalizedBlockOutput = {}
const internalKeys = ['webhook', 'workflowId']
for (const [key, value] of Object.entries(output)) {
if (internalKeys.includes(key)) continue
filtered[key] = value
}
return filtered
}
return output
}
private filterOutputForDisplay(
block: SerializedBlock,
output: NormalizedBlockOutput
): NormalizedBlockOutput {
const filtered = this.filterOutputForLog(block, output)
if (isWorkflowBlockType(block.metadata?.id)) {
const { childTraceSpans: _, ...displayOutput } = filtered as {
childTraceSpans?: unknown
} & Record<string, unknown>
return displayOutput
}
return filtered
}
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
const blockId = node.id
const blockName = block.metadata?.name ?? blockId

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { BlockType, isTriggerBehavior } from '@/executor/constants'
import { BlockType, isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
@@ -33,7 +33,12 @@ export class TriggerBlockHandler implements BlockHandler {
const starterOutput = starterState.output
if (starterOutput.webhook?.data) {
const { webhook, workflowId, ...cleanOutput } = starterOutput
const cleanOutput: Record<string, unknown> = {}
for (const [key, value] of Object.entries(starterOutput)) {
if (!isTriggerInternalKey(key)) {
cleanOutput[key] = value
}
}
return cleanOutput
}

View File

@@ -0,0 +1,51 @@
import { getBlock } from '@/blocks'
import { isHiddenFromDisplay } from '@/blocks/types'
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
import type { NormalizedBlockOutput } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
/**
* Filters block output for logging/display purposes.
* Removes internal fields and fields marked with hiddenFromDisplay.
*
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
* @param output - The raw block output to filter
* @param options - Optional configuration
* @param options.block - Full SerializedBlock for trigger behavior detection
* @param options.additionalHiddenKeys - Extra keys to filter out (e.g., 'resume')
*/
export function filterOutputForLog(
blockType: string,
output: NormalizedBlockOutput,
options?: {
block?: SerializedBlock
additionalHiddenKeys?: string[]
}
): NormalizedBlockOutput {
const blockConfig = blockType ? getBlock(blockType) : undefined
const filtered: NormalizedBlockOutput = {}
const additionalHiddenKeys = options?.additionalHiddenKeys ?? []
for (const [key, value] of Object.entries(output)) {
// Skip internal keys (underscore prefix)
if (key.startsWith('_')) continue
if (blockConfig?.outputs && isHiddenFromDisplay(blockConfig.outputs[key])) {
continue
}
// Skip runtime-injected trigger keys not in block config
if (options?.block && isTriggerBehavior(options.block) && isTriggerInternalKey(key)) {
continue
}
// Skip additional hidden keys specified by caller
if (additionalHiddenKeys.includes(key)) {
continue
}
filtered[key] = value
}
return filtered
}

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types'
import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types'
@@ -397,7 +398,11 @@ async function processBlockMetadata(
category: blockConfig.category,
bgColor: blockConfig.bgColor,
inputs: blockConfig.inputs || {},
outputs: blockConfig.outputs || {},
outputs: blockConfig.outputs
? Object.fromEntries(
Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
)
: {},
tools: blockConfig.tools?.access || [],
hideFromToolbar: blockConfig.hideFromToolbar,
}

View File

@@ -6,7 +6,7 @@ import {
type GetBlockConfigResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
@@ -310,6 +310,7 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
const trigger = getTrigger(triggerId)
if (trigger.outputs) {
for (const [key, def] of Object.entries(trigger.outputs)) {
if (isHiddenFromDisplay(def)) continue
outputs[key] = extractOutputField(def)
}
}
@@ -342,6 +343,7 @@ function extractOutputs(
const tool = toolsRegistry[toolId]
if (tool?.outputs) {
for (const [key, def] of Object.entries(tool.outputs)) {
if (isHiddenFromDisplay(def)) continue
outputs[key] = extractOutputField(def)
}
return outputs
@@ -355,6 +357,7 @@ function extractOutputs(
// Use block-level outputs
if (blockConfig.outputs) {
for (const [key, def] of Object.entries(blockConfig.outputs)) {
if (isHiddenFromDisplay(def)) continue
outputs[key] = extractOutputField(def)
}
}

View File

@@ -7,8 +7,7 @@ import {
GetBlocksMetadataResult,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
@@ -249,6 +248,12 @@ export const getBlocksMetadataServerTool: BaseServerTool<
}
}
const filteredOutputs = blockConfig.outputs
? Object.fromEntries(
Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
)
: undefined
metadata = {
id: blockId,
name: blockConfig.name || blockId,
@@ -262,7 +267,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
triggers,
operationInputSchema: operationParameters,
operations,
outputs: blockConfig.outputs,
outputs: filteredOutputs,
}
}

View File

@@ -16,7 +16,12 @@ import {
USER_FILE_PROPERTY_TYPES,
} from '@/lib/workflows/types'
import { getBlock } from '@/blocks'
import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types'
import {
type BlockConfig,
isHiddenFromDisplay,
type OutputCondition,
type OutputFieldDefinition,
} from '@/blocks/types'
import { getTool } from '@/tools/utils'
import { getTrigger, isTriggerValid } from '@/triggers'
@@ -86,8 +91,8 @@ function evaluateOutputCondition(
}
/**
* Filters outputs based on their conditions.
* Returns a new OutputDefinition with only the outputs whose conditions are met.
* Filters outputs based on their conditions and hiddenFromDisplay flag.
* Returns a new OutputDefinition with only the outputs that should be shown.
*/
function filterOutputsByCondition(
outputs: OutputDefinition,
@@ -96,6 +101,8 @@ function filterOutputsByCondition(
const filtered: OutputDefinition = {}
for (const [key, value] of Object.entries(outputs)) {
if (isHiddenFromDisplay(value)) continue
if (!value || typeof value !== 'object' || !('condition' in value)) {
filtered[key] = value
continue
@@ -105,7 +112,7 @@ function filterOutputsByCondition(
const passes = !condition || evaluateOutputCondition(condition, subBlocks)
if (passes) {
const { condition: _, ...rest } = value
const { condition: _, hiddenFromDisplay: __, ...rest } = value
filtered[key] = rest
}
}
@@ -259,50 +266,26 @@ export function getBlockOutputs(
}
if (blockType === 'human_in_the_loop') {
const hitlOutputs: OutputDefinition = {
url: { type: 'string', description: 'Resume UI URL' },
resumeEndpoint: {
type: 'string',
description: 'Resume API endpoint URL for direct curl requests',
},
}
// Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition)
const baseOutputs = filterOutputsByCondition(
{ ...(blockConfig.outputs || {}) } as OutputDefinition,
subBlocks
)
// Add inputFormat fields (resume form fields)
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
for (const field of normalizedInputFormat) {
const fieldName = field?.name?.trim()
if (!fieldName) continue
hitlOutputs[fieldName] = {
baseOutputs[fieldName] = {
type: (field?.type || 'any') as any,
description: `Field from resume form`,
description: field?.description || `Field from resume form`,
}
}
return hitlOutputs
}
if (blockType === 'approval') {
// Start with only url (apiUrl commented out - not accessible as output)
const pauseResumeOutputs: OutputDefinition = {
url: { type: 'string', description: 'Resume UI URL' },
// apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output
}
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
// Add each input format field as a top-level output
for (const field of normalizedInputFormat) {
const fieldName = field?.name?.trim()
if (!fieldName) continue
pauseResumeOutputs[fieldName] = {
type: (field?.type || 'any') as any,
description: `Field from input format`,
}
}
return pauseResumeOutputs
return baseOutputs
}
if (startPath === StartBlockPath.LEGACY_STARTER) {

View File

@@ -9,6 +9,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionResult, PausePoint, SerializedSnapshot } from '@/executor/types'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import type { SerializedConnection } from '@/serializer/types'
const logger = createLogger('HumanInTheLoopManager')
@@ -576,13 +577,11 @@ export class PauseResumeManager {
log.blockId === contextId
)
if (blockLogIndex !== -1) {
// Filter output for logging (exclude internal fields and response)
const filteredOutput: Record<string, unknown> = {}
for (const [key, value] of Object.entries(mergedOutput)) {
if (key.startsWith('_')) continue
if (key === 'response') continue
filteredOutput[key] = value
}
// Filter output for logging using shared utility
// 'resume' is redundant with url/resumeEndpoint so we filter it out
const filteredOutput = filterOutputForLog('human_in_the_loop', mergedOutput, {
additionalHiddenKeys: ['resume'],
})
stateCopy.blockLogs[blockLogIndex] = {
...stateCopy.blockLogs[blockLogIndex],
blockId: stateBlockKey,