@@ -34,6 +34,11 @@ import type {
3434 ContextExtensions ,
3535 WorkflowNodeMetadata ,
3636} from '@/executor/execution/types'
37+ import {
38+ assertStructuredOutputNotTokenLimited ,
39+ parseResponseFormat ,
40+ StructuredOutputTokenLimitError ,
41+ } from '@/executor/handlers/shared/response-format'
3742import {
3843 generatePauseContextId ,
3944 mapNodeMetadataToPauseScopes ,
@@ -228,7 +233,7 @@ export class BlockExecutor {
228233 }
229234 cleanupSelfReference ?.( )
230235
231- let streamingPartialOutput : Record < string , any > | undefined
236+ let failureDiagnosticOutput : Record < string , any > | undefined
232237 try {
233238 /**
234239 * Only the handler call is retried. A streaming handler returns before any
@@ -274,7 +279,7 @@ export class BlockExecutor {
274279 blockCtx . resolvedSecretTraceRegistry = resultRegistry ?. forkForPropagatedEntries ( )
275280 // Timeout / drain failures may still have projected answer text — keep it
276281 // for the failed block output so logs match what the client already saw.
277- streamingPartialOutput = streamingExec . execution ?. output
282+ failureDiagnosticOutput = streamingExec . execution ?. output
278283 throw streamError
279284 }
280285
@@ -403,6 +408,9 @@ export class BlockExecutor {
403408 commitBlockRegistry ( )
404409 return stateOutput
405410 } catch ( error ) {
411+ if ( ! failureDiagnosticOutput && error instanceof StructuredOutputTokenLimitError ) {
412+ failureDiagnosticOutput = error . diagnosticOutput
413+ }
406414 try {
407415 return await this . handleBlockError (
408416 error ,
@@ -416,7 +424,7 @@ export class BlockExecutor {
416424 inputDisplayRegistry ,
417425 isSentinel ,
418426 'execution' ,
419- streamingPartialOutput
427+ failureDiagnosticOutput
420428 )
421429 } finally {
422430 commitBlockRegistry ( )
@@ -548,7 +556,7 @@ export class BlockExecutor {
548556 inputDisplayRegistry : ResolvedSecretTraceRegistry | undefined ,
549557 isSentinel : boolean ,
550558 phase : 'input_resolution' | 'execution' ,
551- streamingPartialOutput ?: Record < string , any >
559+ failureDiagnosticOutput ?: Record < string , any >
552560 ) : Promise < NormalizedBlockOutput > {
553561 const endedAt = new Date ( ) . toISOString ( )
554562 const duration = performance . now ( ) - startTime
@@ -624,12 +632,25 @@ export class BlockExecutor {
624632 error : errorMessage ,
625633 }
626634
627- // Keep any answer text already drained before timeout/failure so logs match
628- // what was projected to the client.
629- const partialContent = streamingPartialOutput ?. content
630- if ( typeof partialContent === 'string' && partialContent ) {
631- errorOutput . content = partialContent
635+ // Retain completed provider diagnostics for observability and billing while
636+ // keeping the block failed so normal downstream execution cannot consume it.
637+ let providerDiagnostics : Record < string , unknown > = { }
638+ for ( const key of [ 'content' , 'model' , 'tokens' , 'toolCalls' , 'providerTiming' , 'cost' ] ) {
639+ const value = failureDiagnosticOutput ?. [ key ]
640+ if ( value !== undefined && ( key !== 'content' || value !== '' ) ) {
641+ providerDiagnostics [ key ] = value
642+ }
632643 }
644+ if ( ctx . piiBlockOutputRedaction ?. enabled && Object . keys ( providerDiagnostics ) . length > 0 ) {
645+ stripThinkingContentFromOutput ( providerDiagnostics )
646+ providerDiagnostics = await redactObjectStrings ( providerDiagnostics , {
647+ entityTypes : ctx . piiBlockOutputRedaction . entityTypes ,
648+ language : ctx . piiBlockOutputRedaction . language ,
649+ customPatterns : ctx . piiBlockOutputRedaction . customPatterns ,
650+ onFailure : 'throw' ,
651+ } )
652+ }
653+ Object . assign ( errorOutput , providerDiagnostics )
633654
634655 // Only real workflow blocks surface a child workflow name. A custom block's
635656 // source workflow is never named to its consumer — and before the handler
@@ -1106,6 +1127,7 @@ export class BlockExecutor {
11061127 resolvedInputs ?. responseFormat ??
11071128 ( block . config ?. params as Record < string , any > | undefined ) ?. responseFormat ??
11081129 ( block . config as Record < string , any > | undefined ) ?. responseFormat
1130+ const parsedResponseFormat = parseResponseFormat ( responseFormat )
11091131
11101132 const streamFormat = streamingExec . streamFormat ?? 'text'
11111133 const pump = createAgentStreamPump ( {
@@ -1238,6 +1260,12 @@ export class BlockExecutor {
12381260 if ( executionOutput && typeof executionOutput === 'object' ) {
12391261 let parsedForFormat = false
12401262 if ( responseFormat ) {
1263+ // Retain the drained text for failed-block diagnostics, but reject it
1264+ // before parsing so truncated structured data never reaches downstream.
1265+ executionOutput . content = fullContent
1266+ if ( parsedResponseFormat ) {
1267+ assertStructuredOutputNotTokenLimited ( executionOutput . providerTiming , executionOutput )
1268+ }
12411269 try {
12421270 const parsed = JSON . parse ( fullContent . trim ( ) )
12431271 streamingExec . execution . output = {
0 commit comments