@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.
- package/dist/cjs/core/Agent.d.ts.map +1 -1
- package/dist/cjs/core/Agent.js +3 -1
- package/dist/cjs/core/Agent.js.map +1 -1
- package/dist/cjs/core/FlowRouter.d.ts.map +1 -1
- package/dist/cjs/core/FlowRouter.js +0 -11
- package/dist/cjs/core/FlowRouter.js.map +1 -1
- package/dist/cjs/core/ResponseModal.d.ts +51 -2
- package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
- package/dist/cjs/core/ResponseModal.js +272 -269
- package/dist/cjs/core/ResponseModal.js.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +3 -1
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/types/agent.d.ts +9 -0
- package/dist/cjs/types/agent.d.ts.map +1 -1
- package/dist/cjs/utils/streamingMessage.d.ts +48 -0
- package/dist/cjs/utils/streamingMessage.d.ts.map +1 -0
- package/dist/cjs/utils/streamingMessage.js +210 -0
- package/dist/cjs/utils/streamingMessage.js.map +1 -0
- package/dist/core/Agent.d.ts.map +1 -1
- package/dist/core/Agent.js +3 -1
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/FlowRouter.d.ts.map +1 -1
- package/dist/core/FlowRouter.js +0 -11
- package/dist/core/FlowRouter.js.map +1 -1
- package/dist/core/ResponseModal.d.ts +51 -2
- package/dist/core/ResponseModal.d.ts.map +1 -1
- package/dist/core/ResponseModal.js +272 -269
- package/dist/core/ResponseModal.js.map +1 -1
- package/dist/core/ToolLoopExecutor.js +1 -1
- package/dist/core/ToolLoopExecutor.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +3 -1
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/types/agent.d.ts +9 -0
- package/dist/types/agent.d.ts.map +1 -1
- package/dist/utils/streamingMessage.d.ts +48 -0
- package/dist/utils/streamingMessage.d.ts.map +1 -0
- package/dist/utils/streamingMessage.js +205 -0
- package/dist/utils/streamingMessage.js.map +1 -0
- package/docs/reference/create-agent.md +2 -0
- package/package.json +1 -1
- package/src/core/Agent.ts +3 -1
- package/src/core/FlowRouter.ts +0 -14
- package/src/core/ResponseModal.ts +332 -299
- package/src/core/ToolLoopExecutor.ts +1 -1
- package/src/providers/GeminiProvider.ts +4 -2
- package/src/types/agent.ts +9 -0
- 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
|
-
*
|
|
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
|
|
586
|
+
private async planTurn(
|
|
547
587
|
responseContext: ResponseContext<TContext, TData>
|
|
548
|
-
): Promise<
|
|
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 =
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
623
|
-
//
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
}
|
|
714
|
+
if (!runPostPhase) {
|
|
715
|
+
return {
|
|
716
|
+
session, message, replyOverridden: false,
|
|
717
|
+
triggeredSignals: signalFirings.length > 0 ? signalFirings : undefined,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
726
720
|
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
|
757
|
-
executedSteps
|
|
818
|
+
isFlowComplete,
|
|
819
|
+
executedSteps,
|
|
758
820
|
stoppedReason,
|
|
759
821
|
appliedInstructions,
|
|
760
|
-
triggeredSignals:
|
|
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
|
-
|
|
937
|
-
|
|
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
|
-
//
|
|
952
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
case 'flowComplete': {
|
|
1019
|
+
innerStream = this.streamFlowCompletion({
|
|
1020
|
+
selectedFlow: plan.outcome.selectedFlow,
|
|
996
1021
|
session,
|
|
997
1022
|
context: effectiveContext,
|
|
998
|
-
|
|
1023
|
+
history,
|
|
1024
|
+
historyEvents,
|
|
1025
|
+
stoppedReason: plan.outcome.stoppedReason,
|
|
999
1026
|
});
|
|
1000
|
-
|
|
1001
|
-
|
|
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:
|
|
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
|
-
|
|
1057
|
-
|
|
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
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
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
|
-
|
|
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 =
|
|
1100
|
-
const 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(
|
|
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:
|
|
1111
|
-
triggeredSignals:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1306
|
+
finalDelta = batchResult.finalMessage.startsWith(chunk.accumulated)
|
|
1307
|
+
? batchResult.finalMessage.slice(chunk.accumulated.length)
|
|
1308
|
+
: batchResult.finalMessage;
|
|
1279
1309
|
}
|
|
1280
1310
|
}
|
|
1281
1311
|
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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)
|