@falai/agent 1.0.1 → 1.1.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 (63) hide show
  1. package/dist/cjs/core/Agent.d.ts.map +1 -1
  2. package/dist/cjs/core/Agent.js +1 -3
  3. package/dist/cjs/core/Agent.js.map +1 -1
  4. package/dist/cjs/core/BatchExecutor.d.ts +6 -0
  5. package/dist/cjs/core/BatchExecutor.d.ts.map +1 -1
  6. package/dist/cjs/core/BatchExecutor.js +13 -1
  7. package/dist/cjs/core/BatchExecutor.js.map +1 -1
  8. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  9. package/dist/cjs/core/ResponseModal.js +1 -0
  10. package/dist/cjs/core/ResponseModal.js.map +1 -1
  11. package/dist/cjs/core/RoutingEngine.d.ts +8 -9
  12. package/dist/cjs/core/RoutingEngine.d.ts.map +1 -1
  13. package/dist/cjs/core/RoutingEngine.js +29 -23
  14. package/dist/cjs/core/RoutingEngine.js.map +1 -1
  15. package/dist/cjs/types/agent.d.ts +18 -0
  16. package/dist/cjs/types/agent.d.ts.map +1 -1
  17. package/dist/cjs/types/route.d.ts +1 -1
  18. package/dist/cjs/types/route.d.ts.map +1 -1
  19. package/dist/cjs/types/routing.d.ts +0 -4
  20. package/dist/cjs/types/routing.d.ts.map +1 -1
  21. package/dist/core/Agent.d.ts.map +1 -1
  22. package/dist/core/Agent.js +1 -3
  23. package/dist/core/Agent.js.map +1 -1
  24. package/dist/core/BatchExecutor.d.ts +6 -0
  25. package/dist/core/BatchExecutor.d.ts.map +1 -1
  26. package/dist/core/BatchExecutor.js +13 -1
  27. package/dist/core/BatchExecutor.js.map +1 -1
  28. package/dist/core/ResponseModal.d.ts.map +1 -1
  29. package/dist/core/ResponseModal.js +1 -0
  30. package/dist/core/ResponseModal.js.map +1 -1
  31. package/dist/core/RoutingEngine.d.ts +8 -9
  32. package/dist/core/RoutingEngine.d.ts.map +1 -1
  33. package/dist/core/RoutingEngine.js +29 -23
  34. package/dist/core/RoutingEngine.js.map +1 -1
  35. package/dist/types/agent.d.ts +18 -0
  36. package/dist/types/agent.d.ts.map +1 -1
  37. package/dist/types/route.d.ts +1 -1
  38. package/dist/types/route.d.ts.map +1 -1
  39. package/dist/types/routing.d.ts +0 -4
  40. package/dist/types/routing.d.ts.map +1 -1
  41. package/docs/README.md +6 -17
  42. package/docs/api/README.md +12 -16
  43. package/docs/api/overview.md +11 -7
  44. package/docs/architecture/data-extraction-flow.md +0 -1
  45. package/docs/architecture/multi-step-execution.md +37 -3
  46. package/docs/core/agent/context-management.md +6 -6
  47. package/docs/core/agent/session-management.md +3 -4
  48. package/docs/core/conversation-flows/data-collection.md +1 -1
  49. package/docs/core/conversation-flows/step-transitions.md +4 -0
  50. package/docs/core/conversation-flows/steps.md +3 -1
  51. package/docs/core/routing/intelligent-routing.md +12 -8
  52. package/docs/guides/getting-started/README.md +10 -13
  53. package/docs/guides/migration/README.md +2 -0
  54. package/docs/guides/migration/multi-step-execution.md +29 -9
  55. package/docs/guides/migration/response-modal-refactor.md +2 -2
  56. package/package.json +1 -1
  57. package/src/core/Agent.ts +1 -3
  58. package/src/core/BatchExecutor.ts +22 -1
  59. package/src/core/ResponseModal.ts +1 -0
  60. package/src/core/RoutingEngine.ts +63 -50
  61. package/src/types/agent.ts +18 -0
  62. package/src/types/route.ts +8 -7
  63. package/src/types/routing.ts +0 -5
@@ -103,9 +103,14 @@ const response3 = await agent.respond("2 people");
103
103
 
104
104
  ### After: Multi-Step Execution
105
105
 
