mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
d24bc7eccb
* 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>
1016 lines
33 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|