@falai/agent 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cjs/core/FlowRouter.d.ts.map +1 -1
  2. package/dist/cjs/core/FlowRouter.js +0 -11
  3. package/dist/cjs/core/FlowRouter.js.map +1 -1
  4. package/dist/cjs/core/ResponseModal.d.ts +51 -2
  5. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  6. package/dist/cjs/core/ResponseModal.js +272 -269
  7. package/dist/cjs/core/ResponseModal.js.map +1 -1
  8. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  9. package/dist/cjs/providers/GeminiProvider.js +3 -1
  10. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  11. package/dist/cjs/utils/streamingMessage.d.ts +48 -0
  12. package/dist/cjs/utils/streamingMessage.d.ts.map +1 -0
  13. package/dist/cjs/utils/streamingMessage.js +210 -0
  14. package/dist/cjs/utils/streamingMessage.js.map +1 -0
  15. package/dist/core/FlowRouter.d.ts.map +1 -1
  16. package/dist/core/FlowRouter.js +0 -11
  17. package/dist/core/FlowRouter.js.map +1 -1
  18. package/dist/core/ResponseModal.d.ts +51 -2
  19. package/dist/core/ResponseModal.d.ts.map +1 -1
  20. package/dist/core/ResponseModal.js +272 -269
  21. package/dist/core/ResponseModal.js.map +1 -1
  22. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  23. package/dist/providers/GeminiProvider.js +3 -1
  24. package/dist/providers/GeminiProvider.js.map +1 -1
  25. package/dist/utils/streamingMessage.d.ts +48 -0
  26. package/dist/utils/streamingMessage.d.ts.map +1 -0
  27. package/dist/utils/streamingMessage.js +205 -0
  28. package/dist/utils/streamingMessage.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/core/FlowRouter.ts +0 -14
  31. package/src/core/ResponseModal.ts +332 -299
  32. package/src/providers/GeminiProvider.ts +4 -2
  33. package/src/utils/streamingMessage.ts +220 -0
@@ -12,6 +12,7 @@ import { SignalCoordinator } from "./SignalCoordinator";
12
12
  import { ResponseGenerationError } from "./ResponseGenerationError";
13
13
  import { cloneDeep, mergeCollected, logger, historyToEvents, completeCurrentFlow, render } from "../utils";
14
14
  import { createTemplateContext } from "../utils/template";
15
+ import { StreamingMessageDecoder } from "../utils/streamingMessage";
15
16
  /**
16
17
  * ResponseModal class that encapsulates all response generation logic
17
18
  * Uses unified approach for both streaming and non-streaming responses
@@ -335,46 +336,40 @@ export class ResponseModal {
335
336
  }
336
337
  }
337
338
  /**
338
- * Unified response generation for non-streaming responses
339
+ * Plan a turn: run signal-halt detection, the auto-chain walk, and flow/step
340
+ * selection, collapsing them into a single {@link TurnOutcome}. This is the
341
+ * shared decision spine for both the streaming and non-streaming paths — the
342
+ * only logic that genuinely differs between them is how each *renders* the
343
+ * outcome (await a value vs. yield chunks) and the leaf provider primitive it
344
+ * uses. Centralizing the decision here is what keeps the two paths from
345
+ * drifting (the class of bug behind the 2.4.x retry/empty fixes).
346
+ *
347
+ * The returned `session` reflects any auto-chain mutation; `signalFirings`
348
+ * is seeded with the pre-signal phase firings and is the live accumulator the
349
+ * post-phase tail appends to.
339
350
  * @private
340
351
  */
