Files
sim/apps/sim/executor/execution/block-executor.ts
T
Bill Leoutsakos d24bc7eccb feat(agent-stream): thinking and tool streaming (#5671)
* feat(agent-stream): add agent-events thinking/tool streaming for chat and canvas

Ship the agent-events-v1 protocol with provider tool loops, dual-gated chat thinking, DeepSeek/Groq/OpenAI reasoning wiring, and ChatGPT-like thinking chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): clear stuck streaming UI and format db snapshot

Biome was failing CI on migrations/meta/0261_snapshot.json. Also settle
assistant streaming/tool flags when SSE ends without a terminal frame,
without clobbering Stop's finalized content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): satisfy biome format and import order

Auto-format the sim package for CI lint:check, and repair the Anthropic
streaming tool-loop payload after an unsafe delete-to-undefined rewrite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): keep drained answer on abort and update migration journal test

Treat AbortError from reader.cancel as a cancelled pump result so soft-complete
retains answerText. Point the workspace storage migration journal assertion at
0261_chat_include_thinking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): keep Stop notice when server emits cancel error

Ignore terminal SSE error frames after the user aborts so
"Client cancelled request" cannot overwrite "Response stopped by user".

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(chat): ChatGPT-style thinking shimmer and stick-to-bottom scroll

Add left-to-right shimmer on live thinking label/body, keep scroll working by
shimmering an inner node, and follow the answer only while near the bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): stop pump on client disconnect; soft-complete agents only

Abort the agent stream pump when the projected HTTP body is cancelled so
provider work does not continue after disconnect. Limit AbortError soft-success
to Agent blocks so Function/HTTP cancels still fail in logs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): persist includeThinking across pause snapshots

Paused chat runs with Include thinking enabled were dropping the flag when
serializing the pause snapshot, so resume always rebuilt streams without
thinking/tool SSE frames.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): keep drained answer text when stream times out

Persist pump answerText onto the streaming execution before throwing on
timeout, and carry that partial content into the failed block output so
logs match what the client already saw.

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(chat): auto-collapse tools chrome when tool streaming ends

Match thinking UX: open while tools run, collapse when finished, and keep
the panel open only if the user manually reopens it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): settle canvas stream chrome on failure paths

Clear agentStreamActive and settle running tool chips when blocks error,
timeouts cancel runs, or execution ends without stream:done so the output
panel does not stay on live Thinking/Using tools chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lint): organize imports in terminal console store

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent-stream): mark open tools cancelled on HITL pause

Pause can interrupt a tool loop without tool end events; settling those
chips as success incorrectly showed unfinished tools as complete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(db): drop branch-local 0261 migration ahead of staging merge

* chore(db): regenerate include_thinking migration as 0266 post staging merge

* fix(providers): resolve type errors in streaming tool loop call sites

* fix(agent-stream): gate agent events opt-in and correct provider loop behavior

- streamToolCalls and provider thinking requests now require run-level
  agentEvents opt-in (canvas on, chat dual-gated, API off) so existing
  runs keep pre-agent-events behavior exactly
- OpenAI reasoning summaries opt-in + strip-and-retry on unverified-org 400
- streaming loops run tool postProcess again (firecrawl/exa async results)
- bedrock live loop falls back to silent path for responseFormat
- deepseek: reasoning_content pass-back unconditional, 'none' sends disabled
- groq: x_groq.usage fallback, reasoning params gated, qwen none disables
- gemini: functionCall parts echoed verbatim, local ids only for events
- truncated turns (max_tokens/length) no longer execute partial tool calls
- MAX_TOOL_ITERATIONS exit flushes last turn text as final answer
- iterations reports actual model calls; shared loop plumbing extracted

* refactor(agent-stream): consolidate protocol, dedupe client/server plumbing, hygiene

- canonical ChatStreamFrame union + type guards consumed by server emitters
  and the chat client; stream_error restored to legacy log-only handling
- strip thinking/tool args from providerTiming on public final envelopes
- shared tool-chip lifecycle module for chat, canvas, and console store
- shared sink-to-execution-events forwarder replaces the copy-pasted
  adapter in the execute route and HITL manager; LIVE_ONLY event set shared
