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