dv-pipecat-flows 0.0.22.dev1516__tar.gz → 0.0.22.dev1700__tar.gz

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 (115) hide show
  1. dv_pipecat_flows-0.0.22.dev1700/.python-version +1 -0
  2. {dv_pipecat_flows-0.0.22.dev1516/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1700}/PKG-INFO +1 -2
  3. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/README.md +0 -1
  4. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -2
  5. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -25
  6. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/actions.py +106 -23
  7. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/adapters.py +245 -0
  8. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/condition_evaluator.py +9 -0
  9. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/manager.py +122 -41
  10. dv_pipecat_flows-0.0.22.dev1516/.python-version +0 -1
  11. dv_pipecat_flows-0.0.22.dev1516/evals/kb/.gitignore +0 -2
  12. dv_pipecat_flows-0.0.22.dev1516/evals/kb/README.md +0 -213
  13. dv_pipecat_flows-0.0.22.dev1516/evals/kb/RESULTS.md +0 -133
  14. dv_pipecat_flows-0.0.22.dev1516/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -713
  15. dv_pipecat_flows-0.0.22.dev1516/evals/kb/chunkers.py +0 -179
  16. dv_pipecat_flows-0.0.22.dev1516/evals/kb/contracts.py +0 -128
  17. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datagen/generate_corpus.py +0 -281
  18. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datagen/generate_dataset.py +0 -413
  19. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datagen/generate_hybrid.py +0 -181
  20. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datagen/templates/insurance.yaml +0 -40
  21. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datasets/.gitignore +0 -9
  22. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datasets/v1_hybrid/manifest.json +0 -33
  23. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datasets/v1_retrieval/manifest.json +0 -16
  24. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -17
  25. dv_pipecat_flows-0.0.22.dev1516/evals/kb/datasets/v1_synthetic/manifest.json +0 -45
  26. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_filter_pick.py +0 -515
  27. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_filter_scaling.py +0 -286
  28. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_hybrid.py +0 -205
  29. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_latency.py +0 -232
  30. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_rerank.py +0 -279
  31. dv_pipecat_flows-0.0.22.dev1516/evals/kb/runners/run_retrieval.py +0 -340
  32. dv_pipecat_flows-0.0.22.dev1516/evals/kb/service/API.md +0 -490
  33. dv_pipecat_flows-0.0.22.dev1516/evals/kb/service/__init__.py +0 -0
  34. dv_pipecat_flows-0.0.22.dev1516/evals/kb/service/app.py +0 -332
  35. dv_pipecat_flows-0.0.22.dev1516/evals/kb/service/jobs.py +0 -230
  36. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.agents/skills/loki-logs/SKILL.md +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.agents/skills/loki-logs/query-reference.md +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.claude/skills/loki-logs/SKILL.md +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.claude/skills/loki-logs/query-reference.md +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.gitattributes +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.gitignore +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.pre-commit-config.yaml +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/.readthedocs.yaml +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/AGENTS.md +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/CHANGELOG.md +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/CLAUDE.md +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/CONTRIBUTING.md +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/LICENSE +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/MANIFEST.in +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/dev-requirements.txt +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/docker-compose.dev.yml +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/.eslintrc.json +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/.prettierrc +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/css/tailwind.css +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/examples/food_ordering.json +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/examples/movie_explorer.json +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/examples/patient_intake.json +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/examples/restaurant_reservation.json +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/examples/travel_planner.json +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/favicon.png +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/favicon.svg +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/index.html +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/editor/canvas.js +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/editor/editorState.js +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/editor/sidePanel.js +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/editor/toolbar.js +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/main.js +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/baseNode.js +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/endNode.js +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/flowNode.js +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/functionNode.js +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/index.js +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/mergeNode.js +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/nodes/startNode.js +0 -0
  75. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/types.js +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/utils/export.js +0 -0
  77. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/utils/helpers.js +0 -0
  78. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/utils/import.js +0 -0
  79. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/js/utils/validation.js +0 -0
  80. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/jsdoc.json +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/package-lock.json +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/package.json +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/postcss.config.cjs +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/public/favicon.png +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/public/favicon.svg +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/tailwind.config.cjs +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/vercel.json +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/editor/vite.config.js +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/engine_primitives_plan.md +0 -0
  90. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/env.example +0 -0
  91. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/images/food-ordering-flow.png +0 -0
  92. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/pipecat-flows.png +0 -0
  93. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/pipecat_upgrade.md +0 -0
  94. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/pyproject.toml +0 -0
  95. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/remote-asterisk-code/README.md +0 -0
  96. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/remote-asterisk-code/extensions.conf +0 -0
  97. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/remote-asterisk-code/rtp.conf +0 -0
  98. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/requirements.txt +0 -0
  99. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/scripts/check-pypi-package.py +0 -0
  100. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/scripts/fix-ruff.sh +0 -0
  101. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/scripts/pre-commit.sh +0 -0
  102. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/setup.cfg +0 -0
  103. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  104. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  105. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  106. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/__init__.py +0 -0
  107. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/exceptions.py +0 -0
  108. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/flow_validator.py +0 -0
  109. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/processors/__init__.py +0 -0
  110. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  111. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  112. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  113. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/router_mode.py +0 -0
  114. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/src/pipecat_flows/types.py +0 -0
  115. {dv_pipecat_flows-0.0.22.dev1516 → dv_pipecat_flows-0.0.22.dev1700}/uv.lock +0 -0
