rasa-pro 3.12.18.dev1__py3-none-any.whl → 3.12.25__py3-none-any.whl

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.

Potentially problematic release.


This version of rasa-pro might be problematic. Click here for more details.

Files changed (53) hide show
  1. rasa/__init__.py +0 -6
  2. rasa/core/actions/action.py +2 -5
  3. rasa/core/actions/action_repeat_bot_messages.py +18 -22
  4. rasa/core/channels/voice_stream/asr/asr_engine.py +5 -1
  5. rasa/core/channels/voice_stream/asr/azure.py +9 -0
  6. rasa/core/channels/voice_stream/asr/deepgram.py +5 -0
  7. rasa/core/channels/voice_stream/audiocodes.py +9 -4
  8. rasa/core/channels/voice_stream/twilio_media_streams.py +7 -0
  9. rasa/core/channels/voice_stream/voice_channel.py +47 -9
  10. rasa/core/policies/enterprise_search_policy.py +196 -72
  11. rasa/core/policies/intentless_policy.py +1 -3
  12. rasa/core/processor.py +50 -5
  13. rasa/core/utils.py +11 -2
  14. rasa/dialogue_understanding/coexistence/llm_based_router.py +1 -0
  15. rasa/dialogue_understanding/commands/__init__.py +4 -0
  16. rasa/dialogue_understanding/commands/cancel_flow_command.py +3 -1
  17. rasa/dialogue_understanding/commands/correct_slots_command.py +0 -10
  18. rasa/dialogue_understanding/commands/set_slot_command.py +6 -0
  19. rasa/dialogue_understanding/commands/utils.py +26 -2
  20. rasa/dialogue_understanding/generator/command_generator.py +15 -5
  21. rasa/dialogue_understanding/generator/llm_based_command_generator.py +4 -15
  22. rasa/dialogue_understanding/generator/llm_command_generator.py +1 -3
  23. rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +4 -44
  24. rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +1 -14
  25. rasa/dialogue_understanding/processor/command_processor.py +23 -16
  26. rasa/dialogue_understanding/stack/frames/flow_stack_frame.py +17 -4
  27. rasa/dialogue_understanding/stack/utils.py +3 -1
  28. rasa/dialogue_understanding/utils.py +68 -12
  29. rasa/dialogue_understanding_test/du_test_schema.yml +3 -3
  30. rasa/e2e_test/e2e_test_coverage_report.py +1 -1
  31. rasa/e2e_test/e2e_test_schema.yml +3 -3
  32. rasa/hooks.py +0 -55
  33. rasa/llm_fine_tuning/annotation_module.py +43 -11
  34. rasa/llm_fine_tuning/utils.py +2 -4
  35. rasa/shared/constants.py +0 -5
  36. rasa/shared/core/constants.py +1 -0
  37. rasa/shared/core/flows/constants.py +2 -0
  38. rasa/shared/core/flows/flow.py +129 -13
  39. rasa/shared/core/flows/flows_list.py +18 -1
  40. rasa/shared/core/flows/steps/link.py +7 -2
  41. rasa/shared/providers/constants.py +0 -9
  42. rasa/shared/providers/llm/_base_litellm_client.py +4 -14
  43. rasa/shared/providers/llm/litellm_router_llm_client.py +7 -17
  44. rasa/shared/providers/llm/llm_client.py +15 -24
  45. rasa/shared/providers/llm/self_hosted_llm_client.py +2 -10
  46. rasa/tracing/instrumentation/attribute_extractors.py +2 -2
  47. rasa/version.py +1 -1
  48. {rasa_pro-3.12.18.dev1.dist-info → rasa_pro-3.12.25.dist-info}/METADATA +3 -4
  49. {rasa_pro-3.12.18.dev1.dist-info → rasa_pro-3.12.25.dist-info}/RECORD +52 -53
  50. rasa/monkey_patches.py +0 -91
  51. {rasa_pro-3.12.18.dev1.dist-info → rasa_pro-3.12.25.dist-info}/NOTICE +0 -0
  52. {rasa_pro-3.12.18.dev1.dist-info → rasa_pro-3.12.25.dist-info}/WHEEL +0 -0
  53. {rasa_pro-3.12.18.dev1.dist-info → rasa_pro-3.12.25.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,9 @@
1
1
  from contextlib import contextmanager
2
2
  from typing import Any, Dict, Generator, List, Optional, Text
3
3
 
4
- from rasa.dialogue_understanding.commands import Command
4
+ import structlog
5
+
6
+ from rasa.dialogue_understanding.commands import Command, NoopCommand, SetSlotCommand
5
7
  from rasa.dialogue_understanding.constants import (
6
8
  RASA_RECORD_COMMANDS_AND_PROMPTS_ENV_VAR_NAME,
7
9
  )
@@ -16,7 +18,6 @@ from rasa.shared.nlu.constants import (
16
18
  KEY_USER_PROMPT,
17
19
  PREDICTED_COMMANDS,
18
20
  PROMPTS,
19
- SET_SLOT_COMMAND,
20
21
  )
21
22
  from rasa.shared.nlu.training_data.message import Message
22
23
  from rasa.shared.providers.llm.llm_response import LLMResponse
@@ -26,6 +27,8 @@ record_commands_and_prompts = get_bool_env_variable(
26
27
  RASA_RECORD_COMMANDS_AND_PROMPTS_ENV_VAR_NAME, False
27
28
  )
28
29
 
30
+ structlogger = structlog.get_logger()
31
+
29
32
 
30
33
  @contextmanager
31
34
  def set_record_commands_and_prompts() -> Generator:
@@ -144,21 +147,74 @@ def _handle_via_nlu_in_coexistence(
144
147
  if not tracker:
145
148
  return False
146
149
 
150
+ commands = message.get(COMMANDS, [])
151
+
152
+ # If coexistence routing slot is not active, this setup doesn't
153
+ # support dual routing -> default to CALM
147
154
  if not tracker.has_coexistence_routing_slot:
155
+ structlogger.debug(
156
+ "utils.handle_via_nlu_in_coexistence"
157
+ ".tracker_missing_route_session_to_calm_slot",
158
+ event_info=(
159
+ f"Tracker doesn't have the '{ROUTE_TO_CALM_SLOT}' slot."
160
+ f"Routing to CALM."
161
+ ),
162
+ route_session_to_calm=commands,
163
+ )
148
164
  return False
149
165
 
166
+ # Check if the routing decision is stored in the tracker slot
167
+ # If slot is true -> route to CALM
168
+ # If slot is false -> route to DM1
150
169
  value = tracker.get_slot(ROUTE_TO_CALM_SLOT)
151
170
  if value is not None:
171
+ structlogger.debug(
172
+ "utils.handle_via_nlu_in_coexistence"
173
+ ".tracker_route_session_to_calm_slot_value",
174
+ event_info=(
175
+ f"Tracker slot '{ROUTE_TO_CALM_SLOT}' set to '{value}'. "
176
+ f"Routing to "
177
+ f"{'CALM' if value else 'NLU system'}."
178
+ ),
179
+ route_session_to_calm_value_in_tracker=value,
180
+ )
152
181
  return not value
153
182
 
154
- # routing slot has been reset so we need to check
155
- # the command issued by the Router component
156
- if message.get(COMMANDS):
157
- for command in message.get(COMMANDS):
158
- if (
159
- command.get("command") == SET_SLOT_COMMAND
160
- and command.get("name") == ROUTE_TO_CALM_SLOT
161
- ):
162
- return not command.get("value")
163
-
183
+ # Non-sticky routing to DM1 is only allowed if NoopCommand is the sole predicted
184
+ # command. In that case, route to DM1
185
+ if len(commands) == 1 and commands[0].get("command") == NoopCommand.command():
186
+ structlogger.debug(
187
+ "utils.handle_via_nlu_in_coexistence.noop_command_detected",
188
+ event_info="NoopCommand found. Routing to NLU system non-sticky.",
189
+ commands=commands,
190
+ )
191
+ return True
192
+
193
+ # If the slot was reset (e.g. new session), try to infer routing from
194
+ # attached commands. Look for a SetSlotCommand targeting the ROUTE_TO_CALM_SLOT
195
+ for command in message.get(COMMANDS, []):
196
+ # If slot is true -> route to CALM
197
+ # If slot is false -> route to DM1
198
+ if (
199
+ command.get("command") == SetSlotCommand.command()
200
+ and command.get("name") == ROUTE_TO_CALM_SLOT
201
+ ):
202
+ structlogger.debug(
203
+ "utils.handle_via_nlu_in_coexistence.set_slot_command_detected",
204
+ event_info=(
205
+ f"SetSlotCommand setting the '{ROUTE_TO_CALM_SLOT}' to "
206
+ f"'{command.get('value')}'. "
207
+ f"Routing to "
208
+ f"{'CALM' if command.get('value') else 'NLU system'}."
209
+ ),
210
+ commands=commands,
211
+ )
212
+ return not command.get("value")
213
+
214
+ # If no routing info is available -> default to CALM
215
+ structlogger.debug(
216
+ "utils.handle_via_nlu_in_coexistence.no_routing_info_available",
217
+ event_info="No routing info available. Routing to CALM.",
218
+ commands=commands,
219
+ )
164
220
  return False
@@ -5,12 +5,12 @@ mapping:
5
5
  sequence:
6
6
  - type: map
7
7
  mapping:
8
- regex;(^[a-zA-Z_]+[a-zA-Z0-9_]*$):
8
+ regex;(^[a-zA-Z_]+[a-zA-Z0-9_\-]*$):
9
9
  type: "seq"
10
10
  sequence:
11
11
  - type: map
12
12
  mapping:
13
- regex;(^[a-zA-Z_]+[a-zA-Z0-9_]*$):
13
+ regex;(^[a-zA-Z_]+[a-zA-Z0-9_\-]*$):
14
14
  type: any
15
15
 
16
16
  metadata:
@@ -129,7 +129,7 @@ mapping:
129
129
  type: "seq"
130
130
  sequence:
131
131
  - type: "str"
132
- pattern: ^[a-zA-Z_]+[a-zA-Z0-9_]*$
132
+ pattern: ^[a-zA-Z_]+[a-zA-Z0-9_\-]*$
133
133
  metadata:
134
134
  type: "str"
135
135
  pattern: ^[a-zA-Z_]+[a-zA-Z0-9_]*$
@@ -21,7 +21,7 @@ from rasa.shared.core.flows.flow_path import FlowPath, FlowPathsList, PathNode
21
21
  FLOW_NAME_COL_NAME = "Flow Name"
22
22
  NUM_STEPS_COL_NAME = "Num Steps"
23
23
  MISSING_STEPS_COL_NAME = "Missing Steps"
24
- LINE_NUMBERS_COL_NAME = "Line Numbers"
24
+ LINE_NUMBERS_COL_NAME = "Line Numbers for Missing Steps"
25
25
  COVERAGE_COL_NAME = "Coverage"
26
26
 
27
27
  FLOWS_KEY = "flows"
@@ -5,12 +5,12 @@ mapping:
5
5
  sequence:
6
6
  - type: map
7
7
  mapping:
8
- regex;(^[a-zA-Z_]+[a-zA-Z0-9_]*$):
8
+ regex;(^[a-zA-Z_]+[a-zA-Z0-9_\-]*$):
9
9
  type: "seq"
10
10
  sequence:
11
11
  - type: map
12
12
  mapping:
13
- regex;(^[a-zA-Z_]+[a-zA-Z0-9_]*$):
13
+ regex;(^[a-zA-Z_]+[a-zA-Z0-9_\-]*$):
14
14
  type: any
15
15
 
16
16
  metadata:
@@ -129,7 +129,7 @@ mapping:
129
129
  type: "seq"
130
130
  sequence:
131
131
  - type: "str"
132
- pattern: ^[a-zA-Z_]+[a-zA-Z0-9_]*$
132
+ pattern: ^[a-zA-Z_]+[a-zA-Z0-9_\-]*$
133
133
  metadata:
134
134
  type: "str"
135
135
  pattern: ^[a-zA-Z_]+[a-zA-Z0-9_]*$
rasa/hooks.py CHANGED
@@ -1,20 +1,8 @@
1
1
  import argparse
2
2
  import logging
3
- import os
4
3
  from typing import TYPE_CHECKING, List, Optional, Text, Union
5
4
 
6
- import litellm
7
5
  import pluggy
8
- import structlog
9
-
10
- from rasa.shared.providers.constants import (
11
- LANGFUSE_CALLBACK_NAME,
12
- LANGFUSE_HOST_ENV_VAR,
13
- LANGFUSE_PROJECT_ID_ENV_VAR,
14
- LANGFUSE_PUBLIC_KEY_ENV_VAR,
15
- LANGFUSE_SECRET_KEY_ENV_VAR,
16
- RASA_LANGFUSE_INTEGRATION_ENABLED_ENV_VAR,
17
- )
18
6
 
19
7
  # IMPORTANT: do not import anything from rasa here - use scoped imports
20
8
  # this avoids circular imports, as the hooks are used in different places
@@ -30,7 +18,6 @@ if TYPE_CHECKING:
30
18
 
31
19
  hookimpl = pluggy.HookimplMarker("rasa")
32
20
  logger = logging.getLogger(__name__)
33
- structlogger = structlog.get_logger()
34
21
 
35
22
 
36
23
  @hookimpl # type: ignore[misc]
@@ -70,8 +57,6 @@ def configure_commandline(cmdline_arguments: argparse.Namespace) -> Optional[Tex
70
57
  config.configure_tracing(tracer_provider)
71
58
  config.configure_metrics(endpoints_file)
72
59
 
73
- _init_langfuse_integration()
74
-
75
60
  return endpoints_file
76
61
 
77
62
 
@@ -130,43 +115,3 @@ def after_server_stop() -> None:
130
115
 
131
116
  if anon_pipeline is not None:
132
117
  anon_pipeline.stop()
133
-
134
-
135
- def _is_langfuse_integration_enabled() -> bool:
136
- return (
137
- os.environ.get(RASA_LANGFUSE_INTEGRATION_ENABLED_ENV_VAR, "false").lower()
138
- == "true"
139
- )
140
-
141
-
142
- def _init_langfuse_integration() -> None:
143
- if not _is_langfuse_integration_enabled():
144
- structlogger.info(
145
- "hooks._init_langfuse_integration.disabled",
146
- event_info="Langfuse integration is disabled.",
147
- )
148
- return
149
-
150
- if (
151
- not os.environ.get(LANGFUSE_HOST_ENV_VAR)
152
- or not os.environ.get(LANGFUSE_PROJECT_ID_ENV_VAR)
153
- or not os.environ.get(LANGFUSE_PUBLIC_KEY_ENV_VAR)
154
- or not os.environ.get(LANGFUSE_SECRET_KEY_ENV_VAR)
155
- ):
156
- structlogger.warning(
157
- "hooks._init_langfuse_integration.missing_langfuse_keys",
158
- event_info=(
159
- "Langfuse integration is enabled, but some environment variables "
160
- "are missing. Please set LANGFUSE_HOST, LANGFUSE_PROJECT_ID, "
161
- "LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment "
162
- "variables to use Langfuse integration."
163
- ),
164
- )
165
- return
166
-
167
- litellm.success_callback = [LANGFUSE_CALLBACK_NAME]
168
- litellm.failure_callback = [LANGFUSE_CALLBACK_NAME]
169
- structlogger.info(
170
- "hooks.langfuse_callbacks_initialized",
171
- event_info="Langfuse integration initialized.",
172
- )
@@ -9,8 +9,8 @@ from rasa.e2e_test.e2e_test_case import ActualStepOutput, TestCase, TestStep, Te
9
9
  from rasa.e2e_test.e2e_test_runner import TEST_TURNS_TYPE, E2ETestRunner
10
10
  from rasa.llm_fine_tuning.conversations import Conversation, ConversationStep
11
11
  from rasa.llm_fine_tuning.storage import StorageContext
12
- from rasa.shared.core.constants import USER
13
- from rasa.shared.core.events import UserUttered
12
+ from rasa.shared.core.constants import BOT, USER
13
+ from rasa.shared.core.events import BotUttered, UserUttered
14
14
  from rasa.shared.core.trackers import DialogueStateTracker
15
15
  from rasa.shared.exceptions import FinetuningDataPreparationException
16
16
  from rasa.shared.nlu.constants import LLM_COMMANDS, LLM_PROMPT
@@ -83,16 +83,18 @@ def generate_conversation(
83
83
  Conversation.
84
84
  """
85
85
  steps = []
86
- tracker_event_indices = [
87
- i for i, event in enumerate(tracker.events) if isinstance(event, UserUttered)
88
- ]
89
-
90
- if len(test_case.steps) != len(tracker_event_indices):
91
- raise FinetuningDataPreparationException(
92
- "Number of test case steps and tracker events do not match."
93
- )
94
86
 
95
87
  if assertions_used:
88
+ tracker_event_indices = [
89
+ i
90
+ for i, event in enumerate(tracker.events)
91
+ if isinstance(event, UserUttered)
92
+ ]
93
+ if len(test_case.steps) != len(tracker_event_indices):
94
+ raise FinetuningDataPreparationException(
95
+ "Number of test case steps and tracker events do not match."
96
+ )
97
+
96
98
  # we only have user steps, extract the bot response from the bot uttered
97
99
  # events of the test turn
98
100
  for i, (original_step, tracker_event_index) in enumerate(
@@ -110,8 +112,30 @@ def generate_conversation(
110
112
  )
111
113
  steps.extend(_create_bot_test_steps(test_turns[i]))
112
114
  else:
115
+ tracker_event_indices = [
116
+ i
117
+ for i, event in enumerate(tracker.events)
118
+ if isinstance(event, UserUttered) or isinstance(event, BotUttered)
119
+ ]
120
+
121
+ # Generally, we expect one or more bot response(s) for each user utterance
122
+ # in the test case, so that we can evaluate the actual bot response.
123
+ # If the test case ends with one or more user utterance(s) instead,
124
+ # we should thus trim those from the test case steps.
125
+ # This only applies to test cases that have at least one bot utterance;
126
+ # otherwise, all test case steps would be removed.
127
+ has_bot_utterance = any(step.actor == BOT for step in test_case.steps)
128
+ i = len(test_case.steps)
129
+ if has_bot_utterance:
130
+ while i > 0 and test_case.steps[i - 1].actor == USER:
131
+ i -= 1
132
+ test_case_steps = test_case.steps[:i]
133
+
134
+ # If the number of test case steps and tracker events differ,
135
+ # using zip ensures we only process pairs that exist in both lists.
136
+ # Prevents index errors and ensures we don't process unmatched steps or events.
113
137
  for i, (original_step, tracker_event_index) in enumerate(
114
- zip(test_case.steps, tracker_event_indices)
138
+ zip(test_case_steps, tracker_event_indices)
115
139
  ):
116
140
  if original_step.actor == USER:
117
141
  previous_turn = _get_previous_actual_step_output(test_turns, i)
@@ -127,6 +151,14 @@ def generate_conversation(
127
151
  else:
128
152
  steps.append(original_step)
129
153
 
154
+ # the tracker should only include events up to the last bot utterance
155
+ # so that the resulting transcript ends with the last bot utterance too
156
+ # only applies to test cases that have at least one bot utterance
157
+ if has_bot_utterance and test_case.steps and test_case.steps[-1].actor == USER:
158
+ event_to_go_to = tracker_event_indices[len(test_case_steps)] - 1
159
+ timestamp = tracker.events[event_to_go_to].timestamp
160
+ tracker = tracker.travel_back_in_time(timestamp)
161
+
130
162
  # Some messages in an e2e test case could be mapped to commands via
131
163
  # 'NLUCommandAdapter', e.g. the message will not be annotated with a prompt and
132
164
  # commands pair. Only convert steps that have a prompt and commands present into a
@@ -1,6 +1,6 @@
1
1
  from contextlib import contextmanager
2
2
  from datetime import datetime
3
- from typing import Any, Callable, Dict, Generator, List, Optional, Union
3
+ from typing import Callable, Generator, List, Union
4
4
 
5
5
  import structlog
6
6
 
@@ -24,9 +24,7 @@ def make_mock_invoke_llm(commands: str) -> Callable:
24
24
  """
25
25
 
26
26
  async def _mock_invoke_llm(
27
- self: LLMBasedCommandGenerator,
28
- prompt: Union[List[dict], List[str], str],
29
- metadata: Optional[Dict[str, Any]] = None,
27
+ self: LLMBasedCommandGenerator, prompt: Union[List[dict], List[str], str]
30
28
  ) -> LLMResponse:
31
29
  structlogger.debug(
32
30
  f"LLM call intercepted, response mocked. "
rasa/shared/constants.py CHANGED
@@ -342,8 +342,3 @@ ROLE_SYSTEM = "system"
342
342
  # Used for key values in ValidateSlotPatternFlowStackFrame
343
343
  REFILL_UTTER = "refill_utter"
344
344
  REJECTIONS = "rejections"
345
-
346
- LANGFUSE_METADATA_USER_ID = "trace_user_id"
347
- LANGFUSE_METADATA_SESSION_ID = "session_id"
348
- LANGFUSE_CUSTOM_METADATA_DICT = "trace_metadata"
349
- LANGFUSE_TAGS = "tags"
@@ -181,6 +181,7 @@ class SetSlotExtractor(Enum):
181
181
  # the keys for `State` (USER, PREVIOUS_ACTION, SLOTS, ACTIVE_LOOP)
182
182
  # represent the origin of a `SubState`
183
183
  USER = "user"
184
+ BOT = "bot"
184
185
  SLOTS = "slots"
185
186
 
186
187
  USE_TEXT_FOR_FEATURIZATION = "use_text_for_featurization"
@@ -9,3 +9,5 @@ KEY_FILE_PATH = "file_path"
9
9
  KEY_PERSISTED_SLOTS = "persisted_slots"
10
10
  KEY_RUN_PATTERN_COMPLETED = "run_pattern_completed"
11
11
  KEY_TRANSLATION = "translation"
12
+ KEY_CALLED_FLOW = "called_flow"
13
+ KEY_LINKED_FLOW = "linked_flow"
@@ -4,7 +4,7 @@ import copy
4
4
  from dataclasses import dataclass, field
5
5
  from functools import cached_property
6
6
  from pathlib import Path
7
- from typing import Any, Dict, List, Optional, Set, Text, Union
7
+ from typing import Any, Dict, List, Optional, Set, Text, Tuple, Union
8
8
 
9
9
  import structlog
10
10
  from pydantic import BaseModel
@@ -15,10 +15,12 @@ from rasa.engine.language import Language
15
15
  from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX
16
16
  from rasa.shared.core.flows.constants import (
17
17
  KEY_ALWAYS_INCLUDE_IN_PROMPT,
18
+ KEY_CALLED_FLOW,
18
19
  KEY_DESCRIPTION,
19
20
  KEY_FILE_PATH,
20
21
  KEY_ID,
21
22
  KEY_IF,
23
+ KEY_LINKED_FLOW,
22
24
  KEY_NAME,
23
25
  KEY_NLU_TRIGGER,
24
26
  KEY_PERSISTED_SLOTS,
@@ -41,6 +43,7 @@ from rasa.shared.core.flows.steps import (
41
43
  CallFlowStep,
42
44
  CollectInformationFlowStep,
43
45
  EndFlowStep,
46
+ LinkFlowStep,
44
47
  StartFlowStep,
45
48
  )
46
49
  from rasa.shared.core.flows.steps.constants import (
@@ -61,6 +64,8 @@ class FlowLanguageTranslation(BaseModel):
61
64
  """The human-readable name of the flow."""
62
65
 
63
66
  class Config:
67
+ """Configuration for the FlowLanguageTranslation model."""
68
+
64
69
  extra = "ignore"
65
70
 
66
71
 
@@ -232,9 +237,9 @@ class Flow:
232
237
  return translation.name if translation else None
233
238
 
234
239
  def readable_name(self, language: Optional[Language] = None) -> str:
235
- """
236
- Returns the flow's name in the specified language if available; otherwise
237
- falls back to the flow's name, and finally the flow's ID.
240
+ """Returns the flow's name in the specified language if available.
241
+
242
+ Otherwise, falls back to the flow's name, and finally the flow's ID.
238
243
 
239
244
  Args:
240
245
  language: Preferred language code.
@@ -488,6 +493,9 @@ class Flow:
488
493
  current_path: FlowPath,
489
494
  all_paths: FlowPathsList,
490
495
  visited_step_ids: Set[str],
496
+ call_stack: Optional[
497
+ List[Tuple[Optional[FlowStep], Optional[Flow], str]]
498
+ ] = None,
491
499
  ) -> None:
492
500
  """Processes the flow steps recursively.
493
501
 
@@ -496,19 +504,25 @@ class Flow:
496
504
  current_path: The current path being constructed.
497
505
  all_paths: The list where completed paths are added.
498
506
  visited_step_ids: A set of steps that have been visited to avoid cycles.
507
+ call_stack: Tuple list of (flow, path, flow_type) to track path when \
508
+ calling flows through call and link steps.
499
509
 
500
510
  Returns:
501
511
  None: This function modifies all_paths in place by appending new paths
502
512
  as they are found.
503
513
  """
514
+ if call_stack is None:
515
+ call_stack = []
516
+
504
517
  # Check if the step is relevant for testable_paths extraction.
505
- # We only create new path nodes for ActionFlowStep, CallFlowStep and
506
- # CollectInformationFlowStep because these are externally visible
507
- # changes in the assistant's behaviour (trackable in the e2e tests).
518
+ # We only create new path nodes for CollectInformationFlowStep,
519
+ # ActionFlowStep, CallFlowStep and LinkFlowStep,
520
+ # because these are externally visible changes
521
+ # in the assistant's behaviour (trackable in the e2e tests).
508
522
  # For other flow steps, we only follow their links.
509
- # We decided to ignore calls to other flows in our coverage analysis.
510
523
  should_add_node = isinstance(
511
- current_step, (CollectInformationFlowStep, ActionFlowStep, CallFlowStep)
524
+ current_step,
525
+ (CollectInformationFlowStep, ActionFlowStep, CallFlowStep, LinkFlowStep),
512
526
  )
513
527
  if should_add_node:
514
528
  # Add current step to the current path that is being constructed.
@@ -520,10 +534,45 @@ class Flow:
520
534
  )
521
535
  )
522
536
 
537
+ # Check if the current step has already been visited or
538
+ # if the end of the path has been reached.
539
+ # If so, and we’re not within a called flow, we terminate the current path.
540
+ # This also applies for when we're inside a linked flow and reach its end.
541
+ # If we're inside a called flow and reach its end,
542
+ # continue with the next steps in its parent flow.
523
543
  if current_step.id in visited_step_ids or self.is_end_of_path(current_step):
524
- # Found a cycle, or reached an end step, do not proceed further.
525
- all_paths.paths.append(copy.deepcopy(current_path))
526
- # Remove the last node from the path if it was added.
544
+ # Shallow copy is sufficient, since we only pop from the list and
545
+ # don't mutate the objects inside the tuples.
546
+ # The state of FlowStep and Flow does not change during the traversal.
547
+ call_stack_copy = call_stack.copy()
548
+ # parent_flow_type could be any of: None, i.e. main flow,
549
+ # KEY_CALLED_FLOW(=called_flow) or KEY_LINKED_FLOW(=linked_flow)
550
+ parent_step, parent_flow, parent_flow_type = (
551
+ call_stack_copy.pop() if call_stack_copy else (None, None, None)
552
+ )
553
+
554
+ # Check if within a called flow.
555
+ # If within linked flow, stop the traversal as this takes precedence.
556
+ if parent_step and parent_flow_type == KEY_CALLED_FLOW:
557
+ # As we have reached the END step of a called flow, we need to
558
+ # continue with the next links of the parent step.
559
+ if parent_flow is not None:
560
+ for link in parent_step.next.links:
561
+ parent_flow._handle_link(
562
+ current_path,
563
+ all_paths,
564
+ visited_step_ids,
565
+ link,
566
+ call_stack_copy,
567
+ )
568
+
569
+ else:
570
+ # Found a cycle, or reached an end step, do not proceed further.
571
+ all_paths.paths.append(copy.deepcopy(current_path))
572
+
573
+ # Backtrack: remove the last node after reaching a terminal step.
574
+ # Ensures the path is correctly backtracked, after a path ends or
575
+ # a cycle is detected.
527
576
  if should_add_node:
528
577
  current_path.nodes.pop()
529
578
  return
@@ -531,6 +580,62 @@ class Flow:
531
580
  # Mark current step as visited in this path.
532
581
  visited_step_ids.add(current_step.id)
533
582
 
583
+ # If the current step is a call step, we need to resolve the call
584
+ # and continue with the steps of the called flow.
585
+ if isinstance(current_step, CallFlowStep):
586
+ # Get the steps of the called flow and continue with them.
587
+ called_flow = current_step.called_flow_reference
588
+ if called_flow and (
589
+ start_step_in_called_flow := called_flow.first_step_in_flow()
590
+ ):
591
+ call_stack.append((current_step, self, KEY_CALLED_FLOW))
592
+ called_flow._go_over_steps(
593
+ start_step_in_called_flow,
594
+ current_path,
595
+ all_paths,
596
+ visited_step_ids,
597
+ call_stack,
598
+ )
599
+
600
+ # After processing the steps of the called (child) flow,
601
+ # remove them from the visited steps
602
+ # to allow the calling (parent) flow to revisit them later.
603
+ visited_step_ids.remove(current_step.id)
604
+ call_stack.pop()
605
+
606
+ # Backtrack: remove the last node
607
+ # after returning from a called (child) flow.
608
+ # Ensures the parent flow can continue exploring other branches.
609
+ if should_add_node:
610
+ current_path.nodes.pop()
611
+ return
612
+
613
+ # If the current step is a LinkFlowStep, step into the linked flow,
614
+ # process its links, and do not return from that flow anymore.
615
+ if isinstance(current_step, LinkFlowStep):
616
+ # Get the steps of the linked flow and continue with them.
617
+ linked_flow = current_step.linked_flow_reference
618
+ if linked_flow and (
619
+ start_step_in_linked_flow := linked_flow.first_step_in_flow()
620
+ ):
621
+ call_stack.append((current_step, self, KEY_LINKED_FLOW))
622
+ linked_flow._go_over_steps(
623
+ start_step_in_linked_flow,
624
+ current_path,
625
+ all_paths,
626
+ visited_step_ids,
627
+ call_stack,
628
+ )
629
+ visited_step_ids.remove(current_step.id)
630
+ call_stack.pop()
631
+
632
+ # Backtrack: remove the last node
633
+ # after returning from a linked (child) flow.
634
+ # Ensures the parent can continue after the linked flow is processed.
635
+ if should_add_node:
636
+ current_path.nodes.pop()
637
+ return
638
+
534
639
  # Iterate over all links of the current step.
535
640
  for link in current_step.next.links:
536
641
  self._handle_link(
@@ -538,12 +643,15 @@ class Flow:
538
643
  all_paths,
539
644
  visited_step_ids,
540
645
  link,
646
+ call_stack,
541
647
  )
542
648
 
543
649
  # Backtrack the current step and remove it from the path.
544
650
  visited_step_ids.remove(current_step.id)
545
651
 
546
- # Remove the last node from the path if it was added.
652
+ # Backtrack: remove the last node
653
+ # after processing all links of the current step.
654
+ # Ensures the next recursion can start once all links are explored.
547
655
  if should_add_node:
548
656
  current_path.nodes.pop()
549
657
 
@@ -553,6 +661,9 @@ class Flow:
553
661
  all_paths: FlowPathsList,
554
662
  visited_step_ids: Set[str],
555
663
  link: FlowStepLink,
664
+ call_stack: Optional[
665
+ List[Tuple[Optional[FlowStep], Optional[Flow], str]]
666
+ ] = None,
556
667
  ) -> None:
557
668
  """Handles the next step in a flow.
558
669
 
@@ -561,6 +672,8 @@ class Flow:
561
672
  all_paths: The list where completed paths are added.
562
673
  visited_step_ids: A set of steps that have been visited to avoid cycles.
563
674
  link: The link to be followed.
675
+ call_stack: Tuple list of (flow, path, flow_type) to track path when \
676
+ calling flows through call and link steps..
564
677
 
565
678
  Returns:
566
679
  None: This function modifies all_paths in place by appending new paths
@@ -575,6 +688,7 @@ class Flow:
575
688
  current_path,
576
689
  all_paths,
577
690
  visited_step_ids,
691
+ call_stack,
578
692
  )
579
693
  return
580
694
  # IfFlowStepLink and ElseFlowStepLink are conditional links.
@@ -588,6 +702,7 @@ class Flow:
588
702
  current_path,
589
703
  all_paths,
590
704
  visited_step_ids,
705
+ call_stack,
591
706
  )
592
707
  return
593
708
  else:
@@ -598,6 +713,7 @@ class Flow:
598
713
  current_path,
599
714
  all_paths,
600
715
  visited_step_ids,
716
+ call_stack,
601
717
  )
602
718
  return
603
719
 
@@ -36,6 +36,7 @@ class FlowsList:
36
36
  def __post_init__(self) -> None:
37
37
  """Initializes the FlowsList object."""
38
38
  self._resolve_called_flows()
39
+ self._resolve_linked_flows()
39
40
 
40
41
  def __iter__(self) -> Generator[Flow, None, None]:
41
42
  """Iterates over the flows."""
@@ -103,7 +104,10 @@ class FlowsList:
103
104
  )
104
105
 
105
106
  def _resolve_called_flows(self) -> None:
106
- """Resolves the called flows."""
107
+ """Resolves the called flows.
108
+
109
+ `Resolving` here means connecting the step to the actual `Flow` object.
110
+ """
107
111
  from rasa.shared.core.flows.steps import CallFlowStep
108
112
 
109
113
  for flow in self.underlying_flows:
@@ -112,6 +116,19 @@ class FlowsList:
112
116
  # only resolve the reference, if it isn't already resolved
113
117
  step.called_flow_reference = self.flow_by_id(step.call)
114
118
 
119
+ def _resolve_linked_flows(self) -> None:
120
+ """Resolves the linked flows.
121
+
122
+ `Resolving` here means connecting the step to the actual `Flow` object.
123
+ """
124
+ from rasa.shared.core.flows.steps import LinkFlowStep
125
+
126
+ for flow in self.underlying_flows:
127
+ for step in flow.steps:
128
+ if isinstance(step, LinkFlowStep) and not step.linked_flow_reference:
129
+ # only resolve the reference, if it isn't already resolved
130
+ step.linked_flow_reference = self.flow_by_id(step.link)
131
+
115
132
  def as_json_list(self) -> List[Dict[Text, Any]]:
116
133
  """Serialize the FlowsList object to list format and not to the original dict.
117
134