- stream:thinking payload field renamed data->text; canvas thinking batched
- abort reasons carried as AbortError DOMExceptions so raw fetch consumers
  classify correctly; thinking cap renamed to chars and scope-documented
- kimi wired for agent events like the other compat providers
- deleted dead exports/step-N comments; fixtures match real wire shapes;
  loop tests use explicit mocks instead of importOriginal

* test(agent-stream): cover the dual-gated execution path and typed abort reasons

- chat route tests assert agentEvents reaches executeWorkflow only when
  policy and protocol header agree
- execution-limits tests assert AbortError-typed reasons
- executor metadata type carries agentEvents

* fix(deploy-modal): align include-thinking spacing with the modal's 6.5px rhythm

* docs(agent-stream): autogenerate per-model thinking/tool stream support on the Agent block page

- capabilities.thinking.streamed ('full' | 'summary' | 'none') on models.ts,
  explicit for the Anthropic family where visibility varies per generation;
  getThinkingStreamVisibility exposes the derivation for docs and UI alike
- scripts/sync-agent-stream-docs.ts regenerates the support tables between
  markers in workflows/blocks/agent.mdx from the model registry and
  STREAMING_TOOL_CALL_PROVIDERS; --check fails on drift or missing metadata
- wired agent-stream-docs:check into CI next to the other sync gates

* feat(anthropic): request summarized thinking display for omitted-default Claude models

The newest Claude generations (Fable 5, Sonnet 5, Opus 4.8/4.7) default
thinking.display to omitted — empty thinking blocks, no deltas. On
agent-events runs Sim now opts back in with display: 'summarized', driven
by the registry's streamed metadata; legacy runs keep the exact
pre-agent-events request shape. Registry, generated docs, and the family
capability table updated accordingly.

* docs(skills): cover thinking.streamed and agent-stream docs sync in model skills

* chore(deps): upgrade @anthropic-ai/sdk to 0.114.0 and adopt official types

- adaptive thinking, display, and output_config are now SDK-typed; the only
  remaining custom payload field is output_format (beta-header structured
  outputs, which the SDK models as output_config.format instead)
- anthropic stream events narrow on the SDK's discriminated unions instead
  of anonymous casts; compat deltas type content/tool_calls from the OpenAI
  SDK with vendor reasoning fields as an explicit optional extension
- @sim/auth exposes an explicit VerifyAuth contract so its declarations no
  longer reference better-auth's nested zod instance (TS2883 under fresh
  install layouts); realtime consumer aligned
- docs app zod pinned to the repo's exact 4.3.6 so ai SDK types bind the
  same zod instance (docs type-check was latently broken)
- knowledge embedding tests made hermetic against local .env keys and
  hosted rotation fallback

* refactor(providers): replace legacy as-any stream casts with annotated typed casts

* refactor(providers): finish provider audit — remove dead byte-stream helper, annotate remaining legacy casts

Audit of all 26 providers for the agent-events feature confirmed every
streaming execution declares agent-events-v1 and every adapter emits
AgentStreamEvent objects. Cleanup from the audit: the unconsumed legacy
createOpenAICompatibleStream byte helper is deleted, and the remaining
streamResponse-as-any casts (xai, nvidia, kimi, meta, zai, sakana) are
annotated typed casts matching the groq/deepseek fix.

* feat(streaming): stream answer text live during tool loops via turn_end protocol

The live tool loops buffered all answer text per model turn (classification
of intermediate vs final is only known at turn end), so gated surfaces saw
thinking stream, then dead air with the thinking chrome stuck open, then the
whole answer at once.

Loops now emit text deltas live as `turn: 'pending'` plus a `turn_end`
event per turn. The pump buffers pending text and projects it to the byte
path (answerText/logs/memory/legacy clients) only on a final turn_end, so
all settled semantics are unchanged. Gated surfaces render the pending text
as it streams and reconcile with a reset when a turn resolves to tools:

- public chat: live `chunk` frames from the sink + dual-gated `chunk_reset`;
  byte-path frame emission is suppressed to avoid duplicates (kept for
  response-format transformed streams via clientStreamTransformed)
- canvas: forwarder emits live `stream:chunk` + `stream:chunk_reset`; the
  execute route and HITL resume readers stop re-emitting byte chunks; panel
  chat tracks per-block segments and replaces content on flush
