@falai/agent 0.1.0-alpha2 → 0.1.0-alpha3

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 (59) hide show
  1. package/README.md +106 -0
  2. package/dist/cjs/src/core/BatchExecutor.d.ts +353 -0
  3. package/dist/cjs/src/core/BatchExecutor.d.ts.map +1 -0
  4. package/dist/cjs/src/core/BatchExecutor.js +842 -0
  5. package/dist/cjs/src/core/BatchExecutor.js.map +1 -0
  6. package/dist/cjs/src/core/BatchPromptBuilder.d.ts +86 -0
  7. package/dist/cjs/src/core/BatchPromptBuilder.d.ts.map +1 -0
  8. package/dist/cjs/src/core/BatchPromptBuilder.js +201 -0
  9. package/dist/cjs/src/core/BatchPromptBuilder.js.map +1 -0
  10. package/dist/cjs/src/core/ResponseModal.d.ts +34 -0
  11. package/dist/cjs/src/core/ResponseModal.d.ts.map +1 -1
  12. package/dist/cjs/src/core/ResponseModal.js +460 -34
  13. package/dist/cjs/src/core/ResponseModal.js.map +1 -1
  14. package/dist/cjs/src/index.d.ts +2 -0
  15. package/dist/cjs/src/index.d.ts.map +1 -1
  16. package/dist/cjs/src/index.js +7 -1
  17. package/dist/cjs/src/index.js.map +1 -1
  18. package/dist/cjs/src/types/agent.d.ts +9 -1
  19. package/dist/cjs/src/types/agent.d.ts.map +1 -1
  20. package/dist/cjs/src/types/route.d.ts +98 -0
  21. package/dist/cjs/src/types/route.d.ts.map +1 -1
  22. package/dist/src/core/BatchExecutor.d.ts +353 -0
  23. package/dist/src/core/BatchExecutor.d.ts.map +1 -0
  24. package/dist/src/core/BatchExecutor.js +837 -0
  25. package/dist/src/core/BatchExecutor.js.map +1 -0
  26. package/dist/src/core/BatchPromptBuilder.d.ts +86 -0
  27. package/dist/src/core/BatchPromptBuilder.d.ts.map +1 -0
  28. package/dist/src/core/BatchPromptBuilder.js +197 -0
  29. package/dist/src/core/BatchPromptBuilder.js.map +1 -0
  30. package/dist/src/core/ResponseModal.d.ts +34 -0
  31. package/dist/src/core/ResponseModal.d.ts.map +1 -1
  32. package/dist/src/core/ResponseModal.js +460 -34
  33. package/dist/src/core/ResponseModal.js.map +1 -1
  34. package/dist/src/index.d.ts +2 -0
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +2 -0
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/types/agent.d.ts +9 -1
  39. package/dist/src/types/agent.d.ts.map +1 -1
  40. package/dist/src/types/route.d.ts +98 -0
  41. package/dist/src/types/route.d.ts.map +1 -1
  42. package/docs/api/overview.md +124 -0
  43. package/docs/architecture/multi-step-execution.md +243 -0
  44. package/docs/core/ai-integration/prompt-composition.md +135 -0
  45. package/docs/core/ai-integration/response-processing.md +146 -0
  46. package/docs/core/conversation-flows/data-collection.md +143 -0
  47. package/docs/core/conversation-flows/step-transitions.md +132 -0
  48. package/docs/core/conversation-flows/steps.md +112 -0
  49. package/docs/core/error-handling.md +193 -0
  50. package/docs/guides/getting-started/README.md +102 -0
  51. package/docs/guides/migration/README.md +23 -0
  52. package/docs/guides/migration/multi-step-execution.md +303 -0
  53. package/package.json +4 -2
  54. package/src/core/BatchExecutor.ts +1156 -0
  55. package/src/core/BatchPromptBuilder.ts +275 -0
  56. package/src/core/ResponseModal.ts +605 -35
  57. package/src/index.ts +2 -0
  58. package/src/types/agent.ts +9 -1
  59. package/src/types/route.ts +119 -0
@@ -15,6 +15,7 @@ import type {
15
15
  ToolEventData,
16
16
  AgentStructuredResponse,
17
17
  Term,
18
+ StoppedReason,
18
19
  } from "../types";
19
20
  import { EventKind, MessageRole } from "../types";
20
21
  import type { Agent } from "./Agent";
@@ -22,10 +23,13 @@ import type { Route } from "./Route";
22
23
  import { Step } from "./Step";
23
24
  import { ResponseEngine } from "./ResponseEngine";
24
25
  import { ResponsePipeline } from "./ResponsePipeline";
26
+ import { BatchExecutor, type HookFunction } from "./BatchExecutor";
27
+ import { BatchPromptBuilder } from "./BatchPromptBuilder";
25
28
  import { cloneDeep, mergeCollected, enterStep, getLastMessageFromHistory, render, logger, historyToEvents } from "../utils";
26
29
  import { createTemplateContext } from "../utils/template";
27
30
  import type { ToolManager } from "./ToolManager";
