@falai/agent 2.5.0 → 2.6.1

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 (51) hide show
  1. package/dist/cjs/core/Agent.d.ts.map +1 -1
  2. package/dist/cjs/core/Agent.js +3 -1
  3. package/dist/cjs/core/Agent.js.map +1 -1
  4. package/dist/cjs/core/FlowRouter.d.ts.map +1 -1
  5. package/dist/cjs/core/FlowRouter.js +0 -11
  6. package/dist/cjs/core/FlowRouter.js.map +1 -1
  7. package/dist/cjs/core/ResponseModal.d.ts +51 -2
  8. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  9. package/dist/cjs/core/ResponseModal.js +272 -269
  10. package/dist/cjs/core/ResponseModal.js.map +1 -1
  11. package/dist/cjs/core/ToolLoopExecutor.js +1 -1
  12. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  13. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  14. package/dist/cjs/providers/GeminiProvider.js +3 -1
  15. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  16. package/dist/cjs/types/agent.d.ts +9 -0
  17. package/dist/cjs/types/agent.d.ts.map +1 -1
  18. package/dist/cjs/utils/streamingMessage.d.ts +48 -0
  19. package/dist/cjs/utils/streamingMessage.d.ts.map +1 -0
  20. package/dist/cjs/utils/streamingMessage.js +210 -0
  21. package/dist/cjs/utils/streamingMessage.js.map +1 -0
  22. package/dist/core/Agent.d.ts.map +1 -1
  23. package/dist/core/Agent.js +3 -1
  24. package/dist/core/Agent.js.map +1 -1
  25. package/dist/core/FlowRouter.d.ts.map +1 -1
  26. package/dist/core/FlowRouter.js +0 -11
  27. package/dist/core/FlowRouter.js.map +1 -1
  28. package/dist/core/ResponseModal.d.ts +51 -2
  29. package/dist/core/ResponseModal.d.ts.map +1 -1
  30. package/dist/core/ResponseModal.js +272 -269
  31. package/dist/core/ResponseModal.js.map +1 -1
  32. package/dist/core/ToolLoopExecutor.js +1 -1
  33. package/dist/core/ToolLoopExecutor.js.map +1 -1
  34. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  35. package/dist/providers/GeminiProvider.js +3 -1
  36. package/dist/providers/GeminiProvider.js.map +1 -1
  37. package/dist/types/agent.d.ts +9 -0
  38. package/dist/types/agent.d.ts.map +1 -1
  39. package/dist/utils/streamingMessage.d.ts +48 -0
  40. package/dist/utils/streamingMessage.d.ts.map +1 -0
  41. package/dist/utils/streamingMessage.js +205 -0
  42. package/dist/utils/streamingMessage.js.map +1 -0
  43. package/docs/reference/create-agent.md +2 -0
  44. package/package.json +1 -1
  45. package/src/core/Agent.ts +3 -1
  46. package/src/core/FlowRouter.ts +0 -14
  47. package/src/core/ResponseModal.ts +332 -299
  48. package/src/core/ToolLoopExecutor.ts +1 -1
  49. package/src/providers/GeminiProvider.ts +4 -2
  50. package/src/types/agent.ts +9 -0
  51. package/src/utils/streamingMessage.ts +220 -0