341
- async generateUnifiedResponse(responseContext) {
342
- const { effectiveContext, session: initialSession, history, selectedFlow, selectedStep, responseDirectives, isFlowComplete, signal, signalFirings: preSignalFirings, signalPreDirective, signalHalted, signalHaltReply, } = responseContext;
343
- let session = initialSession;
352
+ async planTurn(responseContext) {
353
+ const { effectiveContext, history, selectedFlow, selectedStep, responseDirectives, isFlowComplete, signal, signalFirings: preSignalFirings, signalPreDirective, signalHalted, signalHaltReply, } = responseContext;
354
+ let session = responseContext.session;
344
355
  // Accumulator for signal firings across both phases (fire order)
345
356
  const signalFirings = [...(preSignalFirings || [])];
346
- // Get last user message (needed for both flow and completion handling)
347
357
  // Convert HistoryItem[] to Event[] for internal processing
348
358
  const historyEvents = historyToEvents(history);
359
+ const base = { effectiveContext, history, historyEvents, signal, signalFirings };
349
360
  // ── SIGNAL HALT (Requirement 8.2) ─────────────────────────────────────
350
- // Pre-signal phase emitted halt → skip LLM call entirely.
361
+ // Pre-signal phase emitted halt → skip LLM call entirely. The post-signal
362
+ // phase still runs (it sees the complete turn context).
351
363
  if (signalHalted) {
352
364
  const haltMessage = signalHaltReply || '';
353
- // Run post-signal phase even on halt (post-phase sees complete turn context)
354
- const post = await this.signalCoordinator.applyPostPhase({
355
- session, context: effectiveContext, historyEvents, message: haltMessage,
356
- });
357
- session = post.session;
358
- signalFirings.push(...post.firings);
359
- const message = post.message;
360
365
  return {
361
- message,
362
- session,
363
- toolCalls: undefined,
364
- isFlowComplete: false,
365
- executedSteps: [],
366
- stoppedReason: haltMessage ? 'reply' : 'halt',
367
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
366
+ ...base, session,
367
+ outcome: { kind: 'halt', message: haltMessage, stoppedReason: haltMessage ? 'reply' : 'halt', runPostPhase: true },
368
368
  };
369
369
  }
370
- let message;
371
- let toolCalls = undefined;
372
- let executedSteps;
373
- let stoppedReason;
374
- let appliedInstructions;
375
370
  if (selectedFlow && !isFlowComplete) {
376
- // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work.
377
- // If the current step is auto, the executor advances through it (and any
371
+ // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work. If the
372
+ // current step is auto, the executor advances through it (and any
378
373
  // subsequent auto-steps) until an interactive step or terminal condition.
379
374
  let resolvedStep = selectedStep;
380
375
  const currentStepInstance = session.currentStep
@@ -390,132 +385,159 @@ export class ResponseModal {
390
385
  flow: selectedFlow,
391
386
  });
392
387
  session = autoResult.session;
393
- // Handle halt: emit verbatim reply, return — no LLM call.
394
- // respond() finalizes the returned session exactly once.
388
+ // Halt: emit the verbatim reply, no LLM call. Unlike signal halt,
389
+ // the auto-chain halt is a hard short-circuit that does NOT run the
390
+ // post-signal phase (preserved across both paths).
395
391
  if (autoResult.stoppedReason === 'halt') {
396
- message = autoResult.mergedDirective?.reply || '';
397
- stoppedReason = 'halt';
398
- executedSteps = [];
399
392
  return {
400
- message,
401
- session,
402
- toolCalls: undefined,
403
- isFlowComplete: false,
404
- executedSteps,
405
- stoppedReason,
393
+ ...base, session,
394
+ outcome: { kind: 'halt', message: autoResult.mergedDirective?.reply || '', stoppedReason: 'halt', runPostPhase: false },
406
395
  };
407
396
  }
408
- // Handle flow completion or cross-flow redirect from auto-chain.
409
- // The auto-chain ended without resolving to an interactive step.
410
- // Possible reasons: last_step (no successor), completed (explicit
411
- // complete directive), or goto (cross-flow redirect).
397
+ // Flow completion or cross-flow redirect from auto-chain: the chain
398
+ // ended without resolving to an interactive step (last_step: no
399
+ // successor; completed: explicit complete; goto: cross-flow redirect).
412
400
  if (autoResult.stoppedReason === 'last_step' || autoResult.stoppedReason === 'completed' || autoResult.stoppedReason === 'goto') {
413
401
  logger.debug(`[ResponseModal] Auto-chain ended with ${autoResult.stoppedReason}`);
414
- session = await this.applyFlowCompletion({
415
- selectedFlow,
416
- session,
417
- context: effectiveContext,
418
- history,
419
- });
420
402
  return {
421
- message: '',
422
- session,
423
- toolCalls: undefined,
424
- isFlowComplete: true,
425
- executedSteps: [],
426
- stoppedReason: autoResult.stoppedReason,
403
+ ...base, session,
404
+ outcome: { kind: 'flowComplete', selectedFlow, stoppedReason: autoResult.stoppedReason },
427
405
  };
428
406
  }
429
407
  // Normal case: auto-chain resolved to an interactive step.
430
408
  resolvedStep = autoResult.resolvedStep;
431
409
  }
432
- // SINGLE STEP EXECUTION: Process the resolved interactive step.
433
- // The auto-chain (if it ran) already walked auto-steps. Only the
434
- // interactive step remains for the LLM call.
435
- const result = await this.processFlowResponse({
436
- selectedFlow,
437
- selectedStep: resolvedStep,
438
- responseDirectives,
439
- session,
440
- history,
441
- context: effectiveContext,
442
- historyEvents,
443
- signal,
444
- // Propagate signal pre-directive's appendPrompt for this turn's LLM call (Requirement 8.4)
445
- transientAppendage: signalPreDirective?.appendPrompt,
446
- // Merge signal pre-directive (halt/reply/injectTools) into the pre-LLM bus
447
- mergedPreDirective: signalPreDirective,
448
- });
449
- message = result.message;
450
- toolCalls = result.toolCalls;
451
- session = result.session;
452
- appliedInstructions = result.appliedInstructions;
453
- // Track executed step for single-step execution
454
- if (resolvedStep) {
455
- executedSteps = [{
456
- id: resolvedStep.id,
457
- flowId: selectedFlow.id,
458
- }];
459
- }
460
- // Use stoppedReason from processFlowResponse if set (halt/reply),
461
- // otherwise default to 'needs_input' for normal LLM responses.
462
- stoppedReason = result.stoppedReason || 'needs_input';
410
+ return {
411
+ ...base, session,
412
+ outcome: { kind: 'flowStep', selectedFlow, step: resolvedStep, responseDirectives, signalPreDirective },
413
+ };
463
414
  }