28
31
  import { END_ROUTE_ID } from "../constants";
32
+ import type { StepOptions } from "../types/route";
29
33
 
30
34
  /**
31
35
  * Configuration options for ResponseModal
@@ -130,6 +134,12 @@ interface ResponseContext<TContext = unknown, TData = unknown> {
130
134
  selectedStep?: Step<TContext, TData>;
131
135
  responseDirectives?: string[];
132
136
  isRouteComplete: boolean;
137
+ /** Batch of steps to execute (for multi-step execution) */
138
+ batchSteps?: StepOptions<TContext, TData>[];
139
+ /** Reason why batch determination stopped */
140
+ batchStoppedReason?: StoppedReason;
141
+ /** Step that caused batch to stop (if applicable) */
142
+ batchStoppedAtStep?: StepOptions<TContext, TData>;
133
143
  }
134
144
 
135
145
  /**
@@ -139,6 +149,8 @@ interface ResponseContext<TContext = unknown, TData = unknown> {
139
149
  export class ResponseModal<TContext = unknown, TData = unknown> {
140
150
  private readonly responseEngine: ResponseEngine<TContext, TData>;
141
151
  private readonly responsePipeline: ResponsePipeline<TContext, TData>;
152
+ private readonly batchExecutor: BatchExecutor<TContext, TData>;
153
+ private readonly batchPromptBuilder: BatchPromptBuilder<TContext, TData>;
142
154
 
143
155
  constructor(
144
156
  private readonly agent: Agent<TContext, TData>,
@@ -158,6 +170,12 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
158
170
  this.agent.updateCollectedData.bind(this.agent),
159
171
  this.getToolManager()
160
172
  );
173
+
174
+ // Initialize batch executor for multi-step execution
175
+ this.batchExecutor = new BatchExecutor<TContext, TData>();
176
+
177
+ // Initialize batch prompt builder for combined prompts
178
+ this.batchPromptBuilder = new BatchPromptBuilder<TContext, TData>();
161
179
  }
162
180
 
163
181
  /**
@@ -419,12 +437,16 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
419
437
  }
420
438
 
421
439
  // PHASE 2: ROUTING + STEP SELECTION - Determine which route and step to use
440
+ // Also performs pre-extraction and batch determination
422
441
  let routingResult: {
423
442
  selectedRoute?: Route<TContext, TData>;
424
443
  selectedStep?: Step<TContext, TData>;
425
444
  responseDirectives?: string[];
426
445
  session: SessionState<TData>;
427
446
  isRouteComplete: boolean;
447
+ batchSteps?: StepOptions<TContext, TData>[];
448
+ batchStoppedReason?: StoppedReason;
449
+ batchStoppedAtStep?: StepOptions<TContext, TData>;
428
450
  };
429
451
  try {
430
452
  routingResult = await this.handleUnifiedRoutingAndStepSelection({
@@ -445,6 +467,9 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
445
467
  selectedStep: routingResult.selectedStep,
446
468
  responseDirectives: routingResult.responseDirectives,
447
469
  isRouteComplete: routingResult.isRouteComplete,
470
+ batchSteps: routingResult.batchSteps,
471
+ batchStoppedReason: routingResult.batchStoppedReason,
472
+ batchStoppedAtStep: routingResult.batchStoppedAtStep,
448
473
  };
449
474
  } catch (error) {
450
475
  // Re-throw ResponseGenerationError as-is, wrap others
@@ -470,6 +495,12 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
470
495
  responseDirectives?: string[];
471
496
  session: SessionState<TData>;
472
497
  isRouteComplete: boolean;
498
+ /** Batch of steps to execute (for multi-step execution) */
499
+ batchSteps?: StepOptions<TContext, TData>[];
500
+ /** Reason why batch determination stopped */
501
+ batchStoppedReason?: StoppedReason;
502
+ /** Step that caused batch to stop (if applicable) */
503
+ batchStoppedAtStep?: StepOptions<TContext, TData>;
473
504
  }> {
474
505
  try {
475
506
  // Use the ResponsePipeline for optimized routing and step selection
@@ -485,13 +516,13 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
485
516
  let updatedSession = routingResult.session;
486
517
  let isRouteComplete = routingResult.isRouteComplete;
487
518
 
488
- // PRE-EXTRACTION: If entering a new route that collects data, extract data from user message first
519
+ // PRE-EXTRACTION: If entering a route that collects data, extract data from user message first
489
520
  // This allows us to skip steps whose data is already provided
521
+ // Requirement 3.1: Perform Pre_Extraction before determining the Batch
490
522
  if (routingResult.selectedRoute && !isRouteComplete) {
491
- const isEnteringNewRoute = !params.session.currentRoute ||
492
- params.session.currentRoute.id !== routingResult.selectedRoute.id;
493
-
494
- if (isEnteringNewRoute && this.shouldPreExtractData(routingResult.selectedRoute)) {
523
+ // Always pre-extract when route collects data (not just on new route entry)
524
+ // This ensures batch determination has the most up-to-date data
525
+ if (this.shouldPreExtractData(routingResult.selectedRoute)) {
495
526
  logger.debug(
496
527
  `[ResponseModal] Pre-extracting data for route: ${routingResult.selectedRoute.title}`
497
528
  );
@@ -509,7 +540,7 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
509
540
  `[ResponseModal] Pre-extracted data:`,
510
541
  extractedData
511
542
  );
512
- // Update session with pre-extracted data
543
+ // Requirement 3.3: Merge pre-extracted data into session before batch determination
513
544
  updatedSession = mergeCollected(updatedSession, extractedData);
514
545
  // Also update agent's collected data
515
546
  await this.agent.updateCollectedData(extractedData);
@@ -526,6 +557,33 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
526
557
  }
527
558
  }
528
559
 
560
+ // BATCH DETERMINATION: Use BatchExecutor to determine which steps can execute together
561
+ // Requirement 3.4: Pre-extraction results affect batch determination
562
+ let batchSteps: StepOptions<TContext, TData>[] | undefined;
563
+ let batchStoppedReason: StoppedReason | undefined;
564
+ let batchStoppedAtStep: StepOptions<TContext, TData> | undefined;
565
+
566
+ if (routingResult.selectedRoute && !isRouteComplete) {
567
+ // Determine current step position for batch determination
568
+ const currentStep = routingResult.selectedStep ||
569
+ (updatedSession.currentStep ? routingResult.selectedRoute.getStep(updatedSession.currentStep.id) : undefined);
570
+
571
+ logger.debug(`[ResponseModal] Determining batch starting from step: ${currentStep?.id || 'initial'}`);
572
+
573
+ const batchResult = await this.batchExecutor.determineBatch({
574
+ route: routingResult.selectedRoute,
575
+ currentStep,
576
+ sessionData: updatedSession.data || {},
577
+ context: params.context,
578
+ });
579
+
580
+ batchSteps = batchResult.steps;
581
+ batchStoppedReason = batchResult.stoppedReason;
582
+ batchStoppedAtStep = batchResult.stoppedAtStep;
583
+
584
+ logger.debug(`[ResponseModal] Batch determined: ${batchSteps.length} steps, stopped reason: ${batchStoppedReason}`);
585
+ }
586
+
529
587
  // Determine next step using pipeline method for consistency
530
588
  const stepResult = await this.responsePipeline.determineNextStep({
531
589
  selectedRoute: routingResult.selectedRoute,
@@ -540,6 +598,9 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
540
598
  responseDirectives: routingResult.responseDirectives,
541
599
  session: stepResult.session,
542
600
  isRouteComplete, // Use updated completion status
601
+ batchSteps,
602
+ batchStoppedReason,
603
+ batchStoppedAtStep,
543
604
  };
544
605
  } catch (error) {
545
606
  throw ResponseGenerationError.fromError(error, 'routing_optimization', params);
@@ -643,7 +704,17 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
643
704
  private async generateUnifiedResponse(
644
705
  responseContext: ResponseContext<TContext, TData>
645
706
  ): Promise<AgentResponse<TData>> {
646
- const { effectiveContext, session: initialSession, history, selectedRoute, selectedStep, responseDirectives, isRouteComplete } = responseContext;
707
+ const {
708
+ effectiveContext,
709
+ session: initialSession,
710
+ history,
711
+ selectedRoute,
712
+ selectedStep,
713
+ responseDirectives,
714
+ isRouteComplete,
715
+ batchSteps,
716
+ batchStoppedReason,
717
+ } = responseContext;
647
718
  let session = initialSession;
648
719
 
649
720
  // Get last user message (needed for both route and completion handling)
@@ -653,27 +724,61 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
653
724
 
654
725
  let message: string;
655
726
  let toolCalls: Array<{ toolName: string; arguments: Record<string, unknown> }> | undefined = undefined;
727
+ let executedSteps: StepRef[] | undefined;
728
+ let stoppedReason: StoppedReason | undefined;
656
729
 
657
730
 
658
731
 
659
732
  if (selectedRoute && !isRouteComplete) {
660
- // Handle normal route processing
733
+ // Check if we have batch steps to execute
734
+ if (batchSteps && batchSteps.length > 0) {
735
+ // BATCH EXECUTION: Execute multiple steps in a single LLM call
736
+ logger.debug(`[ResponseModal] Executing batch of ${batchSteps.length} steps`);
661
737
 
662
- const result = await this.processRouteResponse({
663
- selectedRoute,
664
- selectedStep,
665
- responseDirectives,
666
- session,
667
- history,
668
- context: effectiveContext,
669
- lastMessageText,
670
- historyEvents,
671
- signal: responseContext.history ? undefined : undefined, // TODO: Fix signal passing
672
- });
738
+ const batchResult = await this.executeBatchResponse({
739
+ selectedRoute,
740
+ batchSteps,
741
+ responseDirectives,
742
+ session,
743
+ history,
744
+ context: effectiveContext,
745
+ historyEvents,
746
+ });
747
+
748
+ message = batchResult.message;
749
+ toolCalls = batchResult.toolCalls;
750
+ session = batchResult.session;
751
+ executedSteps = batchResult.executedSteps;
752
+ stoppedReason = batchStoppedReason;
753
+
754
+ } else {
755
+ // SINGLE STEP EXECUTION: Fall back to single-step processing
756
+ // This happens when batch determination returns empty (first step needs input)
757
+ const result = await this.processRouteResponse({
758
+ selectedRoute,
759
+ selectedStep,
760
+ responseDirectives,
761
+ session,
762
+ history,
763
+ context: effectiveContext,
764
+ lastMessageText,
765
+ historyEvents,
766
+ signal: undefined,
767
+ });
673
768
 
674
- message = result.message;
675
- toolCalls = result.toolCalls;
676
- session = result.session;
769
+ message = result.message;
770
+ toolCalls = result.toolCalls;
771
+ session = result.session;
772
+
773
+ // Track executed step for single-step execution
774
+ if (selectedStep) {
775
+ executedSteps = [{
776
+ id: selectedStep.id,
777
+ routeId: selectedRoute.id,
778
+ }];
779
+ }
780
+ stoppedReason = batchStoppedReason || 'needs_input';
781
+ }
677
782
 
678
783
  } else if (isRouteComplete && selectedRoute) {
679
784
  // Handle route completion
@@ -686,17 +791,19 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
686
791
  context: effectiveContext,
687
792
  lastMessageText,
688
793
  historyEvents,
689
- signal: undefined, // TODO: Pass signal from responseContext
794
+ signal: undefined,
690
795
  });
691
796
 
692
797
  // Set step to END_ROUTE marker
693
798
  session = enterStep(session, END_ROUTE_ID, "Route completed");
799
+ stoppedReason = 'route_complete';
694
800
  logger.debug(`[ResponseModal] Route ${selectedRoute.title} completed. Entered END_ROUTE step.`);
695
801
  } catch (error) {
696
802
  logger.error(`[ResponseModal] Error generating completion message:`, error);
697
803
  // Fallback to simple completion message
698
804
  message = `Thank you! I've recorded all the information for your ${selectedRoute.title.toLowerCase()}.`;
699
805
  session = enterStep(session, END_ROUTE_ID, "Route completed");
806
+ stoppedReason = 'route_complete';
700
807
  }
701
808
 
702
809
  } else {
@@ -707,16 +814,291 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
707
814
  context: effectiveContext,
708
815
  session,
709
816
  });