@@ -13,6 +13,7 @@ import type {
13
13
  HistoryItem,
14
14
  Event,
15
15
  AgentStructuredResponse,
16
+ GenerateMessageStreamChunk,
16
17
  StoppedReason,
17
18
  ScopedInstructions,
18
19
  AppliedInstruction,
@@ -40,6 +41,7 @@ import { SignalCoordinator } from "./SignalCoordinator";
40
41
  import { ResponseGenerationError } from "./ResponseGenerationError";
41
42
  import { cloneDeep, mergeCollected, logger, historyToEvents, completeCurrentFlow, render } from "../utils";
42
43
  import { createTemplateContext } from "../utils/template";
44
+ import { StreamingMessageDecoder } from "../utils/streamingMessage";
43
45
  import type { ToolManager } from "./ToolManager";
44
46
 
45
47
  /**
@@ -140,6 +142,34 @@ interface ResponseContext<TContext = unknown, TData = unknown> {
140
142
  signalHaltReply?: string;
141
143
  }
142
144
 
145
+ /**
146
+ * The terminal shape of a turn, decided once by {@link ResponseModal.planTurn}
147
+ * and rendered by either the streaming or non-streaming path. Abstracting the
148
+ * decision (rather than the rendering) is what keeps the two paths in lockstep.
149
+ */
150
+ type TurnOutcome<TContext, TData> =
151
+ /** No LLM call — emit a verbatim message (signal halt or auto-chain halt). */
152
+ | { kind: 'halt'; message: string; stoppedReason: StoppedReason; runPostPhase: boolean }
153
+ /** Flow finished — pure state transition, no message of the framework's own. */
154
+ | { kind: 'flowComplete'; selectedFlow: Flow<TContext, TData>; stoppedReason: StoppedReason }
155
+ /** Render one interactive step via the LLM (the happy path). */
156
+ | { kind: 'flowStep'; selectedFlow: Flow<TContext, TData>; step?: Step<TContext, TData>; responseDirectives?: string[]; signalPreDirective?: Directive<TContext, TData> }
157
+ /** No flows defined — a simple unstructured response. */
158
+ | { kind: 'fallback' };
159
+
160
+ /** The output of {@link ResponseModal.planTurn}: the outcome plus shared turn state. */
161
+ interface TurnPlan<TContext, TData> {
162
+ outcome: TurnOutcome<TContext, TData>;
163
+ /** Session after any auto-chain mutation. */
164
+ session: SessionState<TData>;
165
+ /** Live firing accumulator, seeded with pre-signal phase firings. */
166
+ signalFirings: SignalFiring<TContext, TData>[];
167
+ effectiveContext: TContext;
168
+ history: HistoryItem[];
169
+ historyEvents: Event[];
170
+ signal?: AbortSignal;
171
+ }
172
+
143
173
  /**
144
174
  * ResponseModal class that encapsulates all response generation logic
145
175
  * Uses unified approach for both streaming and non-streaming responses
@@ -540,15 +570,24 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
540
570
  }
541
571
 
542
572
  /**
543
- * Unified response generation for non-streaming responses
573
+ * Plan a turn: run signal-halt detection, the auto-chain walk, and flow/step
574
+ * selection, collapsing them into a single {@link TurnOutcome}. This is the
575
+ * shared decision spine for both the streaming and non-streaming paths — the
576
+ * only logic that genuinely differs between them is how each *renders* the
577
+ * outcome (await a value vs. yield chunks) and the leaf provider primitive it
578
+ * uses. Centralizing the decision here is what keeps the two paths from
579
+ * drifting (the class of bug behind the 2.4.x retry/empty fixes).
580
+ *
581
+ * The returned `session` reflects any auto-chain mutation; `signalFirings`
582
+ * is seeded with the pre-signal phase firings and is the live accumulator the
583
+ * post-phase tail appends to.
544
584
  * @private
545
585
  */
546
- private async generateUnifiedResponse(
586
+ private async planTurn(
547
587
  responseContext: ResponseContext<TContext, TData>
548
- ): Promise<AgentResponse<TData>> {
588
+ ): Promise<TurnPlan<TContext, TData>> {
549
589
  const {
550
590
  effectiveContext,
551
- session: initialSession,
552
591
  history,
553
592
  selectedFlow,
554
593
  selectedStep,
@@ -560,47 +599,29 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
560
599
  signalHalted,
561
600
  signalHaltReply,
562
601
  } = responseContext;
563
- let session = initialSession;
602
+ let session = responseContext.session;
564
603
 
565
604
  // Accumulator for signal firings across both phases (fire order)
566
605
  const signalFirings: SignalFiring<TContext, TData>[] = [...(preSignalFirings || [])];
567
-
568
- // Get last user message (needed for both flow and completion handling)
569
606
  // Convert HistoryItem[] to Event[] for internal processing
570
607
  const historyEvents = historyToEvents(history);
571
608
 
609
+ const base = { effectiveContext, history, historyEvents, signal, signalFirings };
610
+
572
611
  // ── SIGNAL HALT (Requirement 8.2) ─────────────────────────────────────
573
- // Pre-signal phase emitted halt → skip LLM call entirely.
612
+ // Pre-signal phase emitted halt → skip LLM call entirely. The post-signal
613
+ // phase still runs (it sees the complete turn context).
574
614
  if (signalHalted) {
575
615
  const haltMessage = signalHaltReply || '';
576
- // Run post-signal phase even on halt (post-phase sees complete turn context)
577
- const post = await this.signalCoordinator.applyPostPhase({
578
- session, context: effectiveContext, historyEvents, message: haltMessage,
579
- });
580
- session = post.session;
581
- signalFirings.push(...post.firings);
582
- const message = post.message;
583
-
584
616
  return {
585
- message,
586
- session,
587
- toolCalls: undefined,
588
- isFlowComplete: false,
589
- executedSteps: [],
590
- stoppedReason: haltMessage ? 'reply' : 'halt',
591
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
617
+ ...base, session,
618
+ outcome: { kind: 'halt', message: haltMessage, stoppedReason: haltMessage ? 'reply' : 'halt', runPostPhase: true },
592
619
  };
593
620
  }
594
621
 
595
- let message: string;
596
- let toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined = undefined;
597
- let executedSteps: StepRef[] | undefined;
598
- let stoppedReason: StoppedReason | undefined;
599
- let appliedInstructions: AppliedInstruction[] | undefined;
600
-
601
622
  if (selectedFlow && !isFlowComplete) {
602
- // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work.
603
- // If the current step is auto, the executor advances through it (and any
623
+ // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work. If the
624
+ // current step is auto, the executor advances through it (and any
604
625
  // subsequent auto-steps) until an interactive step or terminal condition.
605
626
  let resolvedStep = selectedStep;
606
627
  const currentStepInstance = session.currentStep
@@ -619,43 +640,24 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
619
640
 
620
641
  session = autoResult.session;
621
642
 
622
- // Handle halt: emit verbatim reply, return — no LLM call.
623
- // respond() finalizes the returned session exactly once.
643
+ // Halt: emit the verbatim reply, no LLM call. Unlike signal halt,
644
+ // the auto-chain halt is a hard short-circuit that does NOT run the
645
+ // post-signal phase (preserved across both paths).
624
646
  if (autoResult.stoppedReason === 'halt') {
625
- message = autoResult.mergedDirective?.reply || '';
626
- stoppedReason = 'halt';
627
- executedSteps = [];
628
-
629
647
  return {
630
- message,
631
- session,
632
- toolCalls: undefined,
633
- isFlowComplete: false,
634
- executedSteps,
635
- stoppedReason,
648
+ ...base, session,
649
+ outcome: { kind: 'halt', message: autoResult.mergedDirective?.reply || '', stoppedReason: 'halt', runPostPhase: false },
636
650
  };
637
651
  }
638
652
 
639
- // Handle flow completion or cross-flow redirect from auto-chain.
640
- // The auto-chain ended without resolving to an interactive step.
641
- // Possible reasons: last_step (no successor), completed (explicit
642
- // complete directive), or goto (cross-flow redirect).
653
+ // Flow completion or cross-flow redirect from auto-chain: the chain
654
+ // ended without resolving to an interactive step (last_step: no
655
+ // successor; completed: explicit complete; goto: cross-flow redirect).
643
656
  if (autoResult.stoppedReason === 'last_step' || autoResult.stoppedReason === 'completed' || autoResult.stoppedReason === 'goto') {
644
657
  logger.debug(`[ResponseModal] Auto-chain ended with ${autoResult.stoppedReason}`);
645
- session = await this.applyFlowCompletion({
646
- selectedFlow,
647
- session,
648
- context: effectiveContext,
649
- history,
650
- });
651
-
652
658
  return {
653
- message: '',
654
- session,
655
- toolCalls: undefined,
656
- isFlowComplete: true,
657
- executedSteps: [],
658
- stoppedReason: autoResult.stoppedReason,
659
+ ...base, session,
660
+ outcome: { kind: 'flowComplete', selectedFlow, stoppedReason: autoResult.stoppedReason },
659
661
  };
660
662
  }
661
663
 
@@ -663,101 +665,161 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
663
665
  resolvedStep = autoResult.resolvedStep;
664
666
  }
665
667
 
666
- // SINGLE STEP EXECUTION: Process the resolved interactive step.
667
- // The auto-chain (if it ran) already walked auto-steps. Only the
668
- // interactive step remains for the LLM call.
669
- const result = await this.processFlowResponse({
670
- selectedFlow,
671
- selectedStep: resolvedStep,
672
- responseDirectives,
673
- session,
674
- history,
675
- context: effectiveContext,
676
- historyEvents,
677
- signal,
678
- // Propagate signal pre-directive's appendPrompt for this turn's LLM call (Requirement 8.4)
679
- transientAppendage: signalPreDirective?.appendPrompt,
680
- // Merge signal pre-directive (halt/reply/injectTools) into the pre-LLM bus
681
- mergedPreDirective: signalPreDirective,
682
- });
668
+ return {
669
+ ...base, session,
670
+ outcome: { kind: 'flowStep', selectedFlow, step: resolvedStep, responseDirectives, signalPreDirective },
671
+ };
672
+ }
683
673
 
684
- message = result.message;
685
- toolCalls = result.toolCalls;
686
- session = result.session;
687
- appliedInstructions = result.appliedInstructions;
688
-
689
- // Track executed step for single-step execution
690
- if (resolvedStep) {
691
- executedSteps = [{
692
- id: resolvedStep.id,
693
- flowId: selectedFlow.id,
694
- }];
695
- }
696
- // Use stoppedReason from processFlowResponse if set (halt/reply),
697
- // otherwise default to 'needs_input' for normal LLM responses.
698
- stoppedReason = result.stoppedReason || 'needs_input';
699
-
700
- } else if (isFlowComplete && selectedFlow) {
701
- // Flow completion path: pure state transition, no LLM call.
702
- // The framework emits no message of its own.
703
- // stoppedReason is 'last_step' because this completion was detected by
704
- // implicit terminus (no successor or all successors skipped), not by an
705
- // explicit `complete` directive.
674
+ if (isFlowComplete && selectedFlow) {
675
+ // Flow completion path: pure state transition, no LLM call. The reason
676
+ // is 'last_step' (implicit terminus — no successor or all skipped).
706
677
  logger.debug(`[ResponseModal] Releasing session to idle for completed flow: ${selectedFlow.title}`);
678
+ return {
679
+ ...base, session,
680
+ outcome: { kind: 'flowComplete', selectedFlow, stoppedReason: 'last_step' },
681
+ };
682
+ }
707
683
 
708
- session = await this.applyFlowCompletion({
709
- selectedFlow,
710
- session,
711
- context: effectiveContext,
712
- history,
713
- });
714
- message = '';
715
- stoppedReason = 'last_step';
716
- executedSteps = [];
684
+ // Fallback: no flows defined, generate a simple response.
685
+ return { ...base, session, outcome: { kind: 'fallback' } };
686
+ }
717
687
 
718
- } else {
719
- // Fallback: No flows defined, generate a simple response
688
+ /**
689
+ * The shared post-signal phase tail (Requirement 9.1–9.4). Runs after the
690
+ * turn's message is known and before persistence, so post-phase signals see
691
+ * the complete turn result (assistant message, collected data, tool results)
692
+ * and can override the reply or wire a pendingDirective.
693
+ *
694
+ * `runPostPhase` is false only for the auto-chain halt short-circuit, which
695
+ * deliberately bypasses the post-phase in both paths; that branch still
696
+ * surfaces any pre-phase firings via `triggeredSignals`.
697
+ * @private
698
+ */
699
+ private async applyTurnPostPhase(params: {
700
+ session: SessionState<TData>;
701
+ context: TContext;
702
+ historyEvents: Event[];
703
+ message: string;
704
+ signalFirings: SignalFiring<TContext, TData>[];
705
+ runPostPhase: boolean;
706
+ }): Promise<{
707
+ session: SessionState<TData>;
708
+ message: string;
709
+ replyOverridden: boolean;
710
+ triggeredSignals?: SignalFiring<TContext, TData>[];
711
+ }> {
712
+ const { session, context, historyEvents, message, signalFirings, runPostPhase } = params;
720
713
 
721
- const fallbackResult = await this.generateFallbackResponse({
722
- history,
723
- context: effectiveContext,
724
- session,
725
- });
714
+ if (!runPostPhase) {
715
+ return {
716
+ session, message, replyOverridden: false,
717
+ triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
718
+ };
719
+ }
726
720
 
727
- message = fallbackResult.message;
728
- appliedInstructions = fallbackResult.appliedInstructions;
721
+ const post = await this.signalCoordinator.applyPostPhase({ session, context, historyEvents, message });
722
+ signalFirings.push(...post.firings);
723
+ return {
724
+ session: post.session,
725
+ message: post.message,
726
+ replyOverridden: post.replyOverridden ?? false,
727
+ triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
728
+ };
729
+ }
729
730
 
730
- // For fallback responses, set empty executedSteps and no stoppedReason
731
- // since there's no flow/step execution happening
732
- executedSteps = [];
733
- stoppedReason = undefined;
731
+ /**
732
+ * Unified response generation for non-streaming responses.
733
+ * Renders the shared {@link planTurn} outcome by awaiting the leaf primitive
734
+ * and running the shared post-phase tail; respond() owns the single finalize.
735
+ * @private
736
+ */
737
+ private async generateUnifiedResponse(
738
+ responseContext: ResponseContext<TContext, TData>
739
+ ): Promise<AgentResponse<TData>> {
740
+ const plan = await this.planTurn(responseContext);
741
+ const { effectiveContext, history, historyEvents, signal, signalFirings } = plan;
742
+ let session = plan.session;
743
+
744
+ let message = '';
745
+ let toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined = undefined;
746
+ let executedSteps: StepRef[] = [];
747
+ let stoppedReason: StoppedReason | undefined;
748
+ let isFlowComplete = false;
749
+ let appliedInstructions: AppliedInstruction[] | undefined;
750
+ let runPostPhase = true;
751
+
752
+ switch (plan.outcome.kind) {
753
+ case 'halt': {
754
+ message = plan.outcome.message;
755
+ stoppedReason = plan.outcome.stoppedReason;
756
+ runPostPhase = plan.outcome.runPostPhase;
757
+ break;
758
+ }
759
+ case 'flowComplete': {
760
+ session = await this.applyFlowCompletion({
761
+ selectedFlow: plan.outcome.selectedFlow,
762
+ session,
763
+ context: effectiveContext,
764
+ history,
765
+ });
766
+ isFlowComplete = true;
767
+ stoppedReason = plan.outcome.stoppedReason;
768
+ break;
769
+ }
770
+ case 'flowStep': {
771
+ const result = await this.processFlowResponse({
772
+ selectedFlow: plan.outcome.selectedFlow,
773
+ selectedStep: plan.outcome.step,
774
+ responseDirectives: plan.outcome.responseDirectives,
775
+ session,
776
+ history,
777
+ context: effectiveContext,
778
+ historyEvents,
779
+ signal,
780
+ // Propagate signal pre-directive's appendPrompt for this turn's LLM call (Requirement 8.4)
781
+ transientAppendage: plan.outcome.signalPreDirective?.appendPrompt,
782
+ // Merge signal pre-directive (halt/reply/injectTools) into the pre-LLM bus
783
+ mergedPreDirective: plan.outcome.signalPreDirective,
784
+ });
785
+ message = result.message;
786
+ toolCalls = result.toolCalls;
787
+ session = result.session;
788
+ appliedInstructions = result.appliedInstructions;
789
+ if (plan.outcome.step) {
790
+ executedSteps = [{ id: plan.outcome.step.id, flowId: plan.outcome.selectedFlow.id }];
791
+ }
792
+ // Use stoppedReason from processFlowResponse if set (halt/reply),
793
+ // otherwise default to 'needs_input' for normal LLM responses.
794
+ stoppedReason = result.stoppedReason || 'needs_input';
795
+ break;
796
+ }
797
+ case 'fallback': {
798
+ const fallbackResult = await this.generateFallbackResponse({
799
+ history,
800
+ context: effectiveContext,
801
+ session,
802
+ signal,
803
+ });
804
+ message = fallbackResult.message;
805
+ appliedInstructions = fallbackResult.appliedInstructions;
806
+ break;
807
+ }
734
808
  }
735
809
 
736
- // POST-SIGNAL PHASE (Requirement 9.1, 9.2, 9.3, 9.4)
737
- // Runs after finalize/onComplete and before session persistence.
738
- // Post-phase signals see the complete turn result: assistant message in
739
- // history, collected data, tool results.
740
- const post = await this.signalCoordinator.applyPostPhase({
741
- session, context: effectiveContext, historyEvents, message,
810
+ const tail = await this.applyTurnPostPhase({
811
+ session, context: effectiveContext, historyEvents, message, signalFirings, runPostPhase,
742
812
  });
743
- session = post.session;
744
- // Append post-phase firings to the accumulator (preserves fire order)
745
- signalFirings.push(...post.firings);
746
- message = post.message;
747
813
 
748
- // Ensure response structure completeness (Requirement 8.1, 8.2, 8.3)
749
- // - executedSteps: array of steps executed (empty array if none)
750
- // - stoppedReason: why execution stopped (undefined for fallback)
751
- // - session.currentStep: reflects final step position
752
814
  return {
753
- message,
754
- session,
815
+ message: tail.message,
816
+ session: tail.session,
755
817
  toolCalls,
756
- isFlowComplete: isFlowComplete,
757
- executedSteps: executedSteps || [],
818
+ isFlowComplete,
819
+ executedSteps,
758
820
  stoppedReason,
759
821
  appliedInstructions,
760
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
822
+ triggeredSignals: tail.triggeredSignals,
761
823
  };
762
824
  }
763
825
 
@@ -926,189 +988,98 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
926
988
  }
927
989
 
928
990
  /**
929
- * Unified streaming response generation
991
+ * Unified streaming response generation.
992
+ * Renders the shared {@link planTurn} outcome as a chunk stream and runs the
993
+ * shared post-phase tail on the final chunk (finalizing exactly once).
930
994
  * @private
931
995
  */
932
996
  private async *generateUnifiedStreamingResponse(
933
997
  responseContext: ResponseContext<TContext, TData>
934
998
  ): AsyncGenerator<AgentResponseStreamChunk<TData>> {
935
- const {
936
- effectiveContext,
937
- session: initialSession,
938
- history,
939
- selectedFlow,
940
- selectedStep,
941
- responseDirectives,
942
- isFlowComplete,
943
- signal,
944
- signalFirings: preSignalFirings,
945
- signalPreDirective,
946
- signalHalted,
947
- signalHaltReply,
948
- } = responseContext;
949
- let session = initialSession;
999
+ const plan = await this.planTurn(responseContext);
1000
+ const { effectiveContext, history, historyEvents, signal, signalFirings } = plan;
1001
+ const session = plan.session;
950
1002
 
951
- // Accumulator for signal firings across both phases (fire order)
952
- const signalFirings: SignalFiring<TContext, TData>[] = [...(preSignalFirings || [])];
953
-
954
- // Convert HistoryItem[] to Event[] for internal processing
955
- const historyEvents = historyToEvents(history);
956
-
957
- // ── SIGNAL HALT (Requirement 8.2) ─────────────────────────────────────
958
- if (signalHalted) {
959
- const haltMessage = signalHaltReply || '';
960
- // Run post-signal phase even on halt
961
- const post = await this.signalCoordinator.applyPostPhase({
962
- session, context: effectiveContext, historyEvents, message: haltMessage,
963
- });
964
- session = post.session;
965
- signalFirings.push(...post.firings);
966
- const message = post.message;
967
-
968
- await this.sessionFinalizer.finalize(session, effectiveContext);
969
- yield {
970
- delta: message,
971
- accumulated: message,
972
- done: true,
973
- session,
974
- stoppedReason: haltMessage ? 'reply' : 'halt',
975
- executedSteps: [],
976
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
977
- } as AgentResponseStreamChunk<TData>;
978
- return;
979
- }
980
-
981
- // ── Determine the inner stream generator based on flow state ────────
1003
+ // Build the inner chunk stream for the planned outcome. `runPostPhase` is
1004
+ // the single post-phase gate (false only for auto-chain halt).
982
1005
  let innerStream: AsyncGenerator<AgentResponseStreamChunk<TData>>;
983
-
984
- if (selectedFlow && !isFlowComplete) {
985
- // AUTO-CHAIN: Walk consecutive auto-steps before any LLM work (streaming path).
986
- let resolvedStep = selectedStep;
987
- const currentStepInstance = session.currentStep
988
- ? selectedFlow.getStep(session.currentStep.id)
989
- : selectedStep;
990
-
991
- if (currentStepInstance?.auto) {
992
- const autoChainExecutor = new AutoChainExecutor<TContext, TData>({
993
- maxAutoStepsPerTurn: this.agent.maxAutoStepsPerTurn,
1006
+ let runPostPhase = true;
1007
+
1008
+ switch (plan.outcome.kind) {
1009
+ case 'halt': {
1010
+ runPostPhase = plan.outcome.runPostPhase;
1011
+ innerStream = this.streamTerminalMessage({
1012
+ message: plan.outcome.message,
1013
+ stoppedReason: plan.outcome.stoppedReason,
1014
+ session,
994
1015
  });
995
- const autoResult: AutoChainResult<TContext, TData> = await autoChainExecutor.run({
1016
+ break;
1017
+ }
1018
+ case 'flowComplete': {
1019
+ innerStream = this.streamFlowCompletion({
1020
+ selectedFlow: plan.outcome.selectedFlow,
996
1021
  session,
997
1022
  context: effectiveContext,
998
- flow: selectedFlow,
1023
+ history,
1024
+ historyEvents,
1025
+ stoppedReason: plan.outcome.stoppedReason,
999
1026
  });
1000
-
1001
- session = autoResult.session;
1002
-
1003
- // Handle halt: emit verbatim reply as a single chunk, done.
1004
- if (autoResult.stoppedReason === 'halt') {
1005
- const reply = autoResult.mergedDirective?.reply || '';
1006
- await this.sessionFinalizer.finalize(session, effectiveContext);
1007
- yield {
1008
- delta: reply,
1009
- accumulated: reply,
1010
- done: true,
1011
- session,
1012
- stoppedReason: 'halt',
1013
- executedSteps: [],
1014
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
1015
- } as AgentResponseStreamChunk<TData>;
1016
- return;
1017
- }
1018
-
1019
- // Handle flow completion or cross-flow redirect from auto-chain.
1020
- if (autoResult.stoppedReason === 'last_step' || autoResult.stoppedReason === 'completed' || autoResult.stoppedReason === 'goto') {
1021
- innerStream = this.streamFlowCompletion({
1022
- selectedFlow,
1023
- session,
1024
- context: effectiveContext,
1025
- history,
1026
- historyEvents,
1027
- stoppedReason: autoResult.stoppedReason,
1028
- });
1029
- } else {
1030
- // Normal case: resolved to an interactive step.
1031
- resolvedStep = autoResult.resolvedStep;
1032
- innerStream = this.processFlowStreamingResponse({
1033
- selectedFlow,
1034
- selectedStep: resolvedStep,
1035
- responseDirectives,
1036
- session,
1037
- history,
1038
- context: effectiveContext,
1039
- historyEvents,
1040
- signal,
1041
- transientAppendage: signalPreDirective?.appendPrompt,
1042
- mergedPreDirective: signalPreDirective,
1043
- });
1044
- }
1045
- } else {
1046
- // No auto-step: directly stream the interactive step.
1027
+ break;
1028
+ }
1029
+ case 'flowStep': {
1047
1030
  innerStream = this.processFlowStreamingResponse({
1048
- selectedFlow,
1049
- selectedStep: resolvedStep,
1050
- responseDirectives,
1031
+ selectedFlow: plan.outcome.selectedFlow,
1032
+ selectedStep: plan.outcome.step,
1033
+ responseDirectives: plan.outcome.responseDirectives,
1051
1034
  session,
1052
1035
  history,
1053
1036
  context: effectiveContext,
1054
1037
  historyEvents,
1055
1038
  signal,
1056
- // Propagate signal pre-directive's appendPrompt for this turn's LLM call
1057
- transientAppendage: signalPreDirective?.appendPrompt,
1058
- mergedPreDirective: signalPreDirective,
1039
+ transientAppendage: plan.outcome.signalPreDirective?.appendPrompt,
1040
+ mergedPreDirective: plan.outcome.signalPreDirective,
1059
1041
  });
1042
+ break;
1043
+ }
1044
+ case 'fallback': {
1045
+ innerStream = this.streamFallbackResponse({
1046
+ history,
1047
+ context: effectiveContext,
1048
+ session,
1049
+ signal,
1050
+ });
1051
+ break;
1060
1052
  }
1061
-
1062
- } else if (isFlowComplete && selectedFlow) {
1063
- // Handle flow completion streaming — implicit terminus (no successor
1064
- // or all successors skipped), so the reason is 'last_step'.
1065
- innerStream = this.streamFlowCompletion({
1066
- selectedFlow,
1067
- session,
1068
- context: effectiveContext,
1069
- history,
1070
- historyEvents,
1071
- stoppedReason: 'last_step',
1072
- });
1073
-
1074
- } else {
1075
- // Fallback: No flows defined, stream a simple response
1076
- innerStream = this.streamFallbackResponse({
1077
- history,
1078
- context: effectiveContext,
1079
- session,
1080
- });
1081
1053
  }
1082
1054
 
1083
1055
  // ── Intercept the inner stream on the final chunk ──────────────────────
1084
- // Mirrors the non-streaming path: post-signal phase runs first, then the
1085
- // session (including post-phase mutations) is finalized exactly once,
1086
- // attaching triggeredSignals to the final chunk (Requirement 11.2).
1087
- for await (const chunk of innerStream!) {
1056
+ // Mirrors the non-streaming tail: post-signal phase runs first (when
1057
+ // applicable), then the session is finalized exactly once, attaching
1058
+ // triggeredSignals to the final chunk (Requirement 11.2).
1059
+ for await (const chunk of innerStream) {
1088
1060
  if (chunk.done) {
1089
- // Run post-signal phase on final chunk (Requirement 9.1, 9.2)
1090
- const post = await this.signalCoordinator.applyPostPhase({
1061
+ const tail = await this.applyTurnPostPhase({
1091
1062
  session: chunk.session || session,
1092
1063
  context: effectiveContext,
1093
1064
  historyEvents,
1094
1065
  message: chunk.accumulated,
1066
+ signalFirings,
1067
+ runPostPhase,
1095
1068
  });
1096
- const finalSession = post.session;
1097
- signalFirings.push(...post.firings);
1098
1069
 
1099
- const accumulated = post.message;
1100
- const delta = post.replyOverridden ? accumulated : chunk.delta;
1070
+ const accumulated = tail.message;
1071
+ const delta = tail.replyOverridden ? accumulated : chunk.delta;
1101
1072
 
1102
1073
  // Single streaming exit: finalize the post-phase session so
1103
- // post-signal mutations (e.g. pendingDirective) are persisted
1104
- await this.sessionFinalizer.finalize(finalSession, effectiveContext);
1074
+ // post-signal mutations (e.g. pendingDirective) are persisted.
1075
+ await this.sessionFinalizer.finalize(tail.session, effectiveContext);
1105
1076
 
1106
1077
  yield {
1107
1078
  ...chunk,
1108
1079
  delta,
1109
1080
  accumulated,
1110
- session: finalSession,
1111
- triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
1081
+ session: tail.session,
1082
+ triggeredSignals: tail.triggeredSignals,
1112
1083
  } as AgentResponseStreamChunk<TData>;
1113
1084
  } else {
1114
1085
  yield chunk;
@@ -1116,6 +1087,50 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1116
1087
  }
1117
1088
  }
1118
1089
 
1090
+ /**
1091
+ * Emit a framework-authored message (a halt reply) as a single terminal
1092
+ * chunk, to flow through the shared post-phase tail like any other inner
1093
+ * stream. No LLM call, no provider text — so nothing to extract or finalize
1094
+ * here; the caller's tail owns post-phase + finalize.
1095
+ * @private
1096
+ */
1097
+ // 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
1098
+ private async *streamTerminalMessage(params: {
1099
+ message: string;
1100
+ stoppedReason: StoppedReason;
1101
+ session: SessionState<TData>;
1102
+ }): AsyncGenerator<AgentResponseStreamChunk<TData>> {
1103
+ yield {
1104
+ delta: params.message,
1105
+ accumulated: params.message,
1106
+ done: true,
1107
+ session: params.session,
1108
+ toolCalls: undefined,
1109
+ isFlowComplete: false,
1110
+ stoppedReason: params.stoppedReason,
1111
+ executedSteps: [],
1112
+ } as AgentResponseStreamChunk<TData>;
1113
+ }
1114
+
1115
+ /**
1116
+ * Wrap a provider message stream so each chunk's `delta`/`accumulated` carry
1117
+ * clean message text instead of the raw structured-JSON wrapper. The single
1118
+ * point where streamed JSON is unwrapped — every streaming response variant
1119
+ * (flow step, fallback) consumes provider chunks through here, so consumers
1120
+ * and stored history never see `{"message":...}` fragments. `structured`,
1121
+ * `done`, and `metadata` pass through untouched.
1122
+ * @private
1123
+ */
1124
+ private async *decodeMessageStream(
1125
+ stream: AsyncGenerator<GenerateMessageStreamChunk<AgentStructuredResponse>>
1126
+ ): AsyncGenerator<GenerateMessageStreamChunk<AgentStructuredResponse>> {
1127
+ const decoder = new StreamingMessageDecoder();
1128
+ for await (const chunk of stream) {
1129
+ const clean = decoder.push(chunk.accumulated);
1130
+ yield { ...chunk, delta: clean.delta, accumulated: clean.message };
1131
+ }
1132
+ }
1133
+
1119
1134
  /**
1120
1135
  * Process flow streaming response with unified tool execution and data collection
1121
1136
  * @private
@@ -1240,11 +1255,16 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1240
1255
  parameters: { jsonSchema: responseSchema, schemaName: "response_stream_output" },
1241
1256
  });
1242
1257
 
1243
- // Stream chunks with unified tool handling
1244
- for await (const chunk of stream) {
1258
+ // Stream chunks with unified tool handling. decodeMessageStream gives
1259
+ // each chunk clean message text in delta/accumulated, so the non-done
1260
+ // deltas, the final accumulated, the post-phase message input, and the
1261
+ // assistant message stored by stream() are all clean — never the raw
1262
+ // JSON wrapper (matching the non-streaming structured.message extraction).
1263
+ for await (const chunk of this.decodeMessageStream(stream)) {
1245
1264
  let toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined = undefined;
1246
1265
  // Final message/structured may be replaced by a forced post-tool
1247
1266
  // response (see runStreamingBatch / gap: tools-ran-but-no-text).
1267
+ let finalDelta = chunk.delta;
1248
1268
  let finalAccumulated = chunk.accumulated;
1249
1269
  let finalStructured = chunk.structured;
1250
1270
 
@@ -1253,7 +1273,8 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1253
1273
  toolCalls = chunk.structured.toolCalls;
1254
1274
 
1255
1275
  // Concurrent execution for the initial batch of tool calls,
1256
- // yielding tool-progress chunks as they arrive
1276
+ // yielding tool-progress chunks as they arrive. The accumulated
1277
+ // preamble is already clean text.
1257
1278
  const batchResult = yield* this.toolLoopExecutor.runStreamingBatch({
1258
1279
  toolCalls,
1259
1280
  context,
@@ -1270,20 +1291,31 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1270
1291
  session = batchResult.session;
1271
1292
  toolCalls = batchResult.toolCalls;
1272
1293
 
1294
+ // Prefer the post-tool follow-up structured for collection and
1295
+ // emission whenever present — independent of whether a closing
1296
+ // message was forced — matching the non-streaming path's
1297
+ // `toolResult.structured ?? result` selection.
1298
+ finalStructured = batchResult.structured ?? finalStructured;
1299
+
1273
1300
  // Tools ran but the model produced no result-aware text — use
1274
- // the forced closing message so we never emit the bare
1275
- // preamble (or an empty message) as the final response.
1301
+ // the forced closing message (already clean) so we never emit the
1302
+ // bare preamble (or an empty message) as the final response. Its
1303
+ // delta is the portion not already streamed as the preamble.
1276
1304
  if (batchResult.finalMessage) {
1277
1305
  finalAccumulated = batchResult.finalMessage;
1278
- finalStructured = batchResult.structured ?? finalStructured;
1306
+ finalDelta = batchResult.finalMessage.startsWith(chunk.accumulated)
1307
+ ? batchResult.finalMessage.slice(chunk.accumulated.length)
1308
+ : batchResult.finalMessage;
1279
1309
  }
1280
1310
  }
1281
1311
 
1282
- // Extract collected data on final chunk (from the model's own
1283
- // structured output for this step, not the forced follow-up)
1284
- if (chunk.done && chunk.structured && nextStep.collect) {
1312
+ // Collect data on the final chunk for any flow step — flow
1313
+ // required/optional fields are valid targets even without a step
1314
+ // `collect` preferring the post-tool follow-up structured so a
1315
+ // tool-driven turn harvests fields the model produced after tools.
1316
+ if (chunk.done && finalStructured) {
1285
1317
  session = await this.collectDataFromResponse({
1286
- result: { structured: chunk.structured },
1318
+ result: { structured: finalStructured },
1287
1319
  selectedFlow,
1288
1320
  nextStep,
1289
1321
  session,
@@ -1295,7 +1327,7 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1295
1327
  // - stoppedReason: 'needs_input' for single-step execution (waiting for user input)
1296
1328
  // - session.currentStep: reflects the executed step
1297
1329
  yield {
1298
- delta: chunk.delta,
1330
+ delta: finalDelta,
1299
1331
  accumulated: finalAccumulated,
1300
1332
  done: chunk.done,
1301
1333
  session,
@@ -1620,7 +1652,8 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1620
1652
  },
1621
1653
  });
1622
1654
 
1623
- for await (const chunk of stream) {
1655
+ // Decode the JSON wrapper to clean message text (same as the flow path).
1656
+ for await (const chunk of this.decodeMessageStream(stream)) {
1624
1657
  // Response structure completeness (Requirement 8.1, 8.2, 8.3)
1625
1658
  // - executedSteps: empty for fallback (no flow/step execution)
1626
1659
  // - stoppedReason: undefined for fallback (no flow context)