@@ -0,0 +1 @@
1
+ 3.12.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev1516
3
+ Version: 0.0.22.dev1700
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -309,4 +309,3 @@ We aim to review all contributions promptly and provide constructive feedback to
309
309
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
310
310
 
311
311
 
312
-
@@ -284,4 +284,3 @@ We aim to review all contributions promptly and provide constructive feedback to
284
284
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
285
285
 
286
286
 
287
-
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev1516
3
+ Version: 0.0.22.dev1700
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -309,4 +309,3 @@ We aim to review all contributions promptly and provide constructive feedback to
309
309
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
310
310
 
311
311
 
312
-
@@ -60,31 +60,6 @@ editor/js/utils/import.js
60
60
  editor/js/utils/validation.js
61
61
  editor/public/favicon.png
62
62
  editor/public/favicon.svg
63
- evals/kb/.gitignore
64
- evals/kb/README.md
65
- evals/kb/RESULTS.md
66
- evals/kb/UNIFIED_KB_BENCHMARKS.md
67
- evals/kb/chunkers.py
68
- evals/kb/contracts.py
69
- evals/kb/datagen/generate_corpus.py
70
- evals/kb/datagen/generate_dataset.py
71
- evals/kb/datagen/generate_hybrid.py
72
- evals/kb/datagen/templates/insurance.yaml
73
- evals/kb/datasets/.gitignore
74
- evals/kb/datasets/v1_hybrid/manifest.json
75
- evals/kb/datasets/v1_retrieval/manifest.json
76
- evals/kb/datasets/v1_retrieval_long/manifest.json
77
- evals/kb/datasets/v1_synthetic/manifest.json
78
- evals/kb/runners/run_filter_pick.py
79
- evals/kb/runners/run_filter_scaling.py
80
- evals/kb/runners/run_hybrid.py
81
- evals/kb/runners/run_latency.py
82
- evals/kb/runners/run_rerank.py
83
- evals/kb/runners/run_retrieval.py
84
- evals/kb/service/API.md
85
- evals/kb/service/__init__.py
86
- evals/kb/service/app.py
87
- evals/kb/service/jobs.py
88
63
  images/food-ordering-flow.png
89
64
  remote-asterisk-code/README.md
90
65
  remote-asterisk-code/extensions.conf
