@falai/agent 0.5.4 → 0.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 (85) hide show
  1. package/README.md +9 -4
  2. package/dist/cjs/core/Agent.d.ts +0 -5
  3. package/dist/cjs/core/Agent.d.ts.map +1 -1
  4. package/dist/cjs/core/Agent.js +75 -157
  5. package/dist/cjs/core/Agent.js.map +1 -1
  6. package/dist/cjs/core/ResponseEngine.js +2 -2
  7. package/dist/cjs/core/ResponseEngine.js.map +1 -1
  8. package/dist/cjs/core/Route.d.ts +6 -1
  9. package/dist/cjs/core/Route.d.ts.map +1 -1
  10. package/dist/cjs/core/Route.js +19 -1
  11. package/dist/cjs/core/Route.js.map +1 -1
  12. package/dist/cjs/core/RoutingEngine.d.ts +68 -2
  13. package/dist/cjs/core/RoutingEngine.d.ts.map +1 -1
  14. package/dist/cjs/core/RoutingEngine.js +416 -2
  15. package/dist/cjs/core/RoutingEngine.js.map +1 -1
  16. package/dist/cjs/core/State.d.ts +1 -2
  17. package/dist/cjs/core/State.d.ts.map +1 -1
  18. package/dist/cjs/core/State.js +5 -6
  19. package/dist/cjs/core/State.js.map +1 -1
  20. package/dist/cjs/core/Transition.d.ts +2 -2
  21. package/dist/cjs/core/Transition.d.ts.map +1 -1
  22. package/dist/cjs/core/Transition.js +3 -2
  23. package/dist/cjs/core/Transition.js.map +1 -1
  24. package/dist/cjs/types/route.d.ts +15 -4
  25. package/dist/cjs/types/route.d.ts.map +1 -1
  26. package/dist/cjs/utils/event.d.ts +6 -0
  27. package/dist/cjs/utils/event.d.ts.map +1 -0
  28. package/dist/cjs/utils/event.js +20 -0
  29. package/dist/cjs/utils/event.js.map +1 -0
  30. package/dist/core/Agent.d.ts +0 -5
  31. package/dist/core/Agent.d.ts.map +1 -1
  32. package/dist/core/Agent.js +74 -156
  33. package/dist/core/Agent.js.map +1 -1
  34. package/dist/core/ResponseEngine.js +2 -2
  35. package/dist/core/ResponseEngine.js.map +1 -1
  36. package/dist/core/Route.d.ts +6 -1
  37. package/dist/core/Route.d.ts.map +1 -1
  38. package/dist/core/Route.js +19 -1
  39. package/dist/core/Route.js.map +1 -1
  40. package/dist/core/RoutingEngine.d.ts +68 -2
  41. package/dist/core/RoutingEngine.d.ts.map +1 -1
  42. package/dist/core/RoutingEngine.js +416 -2
  43. package/dist/core/RoutingEngine.js.map +1 -1
  44. package/dist/core/State.d.ts +1 -2
  45. package/dist/core/State.d.ts.map +1 -1
  46. package/dist/core/State.js +5 -6
  47. package/dist/core/State.js.map +1 -1
  48. package/dist/core/Transition.d.ts +2 -2
  49. package/dist/core/Transition.d.ts.map +1 -1
  50. package/dist/core/Transition.js +3 -2
  51. package/dist/core/Transition.js.map +1 -1
  52. package/dist/types/route.d.ts +15 -4
  53. package/dist/types/route.d.ts.map +1 -1
  54. package/dist/utils/event.d.ts +6 -0
  55. package/dist/utils/event.d.ts.map +1 -0
  56. package/dist/utils/event.js +17 -0
  57. package/dist/utils/event.js.map +1 -0
  58. package/docs/ADAPTERS.md +1 -1
  59. package/docs/API_REFERENCE.md +15 -7
  60. package/docs/ARCHITECTURE.md +25 -5
  61. package/docs/CONSTRUCTOR_OPTIONS.md +2 -2
  62. package/docs/CONTEXT_MANAGEMENT.md +1 -1
  63. package/docs/GETTING_STARTED.md +1 -1
  64. package/docs/PERSISTENCE.md +3 -3
  65. package/examples/business-onboarding.ts +97 -70
  66. package/examples/company-qna-agent.ts +4 -4
  67. package/examples/custom-database-persistence.ts +2 -2
  68. package/examples/declarative-agent.ts +3 -3
  69. package/examples/extracted-data-modification.ts +1 -1
  70. package/examples/healthcare-agent.ts +9 -3
  71. package/examples/openai-agent.ts +1 -1
  72. package/examples/opensearch-persistence.ts +2 -2
  73. package/examples/persistent-onboarding.ts +18 -12
  74. package/examples/prisma-persistence.ts +3 -3
  75. package/examples/redis-persistence.ts +3 -3
  76. package/examples/travel-agent.ts +23 -4
  77. package/package.json +1 -1
  78. package/src/core/Agent.ts +78 -227
  79. package/src/core/ResponseEngine.ts +2 -2
  80. package/src/core/Route.ts +34 -3
  81. package/src/core/RoutingEngine.ts +663 -2
  82. package/src/core/State.ts +6 -13
  83. package/src/core/Transition.ts +6 -3
  84. package/src/types/route.ts +15 -5
  85. package/src/utils/event.ts +16 -0