- chat client: per-block text segments, chunk_reset handling, and thinking
  chrome now settles on tool start as well as first answer chunk

* fix(streaming): address validated review findings across provider gating and reset reconciliation

Three-reviewer pass over the branch, findings validated against staging:

- agent-handler forwards agentEvents to executeProviderRequest — the flag was
  computed but dropped in the field-by-field copy, so provider-side thinking
  requests (OpenAI summaries, Gemini includeThoughts, Anthropic summarized
  display) never activated on opted-in runs
- openai: restore summary:'auto' alongside explicit reasoning effort — staging
  always paired them; gating summary purely on agentEvents changed legacy
  payloads
- gemini: Gemini 2 + tools + responseFormat falls back to the silent path;
  the live loop never applied the deferred responseSchema for AUTO tools
- openai-compat loop: malformed tool-argument JSON fails the call instead of
  executing with defaulted {} args (staging parsed inside the execution try)
- openai-compat parser: a vendor id arriving after a synthesized start no
  longer renames the call (start/end ids stayed consistent)
- stream-pump: abort closes the byte projection so a drain blocked on
  backpressure cannot deadlock teardown
- chunk_reset removes the block from the client text order (deployed chat +
  panel chat) so a reset block re-registers at arrival position — fixes
  separator/order corruption when parallel blocks stream around a reset
- resume route echoes the negotiated X-Sim-Stream-Protocol response header
  (parity with the chat route); docs: [DONE] wire shape + final-vs-error
  terminal semantics corrected

* chore(deps): exempt pinned @anthropic-ai/sdk 0.114.0 from the release-age gate

CI's bun install --frozen-lockfile blocks 0.114.0 (published 2026-07-23,
younger than the 7-day supply-chain gate). The pin is exact and was vetted
for the agent-events streaming work; following the existing bunfig pattern,
the exclusion ages out on 2026-07-30 and should be dropped then.

* chore(providers): fix double-cast-allowed annotation placement for the strict boundary audit

The audit only recognizes the annotation on the line directly above the cast;
two annotations had drifted behind intervening code lines (groq stream params,
deepseek loop messages) and the OpenAI reasoning-summary widening cast was
never annotated. No behavior change.

* fix(chat): settle straggler tool chips as error when final reports failure

A failed run can still terminate with a `final` frame carrying success: false;
running chips previously settled green regardless of the outcome.

* fix(canvas): wire agent stream chrome into run-from-block

Run-from-block executions emit the same live stream:thinking/stream:tool
events as full runs but registered none of the handlers, so the terminal
never showed thinking or tool chips on that path. The per-run chrome
(batched thinking writes + tool chip lifecycle + settlement on stream done,
block error, and every terminal execution state) is extracted into a shared
createAgentStreamChrome factory consumed by both paths.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-07-23 19:39:03 -07:00

1016 lines
33 KiB
TypeScript