106
- Now, multiple steps can execute in a single call when data requirements are satisfied:
106
+ Now, multiple steps can execute in a single call when data requirements are satisfied and `maxStepsPerBatch` is set higher than `1`:
107
107
 
108
108
  ```typescript
109
+ const agent = new Agent({
110
+ // ...
111
+ maxStepsPerBatch: Infinity, // Enable batching
112
+ });
113
+
109
114
  // Turn 1
110
115
  const response = await agent.respond("Book Grand Hotel for 2 on Friday");
111
116
  // Pre-extraction captures: { hotel: "Grand Hotel", date: "Friday", guests: 2 }
@@ -115,6 +120,8 @@ const response = await agent.respond("Book Grand Hotel for 2 on Friday");
115
120
  // Total: 1 LLM call
116
121
  ```
117
122
 
123
+ > **Note:** By default, `maxStepsPerBatch` is `1`, which preserves the classic single-step behavior. You must explicitly opt in to batching.
124
+
118
125
  ## What Changed
119
126
 
120
127
  | Aspect | Before | After |
@@ -326,9 +333,20 @@ const step2 = { skipIf: (d) => !!d.email }; // Skipped if email exists
326
333
  // - Both steps skipped, route may complete immediately
327
334
  ```
328
335
 
329
- ## Opting Out of Batching
336
+ ## Controlling Batching with `maxStepsPerBatch`
337
+
338
+ As of v1.1.0, batching is **off by default** (`maxStepsPerBatch: 1`). To enable multi-step batching, set the option on your agent:
339
+
340
+ ```typescript
341
+ const agent = new Agent({
342
+ name: "Assistant",
343
+ provider: provider,
344
+ maxStepsPerBatch: Infinity, // Batch all eligible steps (v1.0.x behavior)
345
+ // maxStepsPerBatch: 3, // Or cap at 3 steps per batch
346
+ });
347
+ ```
330
348
 
331
- If you need single-step behavior for specific steps, use `requires` to create dependencies:
349
+ If you need single-step behavior for specific steps while batching is enabled, use `requires` to create dependencies:
332
350
 
333
351
  ```typescript
334
352
  // Force step2 to wait for step1's data