@@ -26,13 +26,14 @@ Actions are used to perform side effects during conversations, such as:
26
26
  import asyncio
27
27
  import inspect
28
28
  from dataclasses import dataclass
29
- from typing import TYPE_CHECKING, Callable, Dict, List, Optional
29
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
30
30
 
31
31
  from loguru import logger
32
32
  from pipecat.frames.frames import (
33
33
  BotStoppedSpeakingFrame,
34
34
  ControlFrame,
35
35
  EndFrame,
36
+ InterruptionFrame,
36
37
  ManuallySwitchServiceFrame,
37
38
  TTSSpeakFrame,
38
39
  )
@@ -97,6 +98,14 @@ class ActionManager:
97
98
  self._ongoing_actions_count = 0
98
99
  self._ongoing_actions_finished_event = asyncio.Event()
99
100
  self._deferred_post_actions: List[ActionConfig] = []
101
+ # Tracks whether an InterruptionFrame has been observed since the
102
+ # last action increment. The BotStoppedSpeakingFrame "interruption
103
+ # recovery" branch must only fire when a real interruption actually
104
+ # happened — otherwise a stale BotStoppedSpeakingFrame from a prior
105
+ # speech (e.g. cached intro completing while the first Speak node
106
+ # is already starting) would zero a legitimate in-flight count and
107
+ # release _ongoing_actions_finished_event prematurely.
108
+ self._seen_interruption_since_increment = False
100
109
 
101
110
  # Register built-in actions
102
111
  self._register_action("tts_say", self._handle_tts_action)
@@ -105,7 +114,12 @@ class ActionManager:
105
114
 
106
115
  # Add pipeline observation
107
116
  task.set_reached_downstream_filter(
108
- (ActionFinishedFrame, FunctionActionFrame, BotStoppedSpeakingFrame)
117
+ (
118
+ ActionFinishedFrame,
119
+ FunctionActionFrame,
120
+ BotStoppedSpeakingFrame,
121
+ InterruptionFrame,
122
+ )
109
123
  )
110
124
 
111
125
  @task.event_handler("on_frame_reached_downstream")
@@ -147,16 +161,39 @@ class ActionManager:
147
161
  # Defensively reset on this frame so the engine doesn't
148
162
  # deadlock; skip the deferred post-actions since the action
149
163
  # didn't complete naturally.
150
- if self._ongoing_actions_count > 0:
164
+ #
165
+ # Recovery is gated on `_seen_interruption_since_increment`:
166
+ # count>0 alone is ambiguous — a stale BotStoppedSpeakingFrame
167
+ # from a prior speech (e.g. cached intro completing after a
168
+ # new Speak's increment) would otherwise zero a legitimate
169
+ # in-flight count and unblock the next _set_node early.
170
+ if (
171
+ self._ongoing_actions_count > 0
172
+ and self._seen_interruption_since_increment
173
+ ):
151
174
  logger.debug(
152
175
  f"BotStoppedSpeaking with in-flight count="
153
- f"{self._ongoing_actions_count} interrupted action; "
176
+ f"{self._ongoing_actions_count} after InterruptionFrame; "
154
177
  f"resetting to release _ongoing_actions_finished_event"
155
178
  )
156
179
  self._ongoing_actions_count = 0
157
180
  self._ongoing_actions_finished_event.set()
181
+ self._seen_interruption_since_increment = False
182
+ elif self._ongoing_actions_count > 0:
183
+ # Stale frame from prior speech. Ignore — the real
184
+ # in-flight action's ActionFinishedFrame is still coming.
185
+ logger.debug(
186
+ f"BotStoppedSpeaking with in-flight count="
187
+ f"{self._ongoing_actions_count} but no InterruptionFrame "
188
+ f"observed since last increment; treating as stale, "
189
+ f"keeping counter intact"
190
+ )
158
191
  else:
159
192
  await self._execute_deferred_post_actions()