import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import {
containsUserFileWithMetadata,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
buildResumeUiUrl,
DEFAULTS,
EDGE,
isSentinelBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type {
BlockStateWriter,
ContextExtensions,
WorkflowNodeMetadata,
} from '@/executor/execution/types'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils.ts'
import {
type BlockHandler,
type BlockLog,
type BlockState,
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
type StreamingExecution,
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import {
buildUnifiedParentIterations,
getIterationContext,
} from '@/executor/utils/iteration-context'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import {
buildBranchNodeId,
buildOuterBranchScopedId,
extractOuterBranchIndex,
} from '@/executor/utils/subflow-utils'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
type VariableResolver,
} from '@/executor/variables/resolver'
import { createAgentStreamPump } from '@/providers/stream-pump'
import type { SerializedBlock } from '@/serializer/types'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('BlockExecutor')
export class BlockExecutor {
private execLogger: Logger
constructor(
private blockHandlers: BlockHandler[],
private resolver: VariableResolver,
private contextExtensions: ContextExtensions,
private state: BlockStateWriter
) {
this.execLogger = logger.withMetadata({
workflowId: this.contextExtensions.metadata?.workflowId,
workspaceId: this.contextExtensions.workspaceId,
executionId: this.contextExtensions.executionId,
userId: this.contextExtensions.userId,
requestId: this.contextExtensions.metadata?.requestId,
})
}
async execute(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock
): Promise<NormalizedBlockOutput> {
const handler = this.findHandler(block)
if (!handler) {
throw buildBlockExecutionError({
block,
context: ctx,
error: `No handler found for block type: ${block.metadata?.id ?? 'unknown'}`,
})
}
const blockType = block.metadata?.id ?? ''
const isSentinel = isSentinelBlockType(blockType)
// Capture startedAt and startTime at the same synchronous instant so
// blockLog.startedAt and performance.now()-derived durationMs share a
// single reference point. Any executor work below counts toward this block.
const startedAt = new Date().toISOString()
const startTime = performance.now()
let blockLog: BlockLog | undefined
let blockStartPromise: Promise<void> | undefined
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node, startedAt)
ctx.blockLogs.push(blockLog)
blockStartPromise = this.fireBlockStartCallback(ctx, node, block, blockLog.executionOrder)
await blockStartPromise
}
let resolvedInputs: Record<string, any> = {}
let inputsForLog: Record<string, any> = {}
const nodeMetadata = {
...this.buildNodeMetadata(node),
executionOrder: blockLog?.executionOrder,
}
let cleanupSelfReference: (() => void) | undefined
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {
cleanupSelfReference = this.preparePauseResumeSelfReference(ctx, node, block, nodeMetadata)
}
try {
if (!isSentinel && blockType) {
await validateBlockType(ctx.userId, ctx.workspaceId, blockType, ctx)
}
if (block.metadata?.id === BlockType.FUNCTION) {
const {
resolvedInputs: fnInputs,
displayInputs,
contextVariables,
} = await this.resolver.resolveInputsForFunctionBlock(
ctx,
node.id,
block.config.params,
block
)
resolvedInputs = {
...fnInputs,
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
...(displayInputs.code !== undefined
? { [FUNCTION_BLOCK_DISPLAY_CODE_KEY]: displayInputs.code }
: {}),
}
inputsForLog = displayInputs
} else {
resolvedInputs = await this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
inputsForLog = resolvedInputs
}
if (blockLog) {
blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id)
}
} catch (error) {
cleanupSelfReference?.()
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'input_resolution'
)
}
cleanupSelfReference?.()
let streamingPartialOutput: Record<string, any> | undefined
try {
const output = handler.executeWithNode
? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata)
: await handler.execute(ctx, block, resolvedInputs)
const isStreamingExecution =
output && typeof output === 'object' && 'stream' in output && 'execution' in output
let normalizedOutput: NormalizedBlockOutput
if (isStreamingExecution) {
const streamingExec = output as StreamingExecution
// Always drain via the agent stream pump (tokens/cost/timing callbacks),
// even with no `onStream`. When block-output redaction is on we do not
// live-forward chunks; content is masked before persist and the masked
// final output reaches the client via block-complete.
try {
await this.handleStreamingExecution(
ctx,
node,
block,
streamingExec,
resolvedInputs,
normalizeStringArray(ctx.selectedOutputs)
)
} catch (streamError) {
// Timeout / drain failures may still have projected answer text — keep it
// for the failed block output so logs match what the client already saw.
streamingPartialOutput = streamingExec.execution?.output
throw streamError
}
normalizedOutput = this.normalizeOutput(
streamingExec.execution.output ?? streamingExec.execution
)
} else {
normalizedOutput = this.normalizeOutput(output)
}
if (ctx.includeFileBase64 === true && containsUserFileWithMetadata(normalizedOutput)) {
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
requestId: ctx.metadata.requestId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
maxBytes: ctx.base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput
}
if (ctx.piiBlockOutputRedaction?.enabled) {
// In-flight redaction before the log/state split below, so both the
// downstream state copy and the persisted log copy are masked.
// `onFailure: 'throw'` aborts the run rather than feeding corrupted/leaked
// data downstream.
const redactionOptions = {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
customPatterns: ctx.piiBlockOutputRedaction.customPatterns,
onFailure: 'throw' as const,
}
// Tools like the function executor offload large outputs to large-value
// refs BEFORE they reach here, and the string walk treats a ref as opaque.
// So hydrate → mask → re-store any refs first, then mask inline strings —
// otherwise PII inside an offloaded output is never redacted.
normalizedOutput = await redactLargeValueRefsInValue(normalizedOutput, {
...redactionOptions,
store: {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
},
})
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
}
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
preserveUserFileBase64: ctx.includeFileBase64 === true,
requireDurable: true,
})) as NormalizedBlockOutput
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = true
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
}
}
const { childTraceSpans: _traces, ...outputForState } = normalizedOutput
this.setNodeOutput(node, outputForState as NormalizedBlockOutput, duration)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId =
typeof normalizedOutput._childWorkflowInstanceId === 'string'
? normalizedOutput._childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(inputsForLog, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
return outputForState as NormalizedBlockOutput
} catch (error) {
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'execution',
streamingPartialOutput
)
}
}
private buildNodeMetadata(node: DAGNode): WorkflowNodeMetadata {
const metadata = node?.metadata ?? {}
return {
nodeId: node.id,
loopId: metadata.subflowType === 'loop' ? metadata.subflowId : undefined,
parallelId: metadata.subflowType === 'parallel' ? metadata.subflowId : undefined,
subflowId: metadata.subflowId,
subflowType: metadata.subflowType,
branchIndex: metadata.branchIndex,
branchTotal: metadata.branchTotal,
originalBlockId: metadata.originalBlockId,
isLoopNode: metadata.isLoopNode,
}
}
private setNodeOutput(node: DAGNode, output: NormalizedBlockOutput, duration = 0): void {
this.state.setBlockOutput(node.id, output, duration)
const originalBlockId = node.metadata.originalBlockId
const branchIndex = node.metadata.branchIndex
if (
node.metadata.isParallelBranch &&
originalBlockId &&
branchIndex !== undefined &&
extractOuterBranchIndex(node.id) === undefined
) {
const globalBranchNodeId = buildBranchNodeId(originalBlockId, branchIndex)
if (globalBranchNodeId !== node.id) {
this.state.setBlockOutput(globalBranchNodeId, output, duration)
}
this.state.setBlockOutput(
buildOuterBranchScopedId(originalBlockId, branchIndex),
output,
duration
)
}
}
private findHandler(block: SerializedBlock): BlockHandler | undefined {
return this.blockHandlers.find((h) => h.canHandle(block))
}
private async handleBlockError(
error: unknown,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
blockStartPromise: Promise<void> | undefined,
startTime: number,
blockLog: BlockLog | undefined,
inputsForLog: Record<string, any>,
isSentinel: boolean,
phase: 'input_resolution' | 'execution',
streamingPartialOutput?: Record<string, any>
): Promise<NormalizedBlockOutput> {
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
const errorMessage = normalizeError(error)
const hasLogInputs =
inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0
const input = hasLogInputs
? inputsForLog
: ((block.config?.params as Record<string, any> | undefined) ?? {})
// Routine user Stop on Agent streams: don't paint a failed agent block
// (workflow is already cancelled). Timeouts abort with reason `'timeout'`.
// Non-agent blocks (HTTP, Function, etc.) still fail normally on AbortError
// so logs don't show a green empty success.
const isAbort =
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
const isTimeout = isTimeoutAbortReason(ctx.abortSignal?.reason)
const isAgentBlock = block.metadata?.id === BlockType.AGENT
if (isAbort && !isTimeout && ctx.abortSignal?.aborted && isAgentBlock) {
const softOutput: NormalizedBlockOutput = {
content: '',
}
this.setNodeOutput(node, softOutput, duration)
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = true
blockLog.error = undefined
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block })
}
this.execLogger.info('Block stream aborted by client; soft-completing', {
blockId: node.id,
blockType: block.metadata?.id,
})
if (!isSentinel && blockLog) {
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(input, block.metadata?.id),
filterOutputForLog(block.metadata?.id || '', softOutput, { block }),
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt
)
}
return softOutput
}
const errorOutput: NormalizedBlockOutput = {
error: errorMessage,
}
// Keep any answer text already drained before timeout/failure so logs match
// what was projected to the client.
const partialContent = streamingPartialOutput?.content
if (typeof partialContent === 'string' && partialContent) {
errorOutput.content = partialContent
}
if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
}
this.setNodeOutput(node, errorOutput, duration)
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = false
blockLog.error = errorMessage
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
blockLog.childTraceSpans = error.childTraceSpans
}
}
this.execLogger.error(
phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed',
{
blockId: node.id,
blockType: block.metadata?.id,
error: errorMessage,
}
)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error)
? error.childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(input, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
const hasErrorPort = this.hasErrorPortEdge(node)
if (hasErrorPort) {
if (blockLog) {
blockLog.errorHandled = true
}
this.execLogger.info('Block has error port - returning error output instead of throwing', {
blockId: node.id,
error: errorMessage,
})
return errorOutput
}
const errorToThrow = error instanceof Error ? error : new Error(errorMessage)
throw buildBlockExecutionError({
block,
error: errorToThrow,
context: ctx,
additionalInfo: {
nodeId: node.id,
executionTime: duration,
},
})
}
private hasErrorPortEdge(node: DAGNode): boolean {
for (const [_, edge] of node.outgoingEdges) {
if (edge.sourceHandle === EDGE.ERROR) {
return true
}
}
return false
}
private createBlockLog(
ctx: ExecutionContext,
blockId: string,
block: SerializedBlock,
node: DAGNode,
startedAt: string
): BlockLog {
let blockName = block.metadata?.name ?? blockId
let loopId: string | undefined
let parallelId: string | undefined
let iterationIndex: number | undefined
if (node?.metadata) {
if (
node.metadata.branchIndex !== undefined &&
node.metadata.subflowType === 'parallel' &&
node.metadata.subflowId
) {
blockName = `${blockName} (iteration ${node.metadata.branchIndex})`
iterationIndex = node.metadata.branchIndex
parallelId = node.metadata.subflowId
} else if (
node.metadata.isLoopNode &&
node.metadata.subflowType === 'loop' &&
node.metadata.subflowId
) {
loopId = node.metadata.subflowId
const loopScope = ctx.loopExecutions?.get(loopId)
if (loopScope && loopScope.iteration !== undefined) {
blockName = `${blockName} (iteration ${loopScope.iteration})`
iterationIndex = loopScope.iteration
} else {
this.execLogger.warn('Loop scope not found for block', { blockId, loopId })
}
}
}
const containerId = parallelId ?? loopId
const parentIterations = containerId
? buildUnifiedParentIterations(ctx, containerId)
: undefined
return {
blockId,
blockName,
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
startedAt,
executionOrder: getNextExecutionOrder(ctx),
endedAt: '',
durationMs: 0,
success: false,
loopId,
parallelId,
iterationIndex,
...(parentIterations?.length && { parentIterations }),
}
}
private normalizeOutput(output: unknown): NormalizedBlockOutput {
if (output === null || output === undefined) {
return {}
}
if (typeof output === 'object' && !Array.isArray(output)) {
return output as NormalizedBlockOutput
}
return { result: output }
}
/**
* Sanitizes inputs for log display.
* - Filters out system fields (UI-only, readonly, internal flags)
* - Removes UI state from inputFormat items (e.g., collapsed)
* - Parses JSON strings to objects for readability
* - Redacts sensitive fields (privateKey, password, tokens, etc.)
* Returns a new object - does not mutate the original inputs.
*/
private sanitizeInputsForLog(
inputs: Record<string, any>,
blockType?: string
): Record<string, any> {
// Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the
// baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input
// field values (the inputMapping contents) instead.
if (isCustomBlockType(blockType)) {
const mapping = inputs.inputMapping
const parsed =
typeof mapping === 'string'
? (() => {
try {
return JSON.parse(mapping)
} catch {
return {}
}
})()
: mapping
inputs = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(inputs)) {
if (
SYSTEM_SUBBLOCK_IDS.includes(key) ||
key === 'triggerMode' ||
key === FUNCTION_BLOCK_CONTEXT_VARS_KEY ||
key === FUNCTION_BLOCK_DISPLAY_CODE_KEY
) {
continue
}
if (key === 'inputFormat' && Array.isArray(value)) {
result[key] = sanitizeInputFormat(value)
continue
}
if (key === 'tools' && Array.isArray(value)) {
result[key] = sanitizeTools(value)
continue
}
// isJSONString is a quick heuristic (checks for { or [), not a validator.
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
if (typeof value === 'string' && isJSONString(value)) {
try {
result[key] = JSON.parse(value.trim())
} catch {
// Not valid JSON, keep original string
result[key] = value
}
} else {
result[key] = value
}
}
return redactApiKeys(result)
}
/**
* Fires the `onBlockStart` progress callback before block execution continues.
* Returning the promise lets completion callbacks preserve lifecycle ordering.
*/
private fireBlockStartCallback(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
executionOrder: number
): Promise<void> | undefined {
if (!this.contextExtensions.onBlockStart) return undefined
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
return this.contextExtensions
.onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext,
ctx.childWorkflowContext
)
.catch((error) => {
this.execLogger.warn('Block start callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
/**
* Fires the `onBlockComplete` progress callback without blocking subsequent blocks.
* Completion is chained behind the matching start callback so SSE/log consumers
* never observe `block:completed` before `block:started` for the same execution.
*/
private fireBlockCompleteCallback(
blockStartPromise: Promise<void> | undefined,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
input: Record<string, any>,
output: NormalizedBlockOutput,
duration: number,
startedAt: string,
executionOrder: number,
endedAt: string,
childWorkflowInstanceId?: string
): void {
if (!this.contextExtensions.onBlockComplete) return
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
void (async () => {
await blockStartPromise
await this.contextExtensions.onBlockComplete?.(
blockId,
blockName,
blockType,
{
input,
output,
executionTime: duration,
startedAt,
executionOrder,
endedAt,
childWorkflowInstanceId,
},
iterationContext,
ctx.childWorkflowContext
)
})().catch((error) => {
this.execLogger.warn('Block completion callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
private preparePauseResumeSelfReference(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
): (() => void) | undefined {
const blockId = node.id
const existingState = ctx.blockStates.get(blockId)
if (existingState?.executed) {
return undefined
}
const executionId = ctx.executionId ?? ctx.metadata?.executionId
const workflowId = ctx.workflowId
if (!executionId || !workflowId) {
return undefined
}
const { loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
let resumeLinks: { apiUrl: string; uiUrl: string }
try {
const baseUrl = getBaseUrl()
resumeLinks = {
apiUrl: buildResumeApiUrl(baseUrl, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(baseUrl, workflowId, executionId),
}
} catch {
resumeLinks = {
apiUrl: buildResumeApiUrl(undefined, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(undefined, workflowId, executionId),
}
}
let previousState: BlockState | undefined
if (existingState) {
previousState = { ...existingState }
}
const hadPrevious = existingState !== undefined
const placeholderState: BlockState = {
output: {
url: resumeLinks.uiUrl,
resumeEndpoint: resumeLinks.apiUrl,
},
executed: false,
executionTime: existingState?.executionTime ?? 0,
}
this.state.setBlockState(blockId, placeholderState)
return () => {
if (hadPrevious && previousState) {
this.state.setBlockState(blockId, previousState)
} else {
this.state.deleteBlockState(blockId)
}
}
}
private async handleStreamingExecution(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
streamingExec: StreamingExecution,
resolvedInputs: Record<string, any>,
selectedOutputs: string[]
): Promise<void> {
const blockId = node.id
const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled)
// Live-forward only when a client stream exists and PII redaction is off.
const forwardToClient = Boolean(ctx.onStream) && !piiEnabled
const responseFormat =
resolvedInputs?.responseFormat ??
(block.config?.params as Record<string, any> | undefined)?.responseFormat ??
(block.config as Record<string, any> | undefined)?.responseFormat
const streamFormat = streamingExec.streamFormat ?? 'text'
const pump = createAgentStreamPump({
source: streamingExec.stream,
streamFormat,
// No live consumer → sink-mode so we never buffer into an unread text stream.
sinkMode: !forwardToClient,
abortSignal: ctx.abortSignal,
})
let onStreamPromise: Promise<void> | undefined
let processedClientStream: ReadableStream<Uint8Array> | undefined
if (forwardToClient && ctx.onStream && pump.textStream) {
processedClientStream = streamingResponseFormatProcessor.processStream(
pump.textStream,
blockId,
selectedOutputs,
responseFormat
)
// Start onStream without awaiting so a sync `subscribe(sink)` can run before
// the first provider pull, then read the projected text stream concurrently
// with `pump.run()`.
onStreamPromise = ctx
.onStream({
...streamingExec,
stream: processedClientStream,
streamFormat: 'text',
subscribe: pump.subscribe,
// processStream returns the input stream identity when no
// response-format extraction applies.
clientStreamTransformed: processedClientStream !== pump.textStream,
})
.catch(async (error) => {
this.execLogger.error('Error in onStream callback', { blockId, error })
await processedClientStream?.cancel().catch(() => {})
})
}
let pumpResult
try {
pumpResult = await pump.run()
} catch (error) {
this.execLogger.error('Error reading stream for block', { blockId, error })
if (onStreamPromise) {
await onStreamPromise.catch(() => {})
}
throw error instanceof Error ? error : new Error(String(error))
}
if (onStreamPromise) {
await onStreamPromise
}
// Timeout still fails the block, but keep any drained answer text so logs
// match what was already projected to the client before the deadline.
// User Stop soft-completes below so logs don't show a scary red agent block
// for a routine cancel (workflow status remains `cancelled` via abort).
if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') {
const truncated = pumpResult.answerText
if (truncated && streamingExec.execution?.output) {
streamingExec.execution.output.content = truncated
}
this.execLogger.warn('Stream timed out; persisting drained answer before failing block', {
blockId,
hasContent: Boolean(truncated),
})
throw new DOMException('Provider request timed out', 'AbortError')
}
// Provider onComplete may have attached thinking to timing segments during drain.
// Under PII redaction, never retain raw thinking in traces.
if (piiEnabled) {
stripThinkingContentFromOutput(streamingExec.execution?.output)
}
// User/unknown cancel: persist truncated answer when present, then return.
if (pumpResult.cancelled) {
const truncated = pumpResult.answerText
if (truncated && streamingExec.execution?.output) {
streamingExec.execution.output.content = truncated
}
this.execLogger.info('Stream cancelled by client; soft-completing agent block', {
blockId,
cancelReason: pumpResult.cancelReason,
hasContent: Boolean(truncated),
})
return
}
// If the pump did not fully drain (should be rare when not cancelled), skip
// persistence of potentially truncated answer text.
if (!pumpResult.fullyDrained) {
this.execLogger.warn(
'Stream consumer exited before source drained; skipping content persistence',
{ blockId }
)
return
}
let fullContent = pumpResult.answerText
if (!fullContent) {
return
}
if (piiEnabled && ctx.piiBlockOutputRedaction) {
// Mask before writing to `execution.output` or `onFullContent`.
fullContent = await redactObjectStrings(fullContent, {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
customPatterns: ctx.piiBlockOutputRedaction.customPatterns,
onFailure: 'throw',
})
}
const executionOutput = streamingExec.execution?.output
if (executionOutput && typeof executionOutput === 'object') {
let parsedForFormat = false
if (responseFormat) {
try {
const parsed = JSON.parse(fullContent.trim())
streamingExec.execution.output = {
...parsed,
tokens: executionOutput.tokens,
toolCalls: executionOutput.toolCalls,
providerTiming: executionOutput.providerTiming,
cost: executionOutput.cost,
model: executionOutput.model,
}
parsedForFormat = true
} catch (error) {
this.execLogger.warn('Failed to parse streamed content for response format', {
blockId,
error,
})
}
}
if (!parsedForFormat) {
executionOutput.content = fullContent
}
}
if (streamingExec.onFullContent) {
try {
await streamingExec.onFullContent(fullContent)
} catch (error) {
this.execLogger.error('onFullContent callback failed', { blockId, error })
}
}
}
}
/** Removes retained thinking from provider timing segments (PII safe default). */
function stripThinkingContentFromOutput(output: unknown): void {
if (!output || typeof output !== 'object') return
const providerTiming = (output as { providerTiming?: { timeSegments?: unknown } }).providerTiming
const segments = providerTiming?.timeSegments
if (!Array.isArray(segments)) return
for (const segment of segments) {
if (segment && typeof segment === 'object' && 'thinkingContent' in segment) {
;(segment as { thinkingContent?: string }).thinkingContent = undefined
}
}
}