@@ -363,11 +381,13 @@ const agent = new Agent({
363
381
 
364
382
  ## Summary
365
383
 
366
- 1. **Multiple steps can now execute together** - reducing LLM calls
367
- 2. **Pre-extraction happens before batch determination** - maximizing batching
368
- 3. **New response fields** - `executedSteps`, `stoppedReason`, `error`
369
- 4. **Hook execution order changed** - all prepare, then LLM, then all finalize
370
- 5. **SkipIf affects batching** - evaluated during batch determination
371
- 6. **Partial progress preserved** - on errors, completed steps are retained
384
+ 1. **`maxStepsPerBatch` defaults to `1`** - single-step execution by default (v1.1.0 breaking change)
385
+ 2. **Set `maxStepsPerBatch: Infinity`** to restore v1.0.x batching behavior
386
+ 3. **Multiple steps can execute together** when batching is enabled, reducing LLM calls
387
+ 4. **Pre-extraction happens before batch determination** - maximizing batching
388
+ 5. **New response fields** - `executedSteps`, `stoppedReason`, `error`
389
+ 6. **Hook execution order changed** - all prepare, then LLM, then all finalize
390
+ 7. **SkipIf affects batching** - evaluated during batch determination
391
+ 8. **Partial progress preserved** - on errors, completed steps are retained
372
392
 
373
393
  The changes improve efficiency and UX while maintaining API compatibility. Most existing code will work without changes, but reviewing hook dependencies and test expectations is recommended.
@@ -513,6 +513,6 @@ The modern `stream()` API is the recommended approach for new streaming implemen
513
513
  ---
514
514
 
515
515
  **Need Help?**
516
- - Check the [examples](../../examples/) directory for complete working examples
516
+ - Check the [examples](../../../examples/) directory for complete working examples
517
517
  - Review the [API documentation](../../api/) for detailed method signatures
518
- - Look at the [streaming examples](../../examples/advanced-patterns/streaming-responses.ts) for side-by-side comparisons
518
+ - Look at the [streaming examples](../../../examples/advanced-patterns/streaming-responses.ts) for side-by-side comparisons
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falai/agent",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Standalone, strongly-typed AI Agent framework with route DSL and AI provider strategy",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
package/src/core/Agent.ts CHANGED
@@ -126,9 +126,7 @@ export class Agent<TContext = any, TData = any> {
126
126
 
127
127
  // Initialize routing engine
128
128
  this.routingEngine = new RoutingEngine<TContext, TData>({
129
- maxCandidates: 5,
130
- allowRouteSwitch: true,
131
- switchThreshold: 70,
129
+ routeSwitchMargin: options.routeSwitchMargin,
132
130
  });
133
131
 
134
132
  // Initialize ResponseModal for handling all response generation
@@ -108,6 +108,12 @@ export interface DetermineBatchParams<TContext, TData> {
108
108
  sessionData: Partial<TData>;
109
109
  /** Agent context for condition evaluation */
110
110
  context: TContext;
111
+ /**
112
+ * Maximum number of steps to include in this batch.
113
+ * When reached, the batch stops with 'max_steps_reached'.
114
+ * Defaults to 1 (single-step execution).
115
+ */
116
+ maxSteps?: number;
111
117
  }
112
118
 
113
119
  /**
@@ -222,7 +228,7 @@ export class BatchExecutor<TContext = unknown, TData = unknown> {
222
228
  * **Validates: Requirements 1.1, 1.4, 1.5, 7.1, 7.2, 7.3**
223
229
  */
224
230
  async determineBatch(params: DetermineBatchParams<TContext, TData>): Promise<BatchResult<TContext, TData>> {
225
- const { route, currentStep, sessionData, context } = params;
231
+ const { route, currentStep, sessionData, context, maxSteps = 1 } = params;
226
232
  const startTime = Date.now();
227
233
 
228
234
  const batchSteps: StepOptions<TContext, TData>[] = [];
@@ -361,6 +367,21 @@ export class BatchExecutor<TContext = unknown, TData = unknown> {
361
367
  batchSize: batchSteps.length,
362
368
  });
363
369
 
370
+ // Check if we've reached the max steps limit
371
+ if (batchSteps.length >= maxSteps) {
372
+ stoppedReason = 'max_steps_reached';
373
+
374
+ logger.debug(`[BatchExecutor] Reached maxStepsPerBatch limit (${maxSteps}), stopping batch`);
375
+
376
+ this.emitBatchEvent('batch_stop', {
377
+ stepId: step.id,
378
+ reason: `Reached maxStepsPerBatch limit (${maxSteps})`,
379
+ stoppedReason: 'max_steps_reached',
380
+ batchSize: batchSteps.length,
381
+ });
382
+ break;
383
+ }
384
+
364
385
  // Move to next step in the sequence
365
386
  const transitions = step.getTransitions();
366
387
  if (transitions.length === 0) {
@@ -575,6 +575,7 @@ export class ResponseModal<TContext = unknown, TData = unknown> {
575
575
  currentStep,
576
576
  sessionData: updatedSession.data || {},
577
577
  context: params.context,
578
+ maxSteps: this.agent.getAgentOptions().maxStepsPerBatch,
578
579
  });
579
580
 
580
581
  batchSteps = batchResult.steps;
@@ -2,7 +2,6 @@ import type {
2
2
  Event,
3
3
  AgentOptions,
4
4
  StructuredSchema,
5
- RoutingDecision,
6
5
  SessionState,
7
6
  AiProvider,
8
7
  TemplateContext,
@@ -36,9 +35,12 @@ export interface RoutingDecisionOutput {
36
35
  }
37
36
 
38
37
  export interface RoutingEngineOptions {
39
- allowRouteSwitch?: boolean;
40
- switchThreshold?: number; // 0-100
41
- maxCandidates?: number;
38
+ /**
39
+ * Score margin the best alternative route must exceed the current route's score
40
+ * by before the agent switches routes. Prevents flip-flopping on marginal differences.
41
+ * @default 15
42
+ */
43
+ routeSwitchMargin?: number;
42
44
  }
43
45
 
44
46
  export interface BuildStepSelectionPromptParams<
@@ -746,7 +748,8 @@ export class RoutingEngine<TContext = unknown, TData = unknown> {
746
748
  const optimalRoute = this.selectOptimalRoute(
747
749
  eligibleRoutes,
748
750
  updatedSession.data || {},
749
- routingResult.structured.routes
751
+ routingResult.structured.routes,
752
+ updatedSession.currentRoute?.id
750
753
  );
751
754
 
752
755
  // If no optimal route found, check why
@@ -908,42 +911,68 @@ export class RoutingEngine<TContext = unknown, TData = unknown> {
908
911
  * @returns Route that should be prioritized for continuation
909
912
  */
910
913
  selectOptimalRoute(
911
- routes: Route<TContext, TData>[],
912
- data: Partial<TData>,
913
- routeScores: Record<string, number>
914
- ): Route<TContext, TData> | undefined {
915
- const completionStatus = this.getRouteCompletionStatus(routes, data);
916
-
917
- // Create weighted scores combining AI intent scores with completion progress
918
- const weightedScores: Array<{ route: Route<TContext, TData>; score: number }> = [];
914
+ routes: Route<TContext, TData>[],
915
+ data: Partial<TData>,
916
+ routeScores: Record<string, number>,
917
+ currentRouteId?: string
918
+ ): Route<TContext, TData> | undefined {
919
+ const completionStatus = this.getRouteCompletionStatus(routes, data);
920
+ const switchMargin = this.options?.routeSwitchMargin ?? 15;
921
+
922
+ // Create weighted scores combining AI intent scores with completion progress
923
+ const weightedScores: Array<{ route: Route<TContext, TData>; score: number }> = [];
924
+
925
+ for (const route of routes) {
926
+ const aiScore = routeScores[route.id] || 0;
927
+ const completionProgress = completionStatus.get(route.id) || 0;
928
+
929
+ // ALWAYS skip fully completed routes to prevent re-entering finished tasks
930
+ if (completionProgress >= 1.0) {
931
+ logger.debug(
932
+ `[RoutingEngine] Excluding completed route: ${route.title} (100% complete)`
933
+ );
934
+ continue;
935
+ }
919
936
 
920
- for (const route of routes) {
921
- const aiScore = routeScores[route.id] || 0;
922
- const completionProgress = completionStatus.get(route.id) || 0;
937
+ // Boost partially complete routes that match user intent
938
+ let weightedScore = aiScore;
939
+ if (completionProgress > 0 && completionProgress < 1.0) {
940
+ weightedScore += (completionProgress * 20); // Up to 20 point boost
941
+ }
923
942
 
924
- // ALWAYS skip fully completed routes to prevent re-entering finished tasks
925
- // Users should not be forced back into completed routes
926
- if (completionProgress >= 1.0) {
927
- logger.debug(
928
- `[RoutingEngine] Excluding completed route: ${route.title} (100% complete)`
929
- );
930
- continue;
943
+ weightedScores.push({ route, score: weightedScore });
931
944
  }
932
945
 
933
- // Boost partially complete routes that match user intent
934
- let weightedScore = aiScore;
935
- if (completionProgress > 0 && completionProgress < 1.0) {
936
- // Boost score for partially complete routes
937
- weightedScore += (completionProgress * 20); // Up to 20 point boost
938
- }
946
+ // Sort by weighted score descending
947
+ weightedScores.sort((a, b) => b.score - a.score);
939
948
 
940
- weightedScores.push({ route, score: weightedScore });
941
- }
949
+ if (weightedScores.length === 0) {
950
+ return undefined;
951
+ }
942
952
 
943
- // Sort by weighted score and return the best option
944
- weightedScores.sort((a, b) => b.score - a.score);
953
+ // Apply sticky routing: if there's a current route, only switch if the
954
+ // best alternative exceeds the current route's score by the configured margin
955
+ if (currentRouteId) {
956
+ const currentEntry = weightedScores.find(e => e.route.id === currentRouteId);
957
+ const bestEntry = weightedScores[0];
958
+
959
+ if (currentEntry && bestEntry.route.id !== currentRouteId) {
960
+ if (bestEntry.score < currentEntry.score + switchMargin) {
961
+ logger.debug(
962
+ `[RoutingEngine] Staying on current route: ${currentEntry.route.title} ` +
963
+ `(current: ${currentEntry.score}, best alternative: ${bestEntry.score}, ` +
964
+ `margin required: ${switchMargin})`
965
+ );
966
+ return currentEntry.route;
967
+ }
968
+ logger.debug(
969
+ `[RoutingEngine] Switching route: ${currentEntry.route.title} → ${bestEntry.route.title} ` +
970
+ `(current: ${currentEntry.score}, alternative: ${bestEntry.score}, ` +
971
+ `margin: ${switchMargin})`
972
+ );
973
+ }
974
+ }
945
975
 
946
- if (weightedScores.length > 0) {
947
976
  logger.debug(
948
977
  `[RoutingEngine] Selected optimal route: ${weightedScores[0].route.title} ` +
949
978
  `(AI: ${routeScores[weightedScores[0].route.id]}, ` +
@@ -953,9 +982,6 @@ export class RoutingEngine<TContext = unknown, TData = unknown> {
953
982
  return weightedScores[0].route;
954
983
  }
955
984
 
956
- return undefined;
957
- }
958
-
959
985
  /**
960
986
  * Build prompt for step selection within a single route
961
987
  * @private
@@ -1380,17 +1406,4 @@ export class RoutingEngine<TContext = unknown, TData = unknown> {
1380
1406
  return pc.build();
1381
1407
  }
1382
1408
 
1383
- decideRouteFromScores(output: RoutingDecision): {
1384
- routeId: string;
1385
- maxScore: number;
1386
- } {
1387
- // Optionally limit candidates and apply switching threshold
1388
- const entries = Object.entries(output.routes).sort((a, b) => b[1] - a[1]);
1389
- const limited = this.options?.maxCandidates
1390
- ? entries.slice(0, this.options.maxCandidates)
1391
- : entries;
1392
- const [topId, topScore] = limited[0] || ["", 0];
1393
- // switchThreshold is enforced by caller when a current route exists
1394
- return { routeId: topId, maxScore: topScore };
1395
- }
1396
1409
  }
@@ -114,6 +114,24 @@ export interface AgentOptions<TContext = unknown, TData = unknown> {
114
114
  schema?: StructuredSchema;
115
115
  /** Initial data to pre-populate when creating the agent */
116
116
  initialData?: Partial<TData>;
117
+ /**
118
+ * Margin (0-100) the best alternative route must exceed the current route's score
119
+ * by before the agent switches. Higher values make the agent "stickier" to the
120
+ * current route. Set to 0 to switch whenever any route scores higher.
121
+ * @default 15
122
+ */
123
+ routeSwitchMargin?: number;
124
+ /**
125
+ * Maximum number of steps to execute in a single batch.
126
+ * Controls how many consecutive steps can run together in one LLM call.
127
+ *
128
+ * - `1` (default): Steps execute one at a time (classic behavior)
129
+ * - `Infinity`: No limit — all eligible steps batch together
130
+ * - Any positive integer: Cap the batch to that many steps
131
+ *
132
+ * @default 1
133
+ */
134
+ maxStepsPerBatch?: number;
117
135
  }
118
136
 
119
137
  /**
@@ -12,13 +12,14 @@ import { Template, ConditionTemplate } from "./template";
12
12
  * Used to indicate the stopping condition for multi-step execution
13
13
  */
14
14
  export type StoppedReason =
15
- | 'needs_input' // Step requires uncollected data
16
- | 'end_route' // Reached END_ROUTE
17
- | 'route_complete' // All Steps processed
18
- | 'prepare_error' // Error in prepare hook
19
- | 'llm_error' // Error during LLM call
20
- | 'validation_error' // Error validating collected data
21
- | 'finalize_error'; // Error in finalize hook (non-fatal, logged)
15
+ | 'needs_input' // Step requires uncollected data
16
+ | 'end_route' // Reached END_ROUTE
17
+ | 'route_complete' // All Steps processed
18
+ | 'max_steps_reached' // Batch hit the maxStepsPerBatch limit
19
+ | 'prepare_error' // Error in prepare hook
20
+ | 'llm_error' // Error during LLM call
21
+ | 'validation_error' // Error validating collected data
22
+ | 'finalize_error'; // Error in finalize hook (non-fatal, logged)
22
23
 
23
24
  /**
24
25
  * Event types for batch execution observability
@@ -8,11 +8,6 @@ export interface RoutingDecision {
8
8
  contextUpdate?: Record<string, unknown>;
9
9
  }
10
10
 
11
- export interface RoutingDecisionWithRoute extends RoutingDecision {
12
- selectedRouteId: string;
13
- maxScore: number;
14
- }
15
-
16
11
  export interface RoutingSchemaOptions {
17
12
  extrasSchema?: StructuredSchema;
18
13
  }