817
+
818
+ // For fallback responses, set empty executedSteps and no stoppedReason
819
+ // since there's no route/step execution happening
820
+ executedSteps = [];
821
+ stoppedReason = undefined;
710
822
  }
711
823
 
824
+ // Ensure response structure completeness (Requirement 8.1, 8.2, 8.3)
825
+ // - executedSteps: array of steps executed (empty array if none)
826
+ // - stoppedReason: why execution stopped (undefined for fallback)
827
+ // - session.currentStep: reflects final step position
712
828
  return {
713
829
  message,
714
830
  session,
715
831
  toolCalls,
716
832
  isRouteComplete,
833
+ executedSteps: executedSteps || [],
834
+ stoppedReason,
835
+ };
836
+ }
837
+
838
+ /**
839
+ * Execute a batch of steps with a single LLM call
840
+ *
841
+ * This method:
842
+ * 1. Executes all prepare hooks for steps in the batch (in order)
843
+ * 2. Builds a combined prompt using BatchPromptBuilder
844
+ * 3. Makes a single LLM call
845
+ * 4. Collects data from the response for all steps
846
+ * 5. Executes all finalize hooks for steps in the batch (in order)
847
+ *
848
+ * @private
849
+ * **Validates: Requirements 1.1, 4.4, 5.1, 5.2**
850
+ */
851
+ private async executeBatchResponse(params: {
852
+ selectedRoute: Route<TContext, TData>;
853
+ batchSteps: StepOptions<TContext, TData>[];
854
+ responseDirectives?: string[];
855
+ session: SessionState<TData>;
856
+ history: HistoryItem[];
857
+ context: TContext;
858
+ historyEvents: Event[];
859
+ signal?: AbortSignal;
860
+ }): Promise<{
861
+ message: string;
862
+ toolCalls?: Array<{ toolName: string; arguments: Record<string, unknown> }>;
863
+ session: SessionState<TData>;
864
+ executedSteps: StepRef[];
865
+ }> {
866
+ const { selectedRoute, batchSteps, history, context, historyEvents, signal } = params;
867
+ let session = params.session;
868
+
869
+ logger.debug(`[ResponseModal] Starting batch execution for ${batchSteps.length} steps`);
870
+
871
+ // Create hook executor function
872
+ const executeHook = async (
873
+ hook: HookFunction<TContext, TData>,
874
+ hookContext: TContext,
875
+ data?: Partial<TData>,
876
+ step?: StepOptions<TContext, TData>
877
+ ): Promise<void> => {
878
+ // Find the route for this step
879
+ const route = selectedRoute;
880
+ // Convert StepOptions to Step if needed for executePrepareFinalize
881
+ const stepInstance = step?.id ? route.getStep(step.id) : undefined;
882
+ await this.executePrepareFinalize(hook, hookContext, data, route, stepInstance);
883
+ };
884
+
885
+ // PHASE 1: Execute all prepare hooks (Requirement 5.1)
886
+ logger.debug(`[ResponseModal] Executing prepare hooks for batch`);
887
+ const prepareResult = await this.batchExecutor.executePrepareHooks({
888
+ steps: batchSteps,
889
+ context,
890
+ data: session.data,
891
+ executeHook,
892
+ });
893
+
894
+ if (!prepareResult.success) {
895
+ // Prepare hook failed - return error response
896
+ logger.error(`[ResponseModal] Prepare hook failed:`, prepareResult.error);
897
+ throw new ResponseGenerationError(
898
+ `Prepare hook failed: ${prepareResult.error?.message}`,
899
+ {
900
+ phase: 'prepare_hooks',
901
+ context: {
902
+ stepId: prepareResult.error?.stepId,
903
+ executedSteps: prepareResult.executedSteps,
904
+ }
905
+ }
906
+ );
907
+ }
908
+
909
+ // PHASE 2: Build combined prompt using BatchPromptBuilder (Requirement 4.4)
910
+ logger.debug(`[ResponseModal] Building batch prompt`);
911
+ const batchPromptResult = await this.batchPromptBuilder.buildBatchPrompt({
912
+ steps: batchSteps,
913
+ route: selectedRoute,
914
+ history: historyEvents,
915
+ context,
916
+ session,
917
+ agentOptions: this.agent.getAgentOptions(),
918
+ });
919
+
920
+ logger.debug(`[ResponseModal] Batch prompt built with ${batchPromptResult.stepCount} steps, collecting: ${batchPromptResult.collectFields.join(', ')}`);
921
+
922
+ // Build response schema for batch (includes all collect fields)
923
+ const responseSchema = this.buildBatchResponseSchema(batchPromptResult.collectFields);
924
+
925
+ // Collect available tools for AI (from all steps in batch)
926
+ const availableTools = this.collectBatchAvailableTools(selectedRoute, batchSteps);
927
+
928
+ // PHASE 3: Make single LLM call (Requirement 4.4)
929
+ logger.debug(`[ResponseModal] Making LLM call for batch`);
930
+ const agentOptions = this.agent.getAgentOptions();
931
+ const result = await agentOptions.provider.generateMessage({
932
+ prompt: batchPromptResult.prompt,
933
+ history: historyEvents,
934
+ context,
935
+ tools: availableTools,
936
+ signal,
937
+ parameters: responseSchema ? { jsonSchema: responseSchema, schemaName: "batch_response" } : undefined,
938
+ });
939
+
940
+ let message = result.structured?.message || result.message;
941
+ let toolCalls = result.structured?.toolCalls;
942
+
943
+ logger.debug(`[ResponseModal] LLM response received for batch`);
944
+
945
+ // Execute tools if any
946
+ if (toolCalls && toolCalls.length > 0) {
947
+ const toolResult = await this.executeUnifiedToolLoop({
948
+ toolCalls,
949
+ context,
950
+ session,
951
+ history,
952
+ selectedRoute,
953
+ responsePrompt: batchPromptResult.prompt,
954
+ availableTools,
955
+ responseSchema,
956
+ signal,
957
+ });
958
+
959
+ session = toolResult.session;
960
+ toolCalls = toolResult.finalToolCalls;
961
+ if (toolResult.finalMessage) {
962
+ message = toolResult.finalMessage;
963
+ }
964
+ }
965
+
966
+ // PHASE 4: Collect data from response for all steps (Requirement 6.1, 6.2, 6.3)
967
+ logger.debug(`[ResponseModal] Collecting batch data`);
968
+ const collectResult = this.batchExecutor.collectBatchData({
969
+ steps: batchSteps,
970
+ llmResponse: result.structured || {},
971
+ session,
972
+ schema: this.agent.getSchema(),
973
+ });
974
+
975
+ session = collectResult.session;
976
+
977
+ if (collectResult.collectedData && Object.keys(collectResult.collectedData).length > 0) {
978
+ // Update agent's collected data
979
+ await this.agent.updateCollectedData(collectResult.collectedData);
980
+ logger.debug(`[ResponseModal] Batch collected data:`, collectResult.collectedData);
981
+ }
982
+
983
+ if (collectResult.validationErrors && collectResult.validationErrors.length > 0) {
984
+ logger.warn(`[ResponseModal] Batch data validation errors:`, collectResult.validationErrors);
985
+ }
986
+
987
+ // Update session to final step position
988
+ const lastStep = batchSteps[batchSteps.length - 1];
989
+ if (lastStep?.id) {
990
+ session = enterStep(session, lastStep.id, lastStep.description);
991
+ logger.debug(`[ResponseModal] Updated session to final batch step: ${lastStep.id}`);
992
+ }
993
+
994
+ // PHASE 5: Execute all finalize hooks (Requirement 5.2)
995
+ logger.debug(`[ResponseModal] Executing finalize hooks for batch`);
996
+ const finalizeResult = await this.batchExecutor.executeFinalizeHooks({
997
+ steps: batchSteps,
998
+ context,
999
+ data: session.data,
1000
+ executeHook,
1001
+ });
1002
+
1003
+ if (finalizeResult.errors && finalizeResult.errors.length > 0) {
1004
+ // Log finalize errors but don't fail (Requirement 5.5)
1005
+ logger.warn(`[ResponseModal] Some finalize hooks failed:`, finalizeResult.errors);
1006
+ }
1007
+
1008
+ // Build executed steps list
1009
+ const executedSteps: StepRef[] = batchSteps
1010
+ .filter(step => step.id)
1011
+ .map(step => ({
1012
+ id: step.id!,
1013
+ routeId: selectedRoute.id,
1014
+ }));
1015
+
1016
+ logger.debug(`[ResponseModal] Batch execution complete. Executed ${executedSteps.length} steps`);
1017
+
1018
+ return {
1019
+ message,
1020
+ toolCalls,
1021
+ session,
1022
+ executedSteps,
1023
+ };
1024
+ }
1025
+
1026
+ /**
1027
+ * Build response schema for batch execution
1028
+ * @private
1029
+ */
1030
+ private buildBatchResponseSchema(collectFields: string[]): Record<string, unknown> {
1031
+ const properties: Record<string, unknown> = {
1032
+ message: {
1033
+ type: "string",
1034
+ description: "Your response to the user",
1035
+ },
1036
+ };
1037
+
1038
+ // Add collect fields to schema
1039
+ for (const field of collectFields) {
1040
+ properties[field] = {
1041
+ type: "string",
1042
+ description: `Collected value for ${field}`,
1043
+ };
1044
+ }
1045
+
1046
+ return {
1047
+ type: "object",
1048
+ properties,
1049
+ required: ["message"],
1050
+ additionalProperties: true,
717
1051
  };
718
1052
  }