464
- else if (isFlowComplete && selectedFlow) {
465
- // Flow completion path: pure state transition, no LLM call.
466
- // The framework emits no message of its own.
467
- // stoppedReason is 'last_step' because this completion was detected by
468
- // implicit terminus (no successor or all successors skipped), not by an
469
- // explicit `complete` directive.
415
+ if (isFlowComplete && selectedFlow) {
416
+ // Flow completion path: pure state transition, no LLM call. The reason
417
+ // is 'last_step' (implicit terminus — no successor or all skipped).
470
418
  logger.debug(`[ResponseModal] Releasing session to idle for completed flow: ${selectedFlow.title}`);
471
- session = await this.applyFlowCompletion({
472
- selectedFlow,
473
- session,
474
- context: effectiveContext,
475
- history,
476
- });
477
- message = '';
478
- stoppedReason = 'last_step';
479
- executedSteps = [];
419
+ return {
420
+ ...base, session,
421
+ outcome: { kind: 'flowComplete', selectedFlow, stoppedReason: 'last_step' },
422
+ };
480
423
  }
481
- else {
482
- // Fallback: No flows defined, generate a simple response
483
- const fallbackResult = await this.generateFallbackResponse({
484
- history,
485
- context: effectiveContext,
486
- session,
487
- });
488
- message = fallbackResult.message;
489
- appliedInstructions = fallbackResult.appliedInstructions;
490
- // For fallback responses, set empty executedSteps and no stoppedReason
491
- // since there's no flow/step execution happening
492
- executedSteps = [];
493
- stoppedReason = undefined;
424
+ // Fallback: no flows defined, generate a simple response.
425
+ return { ...base, session, outcome: { kind: 'fallback' } };
426
+ }
427
+ /**
428
+ * The shared post-signal phase tail (Requirement 9.1–9.4). Runs after the
429
+ * turn's message is known and before persistence, so post-phase signals see
430
+ * the complete turn result (assistant message, collected data, tool results)
431
+ * and can override the reply or wire a pendingDirective.
432
+ *
433
+ * `runPostPhase` is false only for the auto-chain halt short-circuit, which
434
+ * deliberately bypasses the post-phase in both paths; that branch still
435
+ * surfaces any pre-phase firings via `triggeredSignals`.
436
+ * @private
437
+ */
438
+ async applyTurnPostPhase(params) {
439
+ const { session, context, historyEvents, message, signalFirings, runPostPhase } = params;
440
+ if (!runPostPhase) {
441
+ return {
442
+ session, message, replyOverridden: false,
443
+ triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
444
+ };
494
445
  }
495
- // POST-SIGNAL PHASE (Requirement 9.1, 9.2, 9.3, 9.4)
496
- // Runs after finalize/onComplete and before session persistence.
497
- // Post-phase signals see the complete turn result: assistant message in
498
- // history, collected data, tool results.
499
- const post = await this.signalCoordinator.applyPostPhase({
500
- session, context: effectiveContext, historyEvents, message,
501
- });
502
- session = post.session;
503
- // Append post-phase firings to the accumulator (preserves fire order)
446
+ const post = await this.signalCoordinator.applyPostPhase({ session, context, historyEvents, message });
504
447
  signalFirings.push(...post.firings);
505
- message = post.message;
506
- // Ensure response structure completeness (Requirement 8.1, 8.2, 8.3)
507
- // - executedSteps: array of steps executed (empty array if none)
508
- // - stoppedReason: why execution stopped (undefined for fallback)
509
- // - session.currentStep: reflects final step position
510
448
  return {
511
- message,
512
- session,
449
+ session: post.session,
450
+ message: post.message,
451
+ replyOverridden: post.replyOverridden ?? false,
452
+ triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
453
+ };
454
+ }
455
+ /**
456
+ * Unified response generation for non-streaming responses.
457
+ * Renders the shared {@link planTurn} outcome by awaiting the leaf primitive
458
+ * and running the shared post-phase tail; respond() owns the single finalize.
459
+ * @private
460
+ */
461
+ async generateUnifiedResponse(responseContext) {
462
+ const plan = await this.planTurn(responseContext);
463
+ const { effectiveContext, history, historyEvents, signal, signalFirings } = plan;
464
+ let session = plan.session;
465
+ let message = '';
466
+ let toolCalls = undefined;
467
+ let executedSteps = [];
468
+ let stoppedReason;
469
+ let isFlowComplete = false;
470
+ let appliedInstructions;
471
+ let runPostPhase = true;
472
+ switch (plan.outcome.kind) {
473
+ case 'halt': {
474
+ message = plan.outcome.message;
475
+ stoppedReason = plan.outcome.stoppedReason;
476
+ runPostPhase = plan.outcome.runPostPhase;
477
+ break;
478
+ }
479
+ case 'flowComplete': {
480
+ session = await this.applyFlowCompletion({
481
+ selectedFlow: plan.outcome.selectedFlow,
482
+ session,
483
+ context: effectiveContext,
484
+ history,
485
+ });
486
+ isFlowComplete = true;
487
+ stoppedReason = plan.outcome.stoppedReason;
488
+ break;
489
+ }
490
+ case 'flowStep': {
491
+ const result = await this.processFlowResponse({
492
+ selectedFlow: plan.outcome.selectedFlow,
493
+ selectedStep: plan.outcome.step,
494
+ responseDirectives: plan.outcome.responseDirectives,
495
+ session,
496
+ history,
497
+ context: effectiveContext,
498
+ historyEvents,
499
+ signal,
500
+ // Propagate signal pre-directive's appendPrompt for this turn's LLM call (Requirement 8.4)
501
+ transientAppendage: plan.outcome.signalPreDirective?.appendPrompt,
502
+ // Merge signal pre-directive (halt/reply/injectTools) into the pre-LLM bus
503
+ mergedPreDirective: plan.outcome.signalPreDirective,
504
+ });
505
+ message = result.message;
506
+ toolCalls = result.toolCalls;
507
+ session = result.session;
508
+ appliedInstructions = result.appliedInstructions;
509
+ if (plan.outcome.step) {
510
+ executedSteps = [{ id: plan.outcome.step.id, flowId: plan.outcome.selectedFlow.id }];
511
+ }
512
+ // Use stoppedReason from processFlowResponse if set (halt/reply),
513
+ // otherwise default to 'needs_input' for normal LLM responses.
514
+ stoppedReason = result.stoppedReason || 'needs_input';
515
+ break;
516
+ }
517
+ case 'fallback': {
518
+ const fallbackResult = await this.generateFallbackResponse({
519
+ history,
520
+ context: effectiveContext,
521
+ session,
522
+ signal,
523
+ });
524
+ message = fallbackResult.message;
525
+ appliedInstructions = fallbackResult.appliedInstructions;
526
+ break;
527
+ }
528
+ }
529
+ const tail = await this.applyTurnPostPhase({
530
+ session, context: effectiveContext, historyEvents, message, signalFirings, runPostPhase,
531
+ });
532
+ return {
533
+ message: tail.message,
534
+ session: tail.session,
513
535
  toolCalls,
514
- isFlowComplete: isFlowComplete,
515
- executedSteps: executedSteps || [],
536
+ isFlowComplete,
537
+ executedSteps,
516
538
  stoppedReason,
517
539
  appliedInstructions,
518
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
540
+ triggeredSignals: tail.triggeredSignals,
519
541
  };
520
542
  }
521
543
  /**
@@ -643,162 +665,90 @@ export class ResponseModal {
643
665
  }
644
666
  }
645
667
  /**
646
- * Unified streaming response generation
668
+ * Unified streaming response generation.
669
+ * Renders the shared {@link planTurn} outcome as a chunk stream and runs the
670
+ * shared post-phase tail on the final chunk (finalizing exactly once).
647
671
  * @private
648
672
  */
649
673
  async *generateUnifiedStreamingResponse(responseContext) {
650
- const { effectiveContext, session: initialSession, history, selectedFlow, selectedStep, responseDirectives, isFlowComplete, signal, signalFirings: preSignalFirings, signalPreDirective, signalHalted, signalHaltReply, } = responseContext;
651
- let session = initialSession;
652
- // Accumulator for signal firings across both phases (fire order)
653
- const signalFirings = [...(preSignalFirings || [])];
654
- // Convert HistoryItem[] to Event[] for internal processing
655
- const historyEvents = historyToEvents(history);
656
- // ── SIGNAL HALT (Requirement 8.2) ─────────────────────────────────────
657
- if (signalHalted) {
658
- const haltMessage = signalHaltReply || '';
659
- // Run post-signal phase even on halt
660
- const post = await this.signalCoordinator.applyPostPhase({
661
- session, context: effectiveContext, historyEvents, message: haltMessage,
662
- });
663
- session = post.session;
664
- signalFirings.push(...post.firings);
665
- const message = post.message;
666
- await this.sessionFinalizer.finalize(session, effectiveContext);
667
- yield {
668
- delta: message,
669
- accumulated: message,
670
- done: true,
671
- session,
672
- stoppedReason: haltMessage ? 'reply' : 'halt',
673
- executedSteps: [],
674
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
675
- };
676
- return;
677
- }
678
- // ── Determine the inner stream generator based on flow state ────────
674
+ const plan = await this.planTurn(responseContext);
675
+ const { effectiveContext, history, historyEvents, signal, signalFirings } = plan;
676
+ const session = plan.session;
677
+ // Build the inner chunk stream for the planned outcome. `runPostPhase` is
678
+ // the single post-phase gate (false only for auto-chain halt).
679
679
  let innerStream;
680
- if (selectedFlow && !isFlowComplete) {
681
- // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work (streaming path).
682
- let resolvedStep = selectedStep;
683
- const currentStepInstance = session.currentStep
684
- ? selectedFlow.getStep(session.currentStep.id)
685
- : selectedStep;
686
- if (currentStepInstance?.auto) {
687
- const autoChainExecutor = new AutoChainExecutor({
688
- maxAutoStepsPerTurn: this.agent.maxAutoStepsPerTurn,
680
+ let runPostPhase = true;
681
+ switch (plan.outcome.kind) {
682
+ case 'halt': {
683
+ runPostPhase = plan.outcome.runPostPhase;
684
+ innerStream = this.streamTerminalMessage({
685
+ message: plan.outcome.message,
686
+ stoppedReason: plan.outcome.stoppedReason,
687
+ session,
689
688
  });
690
- const autoResult = await autoChainExecutor.run({
689
+ break;
690
+ }
691
+ case 'flowComplete': {
692
+ innerStream = this.streamFlowCompletion({
693
+ selectedFlow: plan.outcome.selectedFlow,
691
694
  session,
692
695
  context: effectiveContext,
693
- flow: selectedFlow,
696
+ history,
697
+ historyEvents,
698
+ stoppedReason: plan.outcome.stoppedReason,
694
699
  });
695
- session = autoResult.session;
696
- // Handle halt: emit verbatim reply as a single chunk, done.
697
- if (autoResult.stoppedReason === 'halt') {
698
- const reply = autoResult.mergedDirective?.reply || '';
699
- await this.sessionFinalizer.finalize(session, effectiveContext);
700
- yield {
701
- delta: reply,
702
- accumulated: reply,
703
- done: true,
704
- session,
705
- stoppedReason: 'halt',
706
- executedSteps: [],
707
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
708
- };
709
- return;
710
- }
711
- // Handle flow completion or cross-flow redirect from auto-chain.
712
- if (autoResult.stoppedReason === 'last_step' || autoResult.stoppedReason === 'completed' || autoResult.stoppedReason === 'goto') {
713
- innerStream = this.streamFlowCompletion({
714
- selectedFlow,
715
- session,
716
- context: effectiveContext,
717
- history,
718
- historyEvents,
719
- stoppedReason: autoResult.stoppedReason,
720
- });
721
- }
722
- else {
723
- // Normal case: resolved to an interactive step.
724
- resolvedStep = autoResult.resolvedStep;
725
- innerStream = this.processFlowStreamingResponse({
726
- selectedFlow,
727
- selectedStep: resolvedStep,
728
- responseDirectives,
729
- session,
730
- history,
731
- context: effectiveContext,
732
- historyEvents,
733
- signal,
734
- transientAppendage: signalPreDirective?.appendPrompt,
735
- mergedPreDirective: signalPreDirective,
736
- });
737
- }
700
+ break;
738
701
  }
739
- else {
740
- // No auto-step: directly stream the interactive step.
702
+ case 'flowStep': {
741
703
  innerStream = this.processFlowStreamingResponse({
742
- selectedFlow,
743
- selectedStep: resolvedStep,
744
- responseDirectives,
704
+ selectedFlow: plan.outcome.selectedFlow,
705
+ selectedStep: plan.outcome.step,
706
+ responseDirectives: plan.outcome.responseDirectives,
745
707
  session,
746
708
  history,
747
709
  context: effectiveContext,
748
710
  historyEvents,
749
711
  signal,
750
- // Propagate signal pre-directive's appendPrompt for this turn's LLM call
751
- transientAppendage: signalPreDirective?.appendPrompt,
752
- mergedPreDirective: signalPreDirective,
712
+ transientAppendage: plan.outcome.signalPreDirective?.appendPrompt,
713
+ mergedPreDirective: plan.outcome.signalPreDirective,
753
714
  });
715
+ break;
716
+ }
717
+ case 'fallback': {
718
+ innerStream = this.streamFallbackResponse({
719
+ history,
720
+ context: effectiveContext,
721
+ session,
722
+ signal,
723
+ });
724
+ break;
754
725
  }
755
- }
756
- else if (isFlowComplete && selectedFlow) {
757
- // Handle flow completion streaming — implicit terminus (no successor
758
- // or all successors skipped), so the reason is 'last_step'.
759
- innerStream = this.streamFlowCompletion({
760
- selectedFlow,
761
- session,
762
- context: effectiveContext,
763
- history,
764
- historyEvents,
765
- stoppedReason: 'last_step',
766
- });
767
- }
768
- else {
769
- // Fallback: No flows defined, stream a simple response
770
- innerStream = this.streamFallbackResponse({
771
- history,
772
- context: effectiveContext,
773
- session,
774
- });
775
726
  }
776
727
  // ── Intercept the inner stream on the final chunk ──────────────────────
777
- // Mirrors the non-streaming path: post-signal phase runs first, then the
778
- // session (including post-phase mutations) is finalized exactly once,
779
- // attaching triggeredSignals to the final chunk (Requirement 11.2).
728
+ // Mirrors the non-streaming tail: post-signal phase runs first (when
729
+ // applicable), then the session is finalized exactly once, attaching
730
+ // triggeredSignals to the final chunk (Requirement 11.2).
780
731
  for await (const chunk of innerStream) {
781
732
  if (chunk.done) {
782
- // Run post-signal phase on final chunk (Requirement 9.1, 9.2)
783
- const post = await this.signalCoordinator.applyPostPhase({
733
+ const tail = await this.applyTurnPostPhase({
784
734
  session: chunk.session || session,
785
735
  context: effectiveContext,
786
736
  historyEvents,
787
737
  message: chunk.accumulated,
738
+ signalFirings,
739
+ runPostPhase,
788
740
  });
789
- const finalSession = post.session;
790
- signalFirings.push(...post.firings);
791
- const accumulated = post.message;
792
- const delta = post.replyOverridden ? accumulated : chunk.delta;
741
+ const accumulated = tail.message;
742
+ const delta = tail.replyOverridden ? accumulated : chunk.delta;
793
743
  // Single streaming exit: finalize the post-phase session so
794
- // post-signal mutations (e.g. pendingDirective) are persisted
795
- await this.sessionFinalizer.finalize(finalSession, effectiveContext);
744
+ // post-signal mutations (e.g. pendingDirective) are persisted.
745
+ await this.sessionFinalizer.finalize(tail.session, effectiveContext);
796
746
  yield {
797
747
  ...chunk,
798
748
  delta,
799
749
  accumulated,
800
- session: finalSession,
801
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
750
+ session: tail.session,
751
+ triggeredSignals: tail.triggeredSignals,
802
752
  };
803
753
  }
804
754
  else {
@@ -806,6 +756,42 @@ export class ResponseModal {
806
756
  }
807
757
  }
808
758
  }
759
+ /**
760
+ * Emit a framework-authored message (a halt reply) as a single terminal
761
+ * chunk, to flow through the shared post-phase tail like any other inner
762
+ * stream. No LLM call, no provider text — so nothing to extract or finalize
763
+ * here; the caller's tail owns post-phase + finalize.
764
+ * @private
765
+ */
766
+ // eslint-disable-next-line @typescript-eslint/require-await -- yield-only async generator; must be `async *` to satisfy the AsyncGenerator return type the caller switches on
767
+ async *streamTerminalMessage(params) {
768
+ yield {
769
+ delta: params.message,
770
+ accumulated: params.message,
771
+ done: true,
772
+ session: params.session,
773
+ toolCalls: undefined,
774
+ isFlowComplete: false,
775
+ stoppedReason: params.stoppedReason,
776
+ executedSteps: [],
777
+ };
778
+ }
779
+ /**
780
+ * Wrap a provider message stream so each chunk's `delta`/`accumulated` carry
781
+ * clean message text instead of the raw structured-JSON wrapper. The single
782
+ * point where streamed JSON is unwrapped — every streaming response variant
783
+ * (flow step, fallback) consumes provider chunks through here, so consumers
784
+ * and stored history never see `{"message":...}` fragments. `structured`,
785
+ * `done`, and `metadata` pass through untouched.
786
+ * @private
787
+ */
788
+ async *decodeMessageStream(stream) {
789
+ const decoder = new StreamingMessageDecoder();
790
+ for await (const chunk of stream) {
791
+ const clean = decoder.push(chunk.accumulated);
792
+ yield { ...chunk, delta: clean.delta, accumulated: clean.message };
793
+ }
794
+ }
809
795
  /**
810
796
  * Process flow streaming response with unified tool execution and data collection
811
797
  * @private
@@ -900,18 +886,24 @@ export class ResponseModal {
900
886
  signal,
901
887
  parameters: { jsonSchema: responseSchema, schemaName: "response_stream_output" },
902
888
  });
903
- // Stream chunks with unified tool handling
904
- for await (const chunk of stream) {
889
+ // Stream chunks with unified tool handling. decodeMessageStream gives
890
+ // each chunk clean message text in delta/accumulated, so the non-done
891
+ // deltas, the final accumulated, the post-phase message input, and the
892
+ // assistant message stored by stream() are all clean — never the raw
893
+ // JSON wrapper (matching the non-streaming structured.message extraction).
894
+ for await (const chunk of this.decodeMessageStream(stream)) {
905
895
  let toolCalls = undefined;
906
896
  // Final message/structured may be replaced by a forced post-tool
907
897
  // response (see runStreamingBatch / gap: tools-ran-but-no-text).
898
+ let finalDelta = chunk.delta;
908
899
  let finalAccumulated = chunk.accumulated;
909
900
  let finalStructured = chunk.structured;
910
901
  // Extract tool calls from AI response on final chunk
911
902
  if (chunk.done && chunk.structured?.toolCalls) {
912
903
  toolCalls = chunk.structured.toolCalls;
913
904
  // Concurrent execution for the initial batch of tool calls,
914
- // yielding tool-progress chunks as they arrive
905
+ // yielding tool-progress chunks as they arrive. The accumulated
906
+ // preamble is already clean text.
915
907
  const batchResult = yield* this.toolLoopExecutor.runStreamingBatch({
916
908
  toolCalls,
917
909
  context,
@@ -927,19 +919,29 @@ export class ResponseModal {
927
919
  });
928
920
  session = batchResult.session;
929
921
  toolCalls = batchResult.toolCalls;
922
+ // Prefer the post-tool follow-up structured for collection and
923
+ // emission whenever present — independent of whether a closing
924
+ // message was forced — matching the non-streaming path's
925
+ // `toolResult.structured ?? result` selection.
926
+ finalStructured = batchResult.structured ?? finalStructured;
930
927
  // Tools ran but the model produced no result-aware text — use
931
- // the forced closing message so we never emit the bare
932
- // preamble (or an empty message) as the final response.
928
+ // the forced closing message (already clean) so we never emit the
929
+ // bare preamble (or an empty message) as the final response. Its
930
+ // delta is the portion not already streamed as the preamble.
933
931
  if (batchResult.finalMessage) {
934
932
  finalAccumulated = batchResult.finalMessage;
935
- finalStructured = batchResult.structured ?? finalStructured;
933
+ finalDelta = batchResult.finalMessage.startsWith(chunk.accumulated)
934
+ ? batchResult.finalMessage.slice(chunk.accumulated.length)
935
+ : batchResult.finalMessage;
936
936
  }
937
937
  }
938
- // Extract collected data on final chunk (from the model's own
939
- // structured output for this step, not the forced follow-up)
940
- if (chunk.done && chunk.structured && nextStep.collect) {
938
+ // Collect data on the final chunk for any flow step — flow
939
+ // required/optional fields are valid targets even without a step
940
+ // `collect` preferring the post-tool follow-up structured so a
941
+ // tool-driven turn harvests fields the model produced after tools.
942
+ if (chunk.done && finalStructured) {
941
943
  session = await this.collectDataFromResponse({
942
- result: { structured: chunk.structured },
944
+ result: { structured: finalStructured },
943
945
  selectedFlow,
944
946
  nextStep,
945
947
  session,
@@ -950,7 +952,7 @@ export class ResponseModal {
950
952
  // - stoppedReason: 'needs_input' for single-step execution (waiting for user input)
951
953
  // - session.currentStep: reflects the executed step
952
954
  yield {
953
- delta: chunk.delta,
955
+ delta: finalDelta,
954
956
  accumulated: finalAccumulated,
955
957
  done: chunk.done,
956
958
  session,
@@ -1211,7 +1213,8 @@ export class ResponseModal {
1211
1213
  schemaName: "fallback_stream_response",
1212
1214
  },
1213
1215
  });
1214
- for await (const chunk of stream) {
1216
+ // Decode the JSON wrapper to clean message text (same as the flow path).
1217
+ for await (const chunk of this.decodeMessageStream(stream)) {
1215
1218
  // Response structure completeness (Requirement 8.1, 8.2, 8.3)
1216
1219
  // - executedSteps: empty for fallback (no flow/step execution)
1217
1220
  // - stoppedReason: undefined for fallback (no flow context)