@@ -1,13 +1,19 @@
1
1
  import type { Event } from "../types/history";
2
2
  import type { Route } from "./Route";
3
+ import type { State } from "./State";
3
4
  import type { StructuredSchema } from "../types/schema";
4
5
  import type { RoutingDecision } from "../types/routing";
5
6
  import type { SessionState } from "../types/session";
7
+ import type { AiProvider } from "../types/ai";
8
+ import { enterRoute, mergeExtracted } from "../types/session";
6
9
  import { PromptComposer } from "./PromptComposer";
10
+ import { getLastMessageFromHistory } from "../utils/event";
7
11
 
8
12
  export interface RoutingDecisionOutput {
9
13
  context: string;
10
14
  routes: Record<string, number>;
15
+ selectedStateId?: string; // For active route, which state to transition to
16
+ stateReasoning?: string; // Why this state was selected
11
17
  responseDirectives?: string[];
12
18
  extractions?: Array<{
13
19
  name: string;
@@ -28,9 +34,601 @@ export interface RoutingEngineOptions {
28
34
  export class RoutingEngine<TContext = unknown> {
29
35
  constructor(private readonly options?: RoutingEngineOptions) {}
30
36
 
37
+ /**
38
+ * Optimized decision for single-route scenarios
39
+ * Skips route scoring and only does state selection
40
+ * @private
41
+ */
42
+ private async decideSingleRouteState(params: {
43
+ route: Route<TContext, unknown>;
44
+ session: SessionState;
45
+ history: Event[];
46
+ agentMeta?: {
47
+ name?: string;
48
+ goal?: string;
49
+ description?: string;
50
+ personality?: string;
51
+ };
52
+ ai: AiProvider;
53
+ context: TContext;
54
+ signal?: AbortSignal;
55
+ }): Promise<{
56
+ selectedRoute?: Route<TContext>;
57
+ selectedState?: State<TContext>;
58
+ responseDirectives?: string[];
59
+ session: SessionState;
60
+ isRouteComplete?: boolean;
61
+ }> {
62
+ const { route, session, history, agentMeta, ai, context, signal } = params;
63
+
64
+ let updatedSession = session;
65
+ const selectedRoute = route;
66
+
67
+ // Enter route if not already in it
68
+ if (!session.currentRoute || session.currentRoute.id !== route.id) {
69
+ updatedSession = enterRoute(session, route.id, route.title);
70
+ if (route.initialData) {
71
+ updatedSession = mergeExtracted(updatedSession, route.initialData);
72
+ console.log(
73
+ `[RoutingEngine] Single-route: Merged initial data:`,
74
+ route.initialData
75
+ );
76
+ }
77
+ console.log(
78
+ `[RoutingEngine] Single-route: Entered route: ${route.title}`
79
+ );
80
+ }
81
+
82
+ // Get candidate states
83
+ const currentState = updatedSession.currentState
84
+ ? route.getState(updatedSession.currentState.id)
85
+ : undefined;
86
+ const candidates = this.getCandidateStates(
87
+ route,
88
+ currentState,
89
+ updatedSession.extracted
90
+ );
91
+
92
+ if (candidates.length === 0) {
93
+ console.warn(`[RoutingEngine] Single-route: No valid states found`);
94
+ return { selectedRoute, session: updatedSession };
95
+ }
96
+
97
+ // If only one candidate, check if route is complete
98
+ if (candidates.length === 1) {
99
+ const isRouteComplete = candidates[0].isRouteComplete;
100
+ if (isRouteComplete) {
101
+ console.log(
102
+ `[RoutingEngine] Single-route: Route complete - all data collected`
103
+ );
104
+ } else {
105
+ console.log(
106
+ `[RoutingEngine] Single-route: Only one valid state: ${candidates[0].state.id}`
107
+ );
108
+ }
109
+ return {
110
+ selectedRoute,
111
+ selectedState: candidates[0].state,
112
+ session: updatedSession,
113
+ isRouteComplete,
114
+ };
115
+ }
116
+
117
+ // Multiple candidates - use AI to select best state
118
+ const lastUserMessage = getLastMessageFromHistory(history);
119
+ const statePrompt = this.buildStateSelectionPrompt(
120
+ route,
121
+ currentState,
122
+ candidates,
123
+ updatedSession.extracted,
124
+ history,
125
+ lastUserMessage,
126
+ agentMeta
127
+ );
128
+
129
+ const stateSchema = this.buildStateSelectionSchema(
130
+ candidates.map((c) => c.state.id)
131
+ );
132
+
133
+ const stateResult = await ai.generateMessage<
134
+ TContext,
135
+ {
136
+ reasoning: string;
137
+ selectedStateId: string;
138
+ responseDirectives?: string[];
139
+ }
140
+ >({
141
+ prompt: statePrompt,
142
+ history,
143
+ context,
144
+ signal,
145
+ parameters: {
146
+ jsonSchema: stateSchema,
147
+ schemaName: "state_selection",
148
+ },
149
+ });
150
+
151
+ const selectedStateId = stateResult.structured?.selectedStateId;
152
+ const selectedState = candidates.find(
153
+ (c) => c.state.id === selectedStateId
154
+ )?.state;
155
+
156
+ if (selectedState) {
157
+ console.log(
158
+ `[RoutingEngine] Single-route: AI selected state: ${selectedState.id}`
159
+ );
160
+ console.log(
161
+ `[RoutingEngine] Single-route: Reasoning: ${stateResult.structured?.reasoning}`
162
+ );
163
+ } else {
164
+ console.warn(
165
+ `[RoutingEngine] Single-route: Invalid state ID returned, using first candidate`
166
+ );
167
+ }
168
+
169
+ return {
170
+ selectedRoute,
171
+ selectedState: selectedState || candidates[0].state,
172
+ responseDirectives: stateResult.structured?.responseDirectives,
173
+ session: updatedSession,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Identify valid next candidate states based on current state and extracted data
179
+ * Returns state with isRouteComplete flag if route is complete (all states skipped + has END_ROUTE transition)
180
+ */
181
+ getCandidateStates<TExtracted = unknown>(
182
+ route: Route<TContext, TExtracted>,
183
+ currentState: State<TContext, TExtracted> | undefined,
184
+ extracted: Partial<TExtracted>
185
+ ): Array<{
186
+ state: State<TContext, TExtracted>;
187
+ condition?: string;
188
+ requiredData?: string[];
189
+ gatherFields?: string[];
190
+ isRouteComplete?: boolean;
191
+ }> {
192
+ const candidates: Array<{
193
+ state: State<TContext, TExtracted>;
194
+ condition?: string;
195
+ requiredData?: string[];
196
+ gatherFields?: string[];
197
+ isRouteComplete?: boolean;
198
+ }> = [];
199
+
200
+ if (!currentState) {
201
+ const initialState = route.initialState;
202
+ if (initialState.shouldSkip(extracted)) {
203
+ const transitions = initialState.getTransitions();
204
+ for (const transition of transitions) {
205
+ const target = transition.getTarget();
206
+ if (target && !target.shouldSkip(extracted)) {
207
+ candidates.push({
208
+ state: target,
209
+ condition: transition.condition,
210
+ requiredData: target.requiredData,
211
+ gatherFields: target.gatherFields,
212
+ });
213
+ }
214
+ }
215
+ } else {
216
+ candidates.push({
217
+ state: initialState,
218
+ requiredData: initialState.requiredData,
219
+ gatherFields: initialState.gatherFields,
220
+ });
221
+ }
222
+ return candidates;
223
+ }
224
+
225
+ const transitions = currentState.getTransitions();
226
+ let hasEndRoute = false;
227
+
228
+ for (const transition of transitions) {
229
+ const target = transition.getTarget();
230
+
231
+ // Check for END_ROUTE transition (no target state)
232
+ if (
233
+ !target &&
234
+ transition.spec.state &&
235
+ typeof transition.spec.state === "symbol"
236
+ ) {
237
+ hasEndRoute = true;
238
+ continue;
239
+ }
240
+
241
+ if (!target) continue;
242
+
243
+ if (target.shouldSkip(extracted)) {
244
+ console.log(
245
+ `[RoutingEngine] Skipping state ${target.id} (skipIf condition met)`
246
+ );
247
+ continue;
248
+ }
249
+
250
+ candidates.push({
251
+ state: target,
252
+ condition: transition.condition,
253
+ requiredData: target.requiredData,
254
+ gatherFields: target.gatherFields,
255
+ });
256
+ }
257
+
258
+ // If no valid candidates found
259
+ if (candidates.length === 0) {
260
+ // If current state has END_ROUTE transition, the route is complete
261
+ if (hasEndRoute) {
262
+ console.log(
263
+ `[RoutingEngine] Route complete: all states processed, END_ROUTE reached`
264
+ );
265
+ // Return current state with completion flag
266
+ return [
267
+ {
268
+ state: currentState,
269
+ condition: "Route complete - all data collected",
270
+ isRouteComplete: true,
271
+ },
272
+ ];
273
+ }
274
+
275
+ // Otherwise, stay in current state if it's still valid
276
+ if (!currentState.shouldSkip(extracted)) {
277
+ candidates.push({
278
+ state: currentState,
279
+ condition: "Continue in current state (no valid transitions)",
280
+ requiredData: currentState.requiredData,
281
+ gatherFields: currentState.gatherFields,
282
+ });
283
+ }
284
+ }
285
+
286
+ return candidates;
287
+ }
288
+
289
+ /**
290
+ * Full routing orchestration: builds prompt and schema, calls AI, selects route/state,
291
+ * and updates the session (including initialData merge when entering a new route).
292
+ *
293
+ * OPTIMIZATION: If there's only 1 route, skips route scoring and only does state selection.
294
+ */
295
+ async decideRouteAndState(params: {
296
+ routes: Route<TContext, unknown>[];
297
+ session: SessionState;
298
+ history: Event[];
299
+ agentMeta?: {
300
+ name?: string;
301
+ goal?: string;
302
+ description?: string;
303
+ personality?: string;
304
+ };
305
+ ai: AiProvider;
306
+ context: TContext;
307
+ signal?: AbortSignal;
308
+ }): Promise<{
309
+ selectedRoute?: Route<TContext>;
310
+ selectedState?: State<TContext>;
311
+ responseDirectives?: string[];
312
+ session: SessionState;
313
+ isRouteComplete?: boolean;
314
+ }> {
315
+ const { routes, session, history, agentMeta, ai, context, signal } = params;
316
+
317
+ if (routes.length === 0) {
318
+ return { session };
319
+ }
320
+
321
+ // OPTIMIZATION: Single route - skip route scoring, only do state selection
322
+ if (routes.length === 1) {
323
+ return this.decideSingleRouteState({
324
+ route: routes[0],
325
+ session,
326
+ history,
327
+ agentMeta,
328
+ ai,
329
+ context,
330
+ signal,
331
+ });
332
+ }
333
+
334
+ const lastUserMessage = getLastMessageFromHistory(history);
335
+
336
+ let activeRouteStates:
337
+ | Array<{
338
+ stateId: string;
339
+ description: string;
340
+ condition?: string;
341
+ requiredData?: string[];
342
+ gatherFields?: string[];
343
+ }>
344
+ | undefined;
345
+ let activeRoute: Route<TContext> | undefined;
346
+ let isRouteComplete = false;
347
+
348
+ if (session.currentRoute) {
349
+ activeRoute = routes.find((r) => r.id === session.currentRoute?.id);
350
+ if (activeRoute) {
351
+ const currentState = session.currentState
352
+ ? activeRoute.getState(session.currentState.id)
353
+ : undefined;
354
+ const candidates = this.getCandidateStates(
355
+ activeRoute,
356
+ currentState,
357
+ session.extracted
358
+ );
359
+
360
+ // Check if route is complete
361
+ if (candidates.length === 1 && candidates[0].isRouteComplete) {
362
+ isRouteComplete = true;
363
+ console.log(
364
+ `[RoutingEngine] Route ${activeRoute.title} is complete - all data collected`
365
+ );
366
+ // Don't include states in routing if route is complete
367
+ activeRouteStates = undefined;
368
+ } else {
369
+ activeRouteStates = candidates.map((c) => ({
370
+ stateId: c.state.id,
371
+ description: c.state.description || "",
372
+ condition: c.condition,
373
+ requiredData: c.requiredData,
374
+ gatherFields: c.gatherFields,
375
+ }));
376
+ console.log(
377
+ `[RoutingEngine] Found ${activeRouteStates.length} candidate states for active route`
378
+ );
379
+ }
380
+ }
381
+ }
382
+
383
+ const routingSchema = this.buildDynamicRoutingSchema(
384
+ routes,
385
+ undefined,
386
+ activeRouteStates
387
+ );
388
+
389
+ const routingPrompt = this.buildRoutingPrompt(
390
+ history,
391
+ routes,
392
+ lastUserMessage,
393
+ agentMeta,
394
+ session,
395
+ activeRouteStates
396
+ );
397
+
398
+ const routingResult = await ai.generateMessage<
399
+ TContext,
400
+ RoutingDecisionOutput
401
+ >({
402
+ prompt: routingPrompt,
403
+ history,
404
+ context,
405
+ signal,
406
+ parameters: {
407
+ jsonSchema: routingSchema,
408
+ schemaName: "routing_output",
409
+ },
410
+ });
411
+
412
+ let selectedRoute: Route<TContext> | undefined;
413
+ let selectedState: State<TContext> | undefined;
414
+ let responseDirectives: string[] | undefined;
415
+ let updatedSession = session;
416
+
417
+ if (routingResult.structured?.routes) {
418
+ const decision = this.decideRouteFromScores({
419
+ context: routingResult.structured.context,
420
+ routes: routingResult.structured.routes,
421
+ responseDirectives: routingResult.structured.responseDirectives,
422
+ });
423
+ selectedRoute = routes.find((r) => r.id === decision.routeId);
424
+ responseDirectives = routingResult.structured.responseDirectives;
425
+
426
+ if (
427
+ selectedRoute === activeRoute &&
428
+ routingResult.structured.selectedStateId &&
429
+ activeRoute
430
+ ) {
431
+ selectedState = activeRoute.getState(
432
+ routingResult.structured.selectedStateId
433
+ );
434
+ if (selectedState) {
435
+ console.log(
436
+ `[RoutingEngine] AI selected state: ${selectedState.id} in active route`
437
+ );
438
+ console.log(
439
+ `[RoutingEngine] State reasoning: ${routingResult.structured.stateReasoning}`
440
+ );
441
+ }
442
+ }
443
+
444
+ if (selectedRoute) {
445
+ console.log(`[RoutingEngine] Selected route: ${selectedRoute.title}`);
446
+ if (
447
+ !session.currentRoute ||
448
+ session.currentRoute.id !== selectedRoute.id
449
+ ) {
450
+ updatedSession = enterRoute(
451
+ session,
452
+ selectedRoute.id,
453
+ selectedRoute.title
454
+ );
455
+ if (selectedRoute.initialData) {
456
+ updatedSession = mergeExtracted(
457
+ updatedSession,
458
+ selectedRoute.initialData
459
+ );
460
+ console.log(
461
+ `[RoutingEngine] Merged initial data:`,
462
+ selectedRoute.initialData
463
+ );
464
+ }
465
+ console.log(`[RoutingEngine] Entered route: ${selectedRoute.title}`);
466
+ }
467
+ }
468
+ }
469
+
470
+ return {
471
+ selectedRoute,
472
+ selectedState,
473
+ responseDirectives,
474
+ session: updatedSession,
475
+ isRouteComplete,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Build prompt for state selection within a single route
481
+ * @private
482
+ */
483
+ private buildStateSelectionPrompt(
484
+ route: Route<TContext>,
485
+ currentState: State<TContext> | undefined,
486
+ candidates: Array<{
487
+ state: State<TContext>;
488
+ condition?: string;
489
+ requiredData?: string[];
490
+ gatherFields?: string[];
491
+ }>,
492
+ extracted: Partial<unknown>,
493
+ history: Event[],
494
+ lastMessage: string,
495
+ agentMeta?: {
496
+ name?: string;
497
+ goal?: string;
498
+ description?: string;
499
+ personality?: string;
500
+ }
501
+ ): string {
502
+ const pc = new PromptComposer();
503
+
504
+ // Add agent metadata
505
+ if (agentMeta?.name || agentMeta?.goal || agentMeta?.description) {
506
+ pc.addAgentMeta({
507
+ name: agentMeta?.name || "Agent",
508
+ description: agentMeta?.description,
509
+ goal: agentMeta?.goal,
510
+ });
511
+ }
512
+
513
+ const personality =
514
+ agentMeta?.personality || "Tone: brief, natural, 1-2 short sentences.";
515
+ pc.addPersonality(personality);
516
+
517
+ // Add route context
518
+ pc.addInstruction(
519
+ `Active Route: ${route.title}\nDescription: ${route.description || "N/A"}`
520
+ );
521
+
522
+ // Add current state context
523
+ if (currentState) {
524
+ pc.addInstruction(
525
+ `Current State: ${currentState.id}\nDescription: ${
526
+ currentState.description || "N/A"
527
+ }`
528
+ );
529
+ } else {
530
+ pc.addInstruction("Current State: None (entering route)");
531
+ }
532
+
533
+ // Add extracted data context
534
+ if (Object.keys(extracted).length > 0) {
535
+ pc.addInstruction(
536
+ `Extracted Data So Far:\n${JSON.stringify(extracted, null, 2)}`
537
+ );
538
+ } else {
539
+ pc.addInstruction("Extracted Data: None yet");
540
+ }
541
+
542
+ // Add conversation history
543
+ pc.addInteractionHistory(history);
544
+ pc.addLastMessage(lastMessage);
545
+
546
+ // Add candidate states
547
+ const stateDescriptions = candidates.map((candidate, idx) => {
548
+ const parts = [
549
+ `${idx + 1}. State ID: ${candidate.state.id}`,
550
+ ` Description: ${candidate.state.description || "N/A"}`,
551
+ ];
552
+
553
+ if (candidate.condition) {
554
+ parts.push(` Condition: ${candidate.condition}`);
555
+ }
556
+
557
+ if (candidate.requiredData && candidate.requiredData.length > 0) {
558
+ parts.push(` Required Data: ${candidate.requiredData.join(", ")}`);
559
+ }
560
+
561
+ if (candidate.gatherFields && candidate.gatherFields.length > 0) {
562
+ parts.push(` Gathers: ${candidate.gatherFields.join(", ")}`);
563
+ }
564
+
565
+ return parts.join("\n");
566
+ });
567
+
568
+ pc.addInstruction(
569
+ `Available States to Transition To:\n${stateDescriptions.join("\n\n")}`
570
+ );
571
+
572
+ // Add decision instructions
573
+ pc.addInstruction(
574
+ [
575
+ "Task: Decide which state to transition to based on:",
576
+ "1. The user's current message and intent",
577
+ "2. The conversation history and context",
578
+ "3. The extracted data we already have",
579
+ "4. The conditions and requirements of each state",
580
+ "5. The logical flow of the conversation",
581
+ "",
582
+ "Rules:",
583
+ "- If a state has a condition, evaluate whether it's met based on context",
584
+ "- If a state requires data we don't have, consider if we should gather it now",
585
+ "- Choose the state that makes the most sense for moving the conversation forward",
586
+ "- States with skipIf conditions that are met have already been filtered out",
587
+ "",
588
+ "Return ONLY JSON matching the provided schema.",
589
+ ].join("\n")
590
+ );
591
+
592
+ return pc.build();
593
+ }
594
+
595
+ /**
596
+ * Build schema for state selection
597
+ * @private
598
+ */
599
+ private buildStateSelectionSchema(validStateIds: string[]): StructuredSchema {
600
+ return {
601
+ description:
602
+ "State transition decision based on conversation context and extracted data",
603
+ type: "object",
604
+ properties: {
605
+ reasoning: {
606
+ type: "string",
607
+ nullable: false,
608
+ description: "Brief explanation of why this state was selected",
609
+ },
610
+ selectedStateId: {
611
+ type: "string",
612
+ nullable: false,
613
+ description: "The ID of the selected state to transition to",
614
+ enum: validStateIds,
615
+ },
616
+ responseDirectives: {
617
+ type: "array",
618
+ items: { type: "string" },
619
+ description:
620
+ "Optional bullet points the response should address (concise)",
621
+ },
622
+ },
623
+ required: ["reasoning", "selectedStateId"],
624
+ additionalProperties: false,
625
+ };
626
+ }
627
+
31
628
  buildDynamicRoutingSchema(
32
629
  routes: Route<TContext>[],
33
- extrasSchema?: StructuredSchema
630
+ extrasSchema?: StructuredSchema,
631
+ activeRouteStates?: { stateId: string; description: string }[]
34
632
  ): StructuredSchema {
35
633
  const routeIds = routes.map((r) => r.id);
36
634
  const routeProperties: Record<string, StructuredSchema> = {};
@@ -72,6 +670,28 @@ export class RoutingEngine<TContext = unknown> {
72
670
  additionalProperties: false,
73
671
  };
74
672
 
673
+ // Add state selection fields if there's an active route with states
674
+ if (activeRouteStates && activeRouteStates.length > 0) {
675
+ base.properties = base.properties || {};
676
+ base.properties.selectedStateId = {
677
+ type: "string",
678
+ nullable: false,
679
+ description:
680
+ "The state ID to transition to within the active route (required if continuing in current route)",
681
+ enum: activeRouteStates.map((s) => s.stateId),
682
+ };
683
+ base.properties.stateReasoning = {
684
+ type: "string",
685
+ nullable: false,
686
+ description: "Brief explanation of why this state was selected",
687
+ };
688
+ base.required = [
689
+ ...(base.required || []),
690
+ "selectedStateId",
691
+ "stateReasoning",
692
+ ];
693
+ }
694
+
75
695
  if (extrasSchema) {
76
696
  base.properties = base.properties || {};
77
697
  base.properties.extractions = extrasSchema;
@@ -90,7 +710,14 @@ export class RoutingEngine<TContext = unknown> {
90
710
  description?: string;
91
711
  personality?: string;
92
712
  },
93
- session?: SessionState
713
+ session?: SessionState,
714
+ activeRouteStates?: Array<{
715
+ stateId: string;
716
+ description: string;
717
+ condition?: string;
718
+ requiredData?: string[];
719
+ gatherFields?: string[];
720
+ }>
94
721
  ): string {
95
722
  const pc = new PromptComposer();
96
723
  if (agentMeta?.name || agentMeta?.goal || agentMeta?.description) {
@@ -128,6 +755,40 @@ export class RoutingEngine<TContext = unknown> {
128
755
  "Note: User is mid-conversation. They may want to continue current route or switch to a new one based on their intent."
129
756
  );
130
757
  pc.addInstruction(sessionInfo.join("\n"));
758
+
759
+ // Add available states for the active route
760
+ if (activeRouteStates && activeRouteStates.length > 0) {
761
+ const stateInfo = [
762
+ "",
763
+ "Available states in active route (choose one to transition to):",
764
+ ];
765
+ activeRouteStates.forEach((state, idx) => {
766
+ stateInfo.push(`${idx + 1}. State: ${state.stateId}`);
767
+ if (state.description) {
768
+ stateInfo.push(` Description: ${state.description}`);
769
+ }
770
+ if (state.condition) {
771
+ stateInfo.push(` Condition: ${state.condition}`);
772
+ }
773
+ if (state.requiredData && state.requiredData.length > 0) {
774
+ stateInfo.push(
775
+ ` Required data: ${state.requiredData.join(", ")}`
776
+ );
777
+ }
778
+ if (state.gatherFields && state.gatherFields.length > 0) {
779
+ stateInfo.push(` Will gather: ${state.gatherFields.join(", ")}`);
780
+ }
781
+ });
782
+ stateInfo.push("");
783
+ stateInfo.push(
784
+ "IMPORTANT: You MUST select a state to transition to. Evaluate which state makes the most sense based on:"
785
+ );
786
+ stateInfo.push("- The conversation flow and what's been collected");
787
+ stateInfo.push("- What data is still needed vs already present");
788
+ stateInfo.push("- The logical next step in the conversation");
789
+ stateInfo.push("- Whether conditions for states are met");
790
+ pc.addInstruction(stateInfo.join("\n"));
791
+ }
131
792
  }
132
793
 
133
794
  pc.addInteractionHistory(history);