719
1053
 
1054
+ /**
1055
+ * Collect available tools from all steps in the batch
1056
+ * @private
1057
+ */
1058
+ private collectBatchAvailableTools(
1059
+ route: Route<TContext, TData>,
1060
+ batchSteps: StepOptions<TContext, TData>[]
1061
+ ): Array<{
1062
+ id: string;
1063
+ name: string;
1064
+ description?: string;
1065
+ parameters?: unknown;
1066
+ }> {
1067
+ const availableTools = new Map<string, Tool<TContext, TData>>();
1068
+
1069
+ // Add agent-level tools
1070
+ this.agent.getTools().forEach((tool) => {
1071
+ availableTools.set(tool.id, tool);
1072
+ });
1073
+
1074
+ // Add route-level tools
1075
+ route.getTools().forEach((tool: Tool<TContext, TData>) => {
1076
+ availableTools.set(tool.id, tool);
1077
+ });
1078
+
1079
+ // Add step-level tools from all batch steps
1080
+ for (const step of batchSteps) {
1081
+ if (step.tools) {
1082
+ for (const toolRef of step.tools) {
1083
+ if (typeof toolRef === "string") {
1084
+ // Reference to registered tool - already in availableTools
1085
+ } else if (typeof toolRef === 'object' && 'id' in toolRef && toolRef.id) {
1086
+ // Inline tool definition
1087
+ availableTools.set(toolRef.id, toolRef);
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ // Convert to the format expected by AI providers
1094
+ return Array.from(availableTools.values()).map((tool) => ({
1095
+ id: tool.id,
1096
+ name: tool.name || tool.id,
1097
+ description: tool.description,
1098
+ parameters: tool.parameters,
1099
+ }));
1100
+ }
1101
+
720
1102
  /**
721
1103
  * Unified streaming response generation
722
1104
  * @private
@@ -724,7 +1106,17 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
724
1106
  private async *generateUnifiedStreamingResponse(
725
1107
  responseContext: ResponseContext<TContext, TData>
726
1108
  ): AsyncGenerator<AgentResponseStreamChunk<TData>> {
727
- const { effectiveContext, session: initialSession, history, selectedRoute, selectedStep, responseDirectives, isRouteComplete } = responseContext;
1109
+ const {
1110
+ effectiveContext,
1111
+ session: initialSession,
1112
+ history,
1113
+ selectedRoute,
1114
+ selectedStep,
1115
+ responseDirectives,
1116
+ isRouteComplete,
1117
+ batchSteps,
1118
+ batchStoppedReason,
1119
+ } = responseContext;
728
1120
  const session = initialSession;
729
1121
 
730
1122
  // Get last user message (needed for both route and completion handling)
@@ -733,17 +1125,35 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
733
1125
  const lastMessageText = getLastMessageFromHistory(historyEvents);
734
1126
 
735
1127
  if (selectedRoute && !isRouteComplete) {
736
- // Handle normal route processing with streaming
737
- yield* this.processRouteStreamingResponse({
738
- selectedRoute,
739
- selectedStep,
740
- responseDirectives,
741
- session,
742
- history,
743
- context: effectiveContext,
744
- lastMessageText,
745
- historyEvents,
746
- });
1128
+ // Check if we have batch steps to execute
1129
+ if (batchSteps && batchSteps.length > 0) {
1130
+ // BATCH EXECUTION: Execute multiple steps with streaming
1131
+ // Note: For streaming, we still use batch execution but stream the response
1132
+ logger.debug(`[ResponseModal] Streaming batch execution for ${batchSteps.length} steps`);
1133
+
1134
+ yield* this.streamBatchResponse({
1135
+ selectedRoute,
1136
+ batchSteps,
1137
+ responseDirectives,
1138
+ session,
1139
+ history,
1140
+ context: effectiveContext,
1141
+ historyEvents,
1142
+ batchStoppedReason,
1143
+ });
1144
+ } else {
1145
+ // SINGLE STEP EXECUTION: Fall back to single-step streaming
1146
+ yield* this.processRouteStreamingResponse({
1147
+ selectedRoute,
1148
+ selectedStep,
1149
+ responseDirectives,
1150
+ session,
1151
+ history,
1152
+ context: effectiveContext,
1153
+ lastMessageText,
1154
+ historyEvents,
1155
+ });
1156
+ }
747
1157
 
748
1158
  } else if (isRouteComplete && selectedRoute) {
749
1159
  // Handle route completion streaming
@@ -764,6 +1174,148 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
764
1174
  });
765
1175
  }
766
1176
  }
1177
+
1178
+ /**
1179
+ * Stream a batch response with multiple steps
1180
+ *
1181
+ * Similar to executeBatchResponse but streams the LLM response.
1182
+ *
1183
+ * @private
1184
+ */
1185
+ private async *streamBatchResponse(params: {
1186
+ selectedRoute: Route<TContext, TData>;
1187
+ batchSteps: StepOptions<TContext, TData>[];
1188
+ responseDirectives?: string[];
1189
+ session: SessionState<TData>;
1190
+ history: HistoryItem[];
1191
+ context: TContext;
1192
+ historyEvents: Event[];
1193
+ batchStoppedReason?: StoppedReason;
1194
+ signal?: AbortSignal;
1195
+ }): AsyncGenerator<AgentResponseStreamChunk<TData>> {
1196
+ const { selectedRoute, batchSteps, context, historyEvents, batchStoppedReason, signal } = params;
1197
+ let session = params.session;
1198
+
1199
+ // Create hook executor function
1200
+ const executeHook = async (
1201
+ hook: HookFunction<TContext, TData>,
1202
+ hookContext: TContext,
1203
+ data?: Partial<TData>,
1204
+ step?: StepOptions<TContext, TData>
1205
+ ): Promise<void> => {
1206
+ const route = selectedRoute;
1207
+ const stepInstance = step?.id ? route.getStep(step.id) : undefined;
1208
+ await this.executePrepareFinalize(hook, hookContext, data, route, stepInstance);
1209
+ };
1210
+
1211
+ // PHASE 1: Execute all prepare hooks
1212
+ const prepareResult = await this.batchExecutor.executePrepareHooks({
1213
+ steps: batchSteps,
1214
+ context,
1215
+ data: session.data,
1216
+ executeHook,
1217
+ });
1218
+
1219
+ if (!prepareResult.success) {
1220
+ // Yield error chunk
1221
+ yield {
1222
+ delta: "",
1223
+ accumulated: "",
1224
+ done: true,
1225
+ session,
1226
+ error: new ResponseGenerationError(
1227
+ `Prepare hook failed: ${prepareResult.error?.message}`,
1228
+ { phase: 'prepare_hooks' }
1229
+ ),
1230
+ };
1231
+ return;
1232
+ }
1233
+
1234
+ // PHASE 2: Build combined prompt
1235
+ const batchPromptResult = await this.batchPromptBuilder.buildBatchPrompt({
1236
+ steps: batchSteps,
1237
+ route: selectedRoute,
1238
+ history: historyEvents,
1239
+ context,
1240
+ session,
1241
+ agentOptions: this.agent.getAgentOptions(),
1242
+ });
1243
+
1244
+ const responseSchema = this.buildBatchResponseSchema(batchPromptResult.collectFields);
1245
+ const availableTools = this.collectBatchAvailableTools(selectedRoute, batchSteps);
1246
+
1247
+ // PHASE 3: Stream LLM response
1248
+ const agentOptions = this.agent.getAgentOptions();
1249
+ const stream = agentOptions.provider.generateMessageStream({
1250
+ prompt: batchPromptResult.prompt,
1251
+ history: historyEvents,
1252
+ context,
1253
+ tools: availableTools,
1254
+ signal,
1255
+ parameters: responseSchema ? { jsonSchema: responseSchema, schemaName: "batch_stream_response" } : undefined,
1256
+ });
1257
+
1258
+ // Build executed steps list
1259
+ const executedSteps: StepRef[] = batchSteps
1260
+ .filter(step => step.id)
1261
+ .map(step => ({
1262
+ id: step.id!,
1263
+ routeId: selectedRoute.id,
1264
+ }));
1265
+
1266
+ // Stream chunks
1267
+ for await (const chunk of stream) {
1268
+ // On final chunk, collect data and execute finalize hooks
1269
+ if (chunk.done) {
1270
+ // Collect data from response
1271
+ if (chunk.structured) {
1272
+ const collectResult = this.batchExecutor.collectBatchData({
1273
+ steps: batchSteps,
1274
+ llmResponse: chunk.structured,
1275
+ session,
1276
+ schema: this.agent.getSchema(),
1277
+ });
1278
+
1279
+ session = collectResult.session;
1280
+
1281
+ if (collectResult.collectedData && Object.keys(collectResult.collectedData).length > 0) {
1282
+ await this.agent.updateCollectedData(collectResult.collectedData);
1283
+ }
1284
+ }
1285
+
1286
+ // Update session to final step position
1287
+ const lastStep = batchSteps[batchSteps.length - 1];
1288
+ if (lastStep?.id) {
1289
+ session = enterStep(session, lastStep.id, lastStep.description);
1290
+ }
1291
+
1292
+ // Execute finalize hooks
1293
+ await this.batchExecutor.executeFinalizeHooks({
1294
+ steps: batchSteps,
1295
+ context,
1296
+ data: session.data,
1297
+ executeHook,
1298
+ });
1299
+
1300
+ // Finalize session
1301
+ await this.finalizeSession(session, context);
1302
+ }
1303
+
1304
+ yield {
1305
+ delta: chunk.delta,
1306
+ accumulated: chunk.accumulated,
1307
+ done: chunk.done,
1308
+ session,
1309
+ toolCalls: chunk.structured?.toolCalls,
1310
+ isRouteComplete: false,
1311
+ executedSteps: chunk.done ? executedSteps : undefined,
1312
+ stoppedReason: chunk.done ? batchStoppedReason : undefined,
1313
+ metadata: chunk.metadata,
1314
+ structured: chunk.structured,
1315
+ };
1316
+ }
1317
+ }
1318
+
767
1319
  /**
768
1320
  * Execute prepare function for current step if available
769
1321
  * @private
@@ -1067,6 +1619,10 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1067
1619
  await this.finalizeSession(session, context);
1068
1620
  }
1069
1621
 
1622
+ // Response structure completeness (Requirement 8.1, 8.2, 8.3)
1623
+ // - executedSteps: single step executed in this response
1624
+ // - stoppedReason: 'needs_input' for single-step execution (waiting for user input)
1625
+ // - session.currentStep: reflects the executed step
1070
1626
  yield {
1071
1627
  delta: chunk.delta,
1072
1628
  accumulated: chunk.accumulated,
@@ -1074,6 +1630,8 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1074
1630
  session,
1075
1631
  toolCalls,
1076
1632
  isRouteComplete: false,
1633
+ executedSteps: chunk.done ? [{ id: nextStep.id, routeId: selectedRoute.id }] : undefined,
1634
+ stoppedReason: chunk.done ? 'needs_input' : undefined,
1077
1635
  metadata: chunk.metadata,
1078
1636
  structured: chunk.structured,
1079
1637
  };
@@ -1653,6 +2211,10 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1653
2211
  await this.finalizeSession(session, context);
1654
2212
  }
1655
2213
 
2214
+ // Response structure completeness (Requirement 8.1, 8.2, 8.3)
2215
+ // - executedSteps: empty for route completion (no new steps executed)
2216
+ // - stoppedReason: 'route_complete' for completed routes
2217
+ // - session.currentStep: set to END_ROUTE
1656
2218
  yield {
1657
2219
  delta: chunk.delta,
1658
2220
  accumulated: chunk.accumulated,
@@ -1660,6 +2222,8 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1660
2222
  session,
1661
2223
  toolCalls: undefined,
1662
2224
  isRouteComplete: true,
2225
+ executedSteps: chunk.done ? [] : undefined,
2226
+ stoppedReason: chunk.done ? 'route_complete' : undefined,
1663
2227
  metadata: chunk.metadata,
1664
2228
  structured: chunk.structured,
1665
2229
  };
@@ -1754,6 +2318,10 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1754
2318
  await this.finalizeSession(session, context);
1755
2319
  }
1756
2320
 
2321
+ // Response structure completeness (Requirement 8.1, 8.2, 8.3)
2322
+ // - executedSteps: empty for fallback (no route/step execution)
2323
+ // - stoppedReason: undefined for fallback (no route context)
2324
+ // - session.currentStep: unchanged (no step progression)
1757
2325
  yield {
1758
2326
  delta: chunk.delta,
1759
2327
  accumulated: chunk.accumulated,
@@ -1761,6 +2329,8 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
1761
2329
  session,
1762
2330
  toolCalls: undefined,
1763
2331
  isRouteComplete: false,
2332
+ executedSteps: chunk.done ? [] : undefined,
2333
+ stoppedReason: undefined,
1764
2334
  metadata: chunk.metadata,
1765
2335
  structured: chunk.structured,
1766
2336
  };