193
+ elif isinstance(frame, InterruptionFrame):
194
+ # Mark — the very next BotStoppedSpeakingFrame is the truncated
195
+ # speech's stop and should trigger the recovery branch above.
196
+ self._seen_interruption_since_increment = True
160
197
  elif isinstance(frame, ActionFinishedFrame):
161
198
  # Handle action finished
162
199
  self._decrement_ongoing_actions_count()
@@ -368,7 +405,7 @@ class ActionManager:
368
405
  To prevent the LLM from immediately responding after TTS in pre-actions,
369
406
  set ``respond_immediately=false`` in the node configuration.
370
407
  """
371
- text = self._pick_tts_variant(action)
408
+ text, text_raw, chosen_lang = self._pick_tts_variant(action)
372
409
  if not text:
373
410
  logger.error("TTS action missing 'text' / 'text_variants' field")
374
411
  return
@@ -387,8 +424,17 @@ class ActionManager:
387
424
  served_from_cache = False
388
425
  if cache_handler is not None:
389
426
  try:
390
- text_raw = action.get("text_raw") or text
391
- served_from_cache = bool(await cache_handler(text=text, text_raw=text_raw))
427
+ # Fall back to text when no raw template was emitted —
428
+ # cache key derivation will treat the rendered text as
429
+ # its own template (lower hit rate but always correct).
430
+ effective_raw = text_raw or text
431
+ served_from_cache = bool(
432
+ await cache_handler(
433
+ text=text,
434
+ text_raw=effective_raw,
435
+ language=chosen_lang,
436
+ )
437
+ )
392
438
  except Exception as e:
393
439
  logger.error(f"tts_say cache handler raised, falling back to live TTS: {e}")
394
440
  served_from_cache = False
@@ -408,24 +454,35 @@ class ActionManager:
408
454
  self._decrement_ongoing_actions_count()
409
455
  logger.error(f"TTS error: {e}")
410
456
 
411
- def _pick_tts_variant(self, action: dict) -> Optional[str]:
412
- """Pick a variation string from ``text_variants`` (or return ``text``).
457
+ def _pick_tts_variant(
458
+ self, action: dict
459
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
460
+ """Pick a variation string from ``text_variants``.
461
+
462
+ Returns ``(text, text_raw, chosen_lang)``:
463
+ * ``text`` — the picked variant (rendered string), or ``action["text"]``
464
+ when no variants are usable.
465
+ * ``text_raw`` — the matching unrendered template (with ``{{var}}``
466
+ markers intact) from ``action["text_raw_variants"][chosen_lang][idx]``
467
+ when present, else ``action["text_raw"]``, else mirrors ``text``.
468
+ Used by the split-cache to derive the stable prefix cache key.
469
+ * ``chosen_lang`` — the ISO key that won, or None when the legacy
470
+ ``text`` fallback was taken. Threaded down to the cache_handler
471
+ so the cache key can be language-scoped (different scripts must
472
+ not share a cache slot).
413
473
 
414
474
  Resolution order for the language key:
415
- 1. ``state["language"]`` (ISO 639-1, set by any Router-LLM's
416
- auto-injected language extract).
475
+ 1. ``state["language"]`` (set by Router-LLM auto-injected extract).
417
476
  2. ``action["fallback_language"]`` (the Speak's primary ISO).
418
477
  3. First key in ``text_variants``.
419
478
 
420
- If a language is chosen but its variant list is empty / missing, falls
421
- back through (2) and (3) in order. Cycling state lives on
422
- :attr:`FlowManager._variant_usage` keyed by ``(speak_node_id, lang)``
423
- every variant index is picked at most once until the set has been
424
- exhausted, at which point it resets and the cycle restarts.
479
+ Cycling state lives on :attr:`FlowManager._variant_usage` keyed by
480
+ ``(speak_node_id, lang)`` every variant index is picked at most
481
+ once until the set has been exhausted, at which point it resets.
425
482
  """
426
483
  variants = action.get("text_variants")
427
484
  if not isinstance(variants, dict) or not variants:
428
- return action.get("text")
485
+ return action.get("text"), action.get("text_raw"), None
429
486
 
430
487
  active = (self._flow_manager.state or {}).get("language") if isinstance(
431
488
  self._flow_manager.state, dict
@@ -454,7 +511,19 @@ class ActionManager:
454
511
  if not chosen_list:
455
512
  # Variants dict is malformed (all keys empty); fall back to the
456
513
  # forward-compat `text` field if the lowerer emitted one.
457
- return action.get("text")
514
+ return action.get("text"), action.get("text_raw"), None
515
+
516
+ # Look up the parallel raw-template list emitted by the backend for
517
+ # this language. Same length as `chosen_list` at lowering time; if
518
+ # absent or short, the caller falls back to chosen_list itself
519
+ # (cache derivation will treat the rendered text as its own
520
+ # template — cache MAY miss but won't garble).
521
+ raw_variants = action.get("text_raw_variants")
522
+ raw_list: List[str] = []
523
+ if isinstance(raw_variants, dict):
524
+ cand_raw = raw_variants.get(chosen_lang or "")
525
+ if isinstance(cand_raw, list):
526
+ raw_list = [s for s in cand_raw if isinstance(s, str)]
458
527
 
459
528
  speak_id = (
460
529
  getattr(self._flow_manager, "_current_node", None)
@@ -465,6 +534,7 @@ class ActionManager:
465
534
  usage = self._flow_manager._variant_usage
466
535
  used = usage.get(usage_key, set())
467
536
  # Pick the smallest index not yet used; reset the set when full.
537
+ picked_idx = 0
468
538
  for idx in range(len(chosen_list)):
469
539
  if idx not in used:
470
540
  used = set(used) | {idx}
@@ -472,11 +542,20 @@ class ActionManager:
472
542
  usage[usage_key] = set() # cycle complete; reset for next round
473
543
  else:
474
544
  usage[usage_key] = used
475
- return chosen_list[idx]
476
-
477
- # Degenerate: usage was already saturated (race) — reset and pick 0.
478
- usage[usage_key] = {0}
479
- return chosen_list[0]
545
+ picked_idx = idx
546
+ break
547
+ else:
548
+ # Degenerate: usage was already saturated (race) — reset and pick 0.
549
+ usage[usage_key] = {0}
550
+ picked_idx = 0
551
+
552
+ picked_text = chosen_list[picked_idx]
553
+ picked_raw = (
554
+ raw_list[picked_idx]
555
+ if 0 <= picked_idx < len(raw_list)
556
+ else picked_text
557
+ )
558
+ return picked_text, picked_raw, chosen_lang
480
559
 
481
560
  async def _maybe_switch_tts_service_for_action(self, action: dict) -> None:
482
561
  """Queue a :class:`ManuallySwitchServiceFrame` when the action carries
@@ -571,6 +650,9 @@ class ActionManager:
571
650
  self._ongoing_actions_count += 1
572
651
  if self._ongoing_actions_count == 1:
573
652
  self._ongoing_actions_finished_event.clear()
653
+ # Reset the interruption flag — any prior InterruptionFrame applied to
654
+ # the previous speech, not this new one.
655
+ self._seen_interruption_since_increment = False
574
656
 
575
657
  def _decrement_ongoing_actions_count(self) -> None:
576
658
  """Decrement the count of ongoing actions and set the finished event if this was the last action."""
@@ -590,3 +672,4 @@ class ActionManager:
590
672
  )
591
673
  self._ongoing_actions_count = 0
592
674
  self._ongoing_actions_finished_event.set()
675
+ self._seen_interruption_since_increment = False
@@ -283,6 +283,138 @@ class UniversalLLMAdapter(LLMAdapter):
283
283
  be in the standard FlowsFunctionSchema format.
284
284
  """
285
285
 
286
+ async def run_router_completion(
287
+ self,
288
+ llm_service: Any,
289
+ system_prompt: str,
290
+ response_schema: Dict[str, Any],
291
+ context_messages: List[Dict[str, Any]],
292
+ *,
293
+ schema_name: str = "router_decision",
294
+ ) -> Optional[str]:
295
+ """Constrained completion for services using the universal LLMContext
296
+ (currently: ``OpenAIResponsesLLMService``).
297
+
298
+ Dispatches by underlying client surface:
299
+ * ``_client.responses`` (OpenAI Responses API) → ``responses.create``
300
+ with ``text.format = {type: json_schema, ...}``
301
+ * ``_client.chat.completions`` (any service exposing OpenAI's
302
+ chat-completions client — e.g. Azure when routed through universal
303
+ context) → falls back to OpenAI-style ``json_schema`` path.
304
+
305
+ Returns the raw JSON string, or None on failure.
306
+ """
307
+ if not hasattr(llm_service, "_client"):
308
+ logger.warning(
309
+ "Universal router completion: llm_service has no _client; "
310
+ "cannot make direct completion call"
311
+ )
312
+ return None
313
+
314
+ model_name = (
315
+ getattr(llm_service, "model_name", None)
316
+ or getattr(llm_service, "_model_name", None)
317
+ or getattr(llm_service, "_model", None)
318
+ )
319
+ if not model_name:
320
+ logger.warning(
321
+ f"Universal router completion: could not resolve model_name on "
322
+ f"{type(llm_service).__name__}; falling back to None."
323
+ )
324
+
325
+ client = llm_service._client
326
+
327
+ # Flatten context to a single input string for the Responses API. The
328
+ # Responses surface accepts a string for `input`; system_prompt goes
329
+ # into `instructions` separately so the model treats it as the
330
+ # authoritative directive (not just another user turn).
331
+ context_text = ""
332
+ if context_messages:
333
+ lines = []
334
+ for msg in context_messages:
335
+ role = msg.get("role", "user")
336
+ content = msg.get("content", "")
337
+ if isinstance(content, list):
338
+ content = "".join(
339
+ p.get("text", "") if isinstance(p, dict) else str(p)
340
+ for p in content
341
+ )
342
+ lines.append(f"{role}: {content}")
343
+ context_text = "\n".join(lines)
344
+
345
+ if hasattr(client, "responses"):
346
+ try:
347
+ response = await client.responses.create(
348
+ model=model_name,
349
+ input=context_text or " ",
350
+ instructions=system_prompt,
351
+ stream=False,
352
+ text={
353
+ "format": {
354
+ "type": "json_schema",
355
+ "name": schema_name,
356
+ "schema": response_schema,
357
+ "strict": True,
358
+ }
359
+ },
360
+ )
361
+ return getattr(response, "output_text", None)
362
+ except Exception as exc: # noqa: BLE001
363
+ logger.error(
364
+ f"Universal (Responses API) router completion failed: {exc!r}; "
365
+ f"falling back to json_object"
366
+ )
367
+ try:
368
+ response = await client.responses.create(
369
+ model=model_name,
370
+ input=context_text or " ",
371
+ instructions=system_prompt,
372
+ stream=False,
373
+ text={"format": {"type": "json_object"}},
374
+ )
375
+ return getattr(response, "output_text", None)
376
+ except Exception as exc2: # noqa: BLE001
377
+ logger.error(
378
+ f"Universal Responses fallback also failed: {exc2!r}"
379
+ )
380
+ return None
381
+
382
+ if hasattr(client, "chat") and hasattr(client.chat, "completions"):
383
+ # Service exposes the OpenAI chat-completions surface even though
384
+ # universal LLMContext is in use. Reuse the OpenAI-style path.
385
+ messages: List[Dict[str, Any]] = [
386
+ {"role": "system", "content": system_prompt}
387
+ ]
388
+ if context_messages:
389
+ messages.extend(context_messages)
390
+ try:
391
+ response = await client.chat.completions.create(
392
+ model=model_name,
393
+ messages=messages,
394
+ temperature=0.1,
395
+ response_format={
396
+ "type": "json_schema",
397
+ "json_schema": {
398
+ "name": schema_name,
399
+ "schema": response_schema,
400
+ "strict": True,
401
+ },
402
+ },
403
+ )
404
+ return response.choices[0].message.content
405
+ except Exception as exc: # noqa: BLE001
406
+ logger.error(
407
+ f"Universal (chat-completions) router completion failed: {exc!r}"
408
+ )
409
+ return None
410
+
411
+ logger.warning(
412
+ f"Universal router completion: llm_service "
413
+ f"{type(llm_service).__name__} exposes neither responses nor "
414
+ f"chat.completions on its _client; no compatible structured-output path."
415
+ )
416
+ return None
417
+
286
418
  def _get_function_name_from_dict(self, function_def: Dict[str, Any]) -> str:
287
419
  raise RuntimeError(
288
420
  "Provider-specific function definitions are not supported in flows using universal LLMContext. Use FlowsFunctionSchemas or direct functions instead."
@@ -591,6 +723,119 @@ class GeminiAdapter(LLMAdapter):
591
723
  and Gemini's as needed.
592
724
  """
593
725
 
726
+ async def run_router_completion(
727
+ self,
728
+ llm_service: Any,
729
+ system_prompt: str,
730
+ response_schema: Dict[str, Any],
731
+ context_messages: List[Dict[str, Any]],
732
+ *,
733
+ schema_name: str = "router_decision",
734
+ ) -> Optional[str]:
735
+ """Gemini constrained completion via ``response_schema`` +
736
+ ``response_mime_type='application/json'``.
737
+
738
+ Accesses ``llm_service._client`` (google-genai SDK) and
739
+ ``llm_service._model_name``, makes a one-shot
740
+ ``generate_content`` outside the streaming pipeline. Returns the
741
+ raw JSON string emitted by the model.
742
+
743
+ Falls back to plain JSON mode (no schema) if the strict
744
+ ``response_schema`` path fails — Gemini's schema validator rejects
745
+ certain JSON Schema features that the universal router schema may
746
+ emit (e.g. nested ``oneOf`` / ``anyOf``).
747
+ """
748
+ if not hasattr(llm_service, "_client"):
749
+ logger.warning(
750
+ "Gemini router completion: llm_service has no _client; "
751
+ "cannot make direct completion call"
752
+ )
753
+ return None
754
+
755
+ model_name = (
756
+ getattr(llm_service, "model_name", None)
757
+ or getattr(llm_service, "_model_name", None)
758
+ or getattr(llm_service, "_model", None)
759
+ )
760
+ if not model_name:
761
+ logger.warning(
762
+ f"Gemini router completion: could not resolve model_name on "
763
+ f"{type(llm_service).__name__}; falling back to None."
764
+ )
765
+
766
+ # google-genai's GenerateContentConfig only ships with the optional
767
+ # `google` extra. Import lazily so non-Gemini deploys aren't forced
768
+ # to install google-genai.
769
+ try:
770
+ from google.genai.types import GenerateContentConfig
771
+ except ImportError:
772
+ logger.error(
773
+ "Gemini router completion: google-genai not installed; "
774
+ "install pipecat-ai[google] to use Router-LLM with Gemini."
775
+ )
776
+ return None
777
+
778
+ # Flatten context into a single user-turn string. Gemini's `contents`
779
+ # accepts Content objects with role+parts, but for a one-shot
780
+ # constrained completion a flat prompt is simpler and avoids the
781
+ # role-alternation requirement (system_instruction handles the system
782
+ # prompt separately).
783
+ context_text = ""
784
+ if context_messages:
785
+ lines = []
786
+ for msg in context_messages:
787
+ role = msg.get("role", "user")
788
+ content = msg.get("content", "")
789
+ if isinstance(content, list):
790
+ content = "".join(
791
+ p.get("text", "") if isinstance(p, dict) else str(p)
792
+ for p in content
793
+ )
794
+ lines.append(f"{role}: {content}")
795
+ context_text = "\n".join(lines)
796
+
797
+ try:
798
+ config = GenerateContentConfig(
799
+ system_instruction=system_prompt,
800
+ response_mime_type="application/json",
801
+ response_schema=response_schema,
802
+ temperature=0.1,
803
+ )
804
+ response = await llm_service._client.aio.models.generate_content(
805
+ model=model_name,
806
+ contents=context_text or " ",
807
+ config=config,
808
+ )
809
+ if response.candidates and response.candidates[0].content:
810
+ for part in response.candidates[0].content.parts:
811
+ if part.text:
812
+ return part.text
813
+ return None
814
+ except Exception as exc: # noqa: BLE001
815
+ logger.error(
816
+ f"Gemini router completion failed: {exc!r}; "
817
+ f"falling back to JSON mode without schema"
818
+ )
819
+ try:
820
+ config = GenerateContentConfig(
821
+ system_instruction=system_prompt,
822
+ response_mime_type="application/json",
823
+ temperature=0.1,
824
+ )
825
+ response = await llm_service._client.aio.models.generate_content(
826
+ model=model_name,
827
+ contents=context_text or " ",
828
+ config=config,
829
+ )
830
+ if response.candidates and response.candidates[0].content:
831
+ for part in response.candidates[0].content.parts:
832
+ if part.text:
833
+ return part.text
834
+ return None
835
+ except Exception as exc2: # noqa: BLE001
836
+ logger.error(f"Gemini router completion fallback also failed: {exc2!r}")
837
+ return None
838
+
594
839
  def _get_function_name_from_dict(self, function_def: Dict[str, Any]) -> str:
595
840
  """Extract function name from Gemini function definition.
596
841
 
@@ -25,6 +25,7 @@ Operator vocabulary (matches the FE's ConditionOperator set):
25
25
 
26
26
  Variable reference forms (matches the rest of the codebase):
27
27
  ``{{name}}`` -> custom/trigger variable, looked up in ``state``
28
+ ``[{name}]`` -> Router/Agent-extracted variable, looked up in ``state``
28
29
  ``((tool.field))`` -> pre-call tool output, looked up in ``tool_outputs``
29
30
  (dotted path; first segment is the tool name,
30
31
  remainder is jsonpath through its response)
@@ -59,6 +60,10 @@ _VALID_OPERATORS = frozenset({
59
60
 
60
61
  _BRACED_VAR_RE = re.compile(r"^\s*\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}\s*$")
61
62
  _PAREN_VAR_RE = re.compile(r"^\s*\(\(\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\)\)\s*$")
63
+ # ``[{name}]`` is the dedicated Router/Agent-extracted-variable token (kept
64
+ # distinct from custom ``{{name}}`` on the FE). Resolves from ``state`` exactly
65
+ # like the braced and bare forms.
66
+ _BRACKET_BRACE_VAR_RE = re.compile(r"^\s*\[\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\]\s*$")
62
67
 
63
68
 
64
69
  def _walk_dotted(obj: Any, path: List[str]) -> Any:
@@ -119,6 +124,10 @@ def _resolve_variable(
119
124
  return None
120
125
  return _walk_dotted(tool_outputs, paren.group(1).split("."))
121
126
 
127
+ bracket_brace = _BRACKET_BRACE_VAR_RE.match(ref)
128
+ if bracket_brace:
129
+ return _walk_dotted(state, bracket_brace.group(1).split("."))
130
+
122
131
  # Bare identifier — treat as state lookup, allowing dotted paths.
123
132
  return _walk_dotted(state, ref.strip().split("."))
124
133