dv-pipecat-flows 0.0.0.dev2093__tar.gz → 0.0.0.dev2098__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 (114) hide show
  1. {dv_pipecat_flows-0.0.0.dev2093/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.0.dev2098}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/manager.py +86 -9
  4. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/speak_interruption_guard.py +8 -8
  5. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/router_mode.py +5 -0
  6. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/types.py +4 -0
  7. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/SKILL.md +0 -0
  8. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/query-reference.md +0 -0
  9. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/SKILL.md +0 -0
  10. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/query-reference.md +0 -0
  11. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.gitattributes +0 -0
  12. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.gitignore +0 -0
  13. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.pre-commit-config.yaml +0 -0
  14. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.python-version +0 -0
  15. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/.readthedocs.yaml +0 -0
  16. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/AGENTS.md +0 -0
  17. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/CHANGELOG.md +0 -0
  18. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/CLAUDE.md +0 -0
  19. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/CONTRIBUTING.md +0 -0
  20. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/LICENSE +0 -0
  21. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/MANIFEST.in +0 -0
  22. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/README.md +0 -0
  23. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/dev-requirements.txt +0 -0
  24. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/docker-compose.dev.yml +0 -0
  25. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/.eslintrc.json +0 -0
  26. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/.prettierrc +0 -0
  27. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/css/tailwind.css +0 -0
  28. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/food_ordering.json +0 -0
  29. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/movie_explorer.json +0 -0
  30. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/patient_intake.json +0 -0
  31. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/restaurant_reservation.json +0 -0
  32. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/travel_planner.json +0 -0
  33. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.png +0 -0
  34. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.svg +0 -0
  35. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/index.html +0 -0
  36. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/canvas.js +0 -0
  37. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/editorState.js +0 -0
  38. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/sidePanel.js +0 -0
  39. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/toolbar.js +0 -0
  40. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/main.js +0 -0
  41. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/baseNode.js +0 -0
  42. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/endNode.js +0 -0
  43. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/flowNode.js +0 -0
  44. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/functionNode.js +0 -0
  45. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/index.js +0 -0
  46. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/mergeNode.js +0 -0
  47. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/startNode.js +0 -0
  48. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/types.js +0 -0
  49. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/export.js +0 -0
  50. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/helpers.js +0 -0
  51. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/import.js +0 -0
  52. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/validation.js +0 -0
  53. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/jsdoc.json +0 -0
  54. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/package-lock.json +0 -0
  55. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/package.json +0 -0
  56. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/postcss.config.cjs +0 -0
  57. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.png +0 -0
  58. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.svg +0 -0
  59. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/tailwind.config.cjs +0 -0
  60. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/vercel.json +0 -0
  61. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/editor/vite.config.js +0 -0
  62. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/engine_primitives_plan.md +0 -0
  63. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/env.example +0 -0
  64. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/.gitignore +0 -0
  65. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/README.md +0 -0
  66. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/RESULTS.md +0 -0
  67. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -0
  68. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/chunkers.py +0 -0
  69. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/contracts.py +0 -0
  70. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_corpus.py +0 -0
  71. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_dataset.py +0 -0
  72. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_hybrid.py +0 -0
  73. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/templates/insurance.yaml +0 -0
  74. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/.gitignore +0 -0
  75. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_hybrid/manifest.json +0 -0
  76. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval/manifest.json +0 -0
  77. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -0
  78. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_synthetic/manifest.json +0 -0
  79. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_pick.py +0 -0
  80. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_scaling.py +0 -0
  81. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_hybrid.py +0 -0
  82. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_latency.py +0 -0
  83. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_rerank.py +0 -0
  84. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_retrieval.py +0 -0
  85. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/API.md +0 -0
  86. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/__init__.py +0 -0
  87. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/app.py +0 -0
  88. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/jobs.py +0 -0
  89. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/images/food-ordering-flow.png +0 -0
  90. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/pipecat-flows.png +0 -0
  91. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/pipecat_upgrade.md +0 -0
  92. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/pyproject.toml +0 -0
  93. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/README.md +0 -0
  94. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/extensions.conf +0 -0
  95. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/rtp.conf +0 -0
  96. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/requirements.txt +0 -0
  97. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/scripts/check-pypi-package.py +0 -0
  98. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/scripts/fix-ruff.sh +0 -0
  99. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/scripts/pre-commit.sh +0 -0
  100. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/setup.cfg +0 -0
  101. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  102. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  103. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  104. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  105. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/__init__.py +0 -0
  106. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/actions.py +0 -0
  107. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/adapters.py +0 -0
  108. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/condition_evaluator.py +0 -0
  109. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/exceptions.py +0 -0
  110. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/flow_validator.py +0 -0
  111. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/__init__.py +0 -0
  112. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  113. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  114. {dv_pipecat_flows-0.0.0.dev2093 → dv_pipecat_flows-0.0.0.dev2098}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.0.dev2093
3
+ Version: 0.0.0.dev2098
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.0.dev2093
3
+ Version: 0.0.0.dev2098
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
@@ -78,6 +78,7 @@ from pipecat_flows.exceptions import (
78
78
  RouterProviderError,
79
79
  )
80
80
  from pipecat_flows.router_mode import (
81
+ BACK_BRANCH_ID,
81
82
  REPROMPT_IN_PLACE,
82
83
  UNCLEAR_BRANCH_ID,
83
84
  build_router_response_schema,
@@ -218,6 +219,12 @@ class FlowManager:
218
219
  # backwards-compatible if a downstream installs an older engine.
219
220
  self._user_turn_observer: Optional[Any] = None
220
221
 
222
+ self._current_node_entered_ts: float = 0.0
223
+ self._previous_node_entered_ts: float = 0.0
224
+
225
+ self._current_llm_router_name: Optional[str] = None
226
+ self._previous_llm_router_name: Optional[str] = None
227
+
221
228
  # Set up static or dynamic mode
222
229
  if flow_config:
223
230
  warnings.warn(
@@ -967,6 +974,24 @@ In all of these cases, you can provide a `name` in your new node's config for de
967
974
 
968
975
  # Case 1: bare string (Speak / single-target Action).
969
976
  if isinstance(auto_transition, str):
977
+ if node_config.get("interruptible", False):
978
+ observer = self._user_turn_observer
979
+ user_barged_in = (
980
+ observer is not None
981
+ and observer.last_user_stopped_ts
982
+ >= self._current_node_entered_ts
983
+ )
984
+ if user_barged_in and self._current_llm_router_name:
985
+ next_cfg = self._nodes.get(auto_transition) or {}
986
+ if next_cfg.get("llm_mode") != "router":
987
+ logger.info(
988
+ f"Interruptible Speak '{node_config.get('name')}': "
989
+ f"caller barged in; natural-next "
990
+ f"'{auto_transition}' is not a Router; routing "
991
+ f"to most-recent Router "
992
+ f"'{self._current_llm_router_name}' instead"
993
+ )
994
+ return self._current_llm_router_name
970
995
  return auto_transition
971
996
 
972
997
  return None
@@ -1001,7 +1026,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
1001
1026
  # and drops the OpenAILLMContextFrame upstream of the main LLM.
1002
1027
  current_cfg = self._nodes.get(self._current_node) or {}
1003
1028
  needs_deferred_flush = False
1004
- if current_cfg.get("uninterruptible"):
1029
+ if not current_cfg.get("interruptible", False):
1005
1030
  needs_deferred_flush = await self._exit_speak_guard(
1006
1031
  current_cfg, target
1007
1032
  )
@@ -1030,15 +1055,15 @@ In all of these cases, you can provide a `name` in your new node's config for de
1030
1055
  node_config = self._nodes.get(self._current_node) or {}
1031
1056
  return bool(node_config.get("suppress_llm_text"))
1032
1057
 
1033
- def is_speak_uninterruptible(self) -> bool:
1034
- """True when the current node has ``uninterruptible`` set, so
1035
- :class:`pipecat_flows.processors.SpeakInterruptionGuard` swallows the
1036
- interruption cascade and the bot's scripted line lands intact.
1058
+ def is_speak_interruptible(self) -> bool:
1059
+ """True when the current node has ``interruptible`` set, so
1060
+ :class:`pipecat_flows.processors.SpeakInterruptionGuard` lets the
1061
+ interruption cascade through and the caller can barge in.
1037
1062
  """
1038
1063
  if not getattr(self, "_current_node", None):
1039
1064
  return False
1040
1065
  node_config = self._nodes.get(self._current_node) or {}
1041
- return bool(node_config.get("uninterruptible"))
1066
+ return bool(node_config.get("interruptible", False))
1042
1067
 
1043
1068
  async def _enter_speak_guard(self, node_config: Dict[str, Any]) -> None:
1044
1069
  """Reset the user aggregator's recency timers so the
@@ -1141,8 +1166,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
1141
1166
  # `_run_router_node` captures a fresh entry_ts — the next iteration
1142
1167
  # correctly waits for a NEW user reply instead of reclassifying the
1143
1168
  # stale one that triggered the previous UNCLEAR.
1144
- timeout_s = float(node_config.get("router_wait_for_user_timeout", 15.0))
1145
1169
  observer = self._user_turn_observer
1170
+
1171
+ timeout_s = float(node_config.get("router_wait_for_user_timeout", 15.0))
1146
1172
  if observer is not None and timeout_s > 0:
1147
1173
  entry_ts = time.monotonic()
1148
1174
  if observer.last_user_stopped_ts >= entry_ts:
@@ -1206,6 +1232,40 @@ In all of these cases, you can provide a `name` in your new node's config for de
1206
1232
  f"lowerer fills it"
1207
1233
  )
1208
1234
 
1235
+ back_routing_enabled = (
1236
+ self._previous_llm_router_name is not None
1237
+ and self._previous_llm_router_name != node_id
1238
+ )
1239
+ if back_routing_enabled:
1240
+ prev_cfg = self._nodes.get(self._previous_llm_router_name) or {}
1241
+ prev_descriptions = prev_cfg.get("router_branch_descriptions") or {}
1242
+ prev_desc_block = "\n".join(
1243
+ f" - {bid}: {desc}" for bid, desc in prev_descriptions.items()
1244
+ ) or " (previous router branches unavailable)"
1245
+ branches = list(branches) + [
1246
+ {"id": BACK_BRANCH_ID, "next": self._previous_llm_router_name}
1247
+ ]
1248
+ system_prompt = (
1249
+ system_prompt
1250
+ + "\n\n## Re-routing to previous Router\n"
1251
+ + f"In addition to your branches above, you may pick "
1252
+ + f"`{BACK_BRANCH_ID}` if the caller's reply is clearly NOT "
1253
+ + f"answering this Router's question and instead contradicts "
1254
+ + f"or changes the prior intent. The previous Router "
1255
+ + f"(`{self._previous_llm_router_name}`) can re-classify into:\n"
1256
+ + prev_desc_block
1257
+ + "\n\nOnly pick this when the caller's reply belongs at the "
1258
+ + "previous decision level — otherwise prefer your own branches."
1259
+ )
1260
+
1261
+ # Log the FULL router system prompt actually being used at runtime, so we
1262
+ # can confirm what the classifier sees (branches/extract/reasoning) rather
1263
+ # than just the sha1 hash logged after the pick below.
1264
+ logger.info(
1265
+ f"Router '{node_id}' system prompt ({len(system_prompt)} chars):\n"
1266
+ f"{system_prompt}"
1267
+ )
1268
+
1209
1269
  schema = build_router_response_schema(branches, extract)
1210
1270
  valid_branch_ids = [b["id"] for b in branches if isinstance(b, dict) and b.get("id")]
1211
1271
  valid_branch_ids.append(UNCLEAR_BRANCH_ID)
@@ -1350,6 +1410,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
1350
1410
  f"router_unclear observer failed for {node_id}: {observer_exc!r}"
1351
1411
  )
1352
1412
 
1413
+ if back_routing_enabled and branch_id == BACK_BRANCH_ID:
1414
+ back_msg = node_config.get("router_back_transition_message") or ""
1415
+ if back_msg:
1416
+ await self._task.queue_frame(TTSSpeakFrame(text=back_msg))
1417
+ logger.info(
1418
+ f"Router '{node_id}' picked back-branch -> "
1419
+ f"{self._previous_llm_router_name} (msg={back_msg!r})"
1420
+ )
1421
+ return self._previous_llm_router_name
1422
+
1353
1423
  target = pick_branch_target(
1354
1424
  branch_id=branch_id,
1355
1425
  node_config=node_config,
@@ -1541,6 +1611,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
1541
1611
  if not self._initialized:
1542
1612
  raise FlowTransitionError(f"{self.__class__.__name__} must be initialized first")
1543
1613
 
1614
+ self._previous_node_entered_ts = self._current_node_entered_ts
1615
+ self._current_node_entered_ts = time.monotonic()
1616
+
1617
+ if node_config.get("llm_mode") == "router":
1618
+ self._previous_llm_router_name = self._current_llm_router_name
1619
+ self._current_llm_router_name = node_id
1620
+
1544
1621
  # Tool-log every transition so operators see the graph traversal on
1545
1622
  # the same timeline as on-call API tool calls. Capture state BEFORE
1546
1623
  # any of the node-setup logic runs so previous_node_id reflects the
@@ -1584,7 +1661,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
1584
1661
  # Latch the guard's reactive flag (read off ``_current_node``)
1585
1662
  # BEFORE pre_actions execute, so the Speak's tts_say plays under
1586
1663
  # protection. Reset the aggregator's recency timers in lockstep.
1587
- if node_config.get("uninterruptible"):
1664
+ if not node_config.get("interruptible", False):
1588
1665
  self._current_node = node_id
1589
1666
  await self._enter_speak_guard(node_config)
1590
1667
 
@@ -1873,7 +1950,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
1873
1950
  # still latched on ``_current_node`` — clear it so the pipeline
1874
1951
  # doesn't strand with every cancel signal being eaten.
1875
1952
  try:
1876
- if node_config.get("uninterruptible") and self._current_node == node_id:
1953
+ if not node_config.get("interruptible", False) and self._current_node == node_id:
1877
1954
  self._current_node = None
1878
1955
  except Exception:
1879
1956
  pass
@@ -4,13 +4,13 @@
4
4
  # SPDX-License-Identifier: BSD 2-Clause License
5
5
  #
6
6
 
7
- """Swallows interruption-cascade frames while the current node is uninterruptible.
7
+ """Swallows interruption-cascade frames while the current node is non-interruptible.
8
8
 
9
9
  Speak / Action / Transfer / End nodes play scripted TTS that must land in full.
10
10
  The pipeline-wide ``InterruptionFrame`` cascade (cancel-task and flush the
11
11
  output audio queue in ``MediaSender``) would truncate them — so this processor
12
12
  drops the cancel signal in both directions when the FlowManager reports
13
- ``is_speak_uninterruptible()``.
13
+ ``is_speak_interruptible()`` is False.
14
14
 
15
15
  User transcripts still pass through, so they accumulate in
16
16
  ``LLMUserContextAggregator._aggregation`` and the FlowManager flushes (or
@@ -49,35 +49,35 @@ _UPSTREAM_DROP_TYPES: Tuple[Type[Frame], ...] = (InterruptionTaskFrame,)
49
49
 
50
50
 
51
51
  class SpeakInterruptionGuard(FrameProcessor):
52
- """Drops interruption-cascade frames while ``is_uninterruptible_fn()`` returns True.
52
+ """Drops interruption-cascade frames while ``is_interruptible_fn()`` returns False.
53
53
 
54
54
  Same holder-callback pattern as :class:`RouterModeGuard` so the manager can
55
55
  flip state via ``_current_node`` without the processor importing
56
56
  FlowManager directly.
57
57
  """
58
58
 
59
- def __init__(self, is_uninterruptible_fn: Callable[[], bool], **kwargs) -> None:
59
+ def __init__(self, is_interruptible_fn: Callable[[], bool], **kwargs) -> None:
60
60
  super().__init__(**kwargs)
61
- self._is_uninterruptible_fn = is_uninterruptible_fn
61
+ self._is_interruptible_fn = is_interruptible_fn
62
62
 
63
63
  async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
64
64
  await super().process_frame(frame, direction)
65
65
 
66
- if not self._is_uninterruptible_fn():
66
+ if self._is_interruptible_fn():
67
67
  await self.push_frame(frame, direction)
68
68
  return
69
69
 
70
70
  if direction == FrameDirection.DOWNSTREAM and isinstance(frame, _DOWNSTREAM_DROP_TYPES):
71
71
  logger.debug(
72
72
  f"SpeakInterruptionGuard dropping {type(frame).__name__} downstream "
73
- f"during uninterruptible node"
73
+ f"during non-interruptible node"
74
74
  )
75
75
  return
76
76
 
77
77
  if direction == FrameDirection.UPSTREAM and isinstance(frame, _UPSTREAM_DROP_TYPES):
78
78
  logger.debug(
79
79
  f"SpeakInterruptionGuard dropping {type(frame).__name__} upstream "
80
- f"during uninterruptible node"
80
+ f"during non-interruptible node"
81
81
  )
82
82
  return
83
83
 
@@ -44,6 +44,11 @@ logger = logging.getLogger(__name__)
44
44
  # Sentinel returned by the LLM when none of the explicit branches match.
45
45
  UNCLEAR_BRANCH_ID = "__unclear__"
46
46
 
47
+ # Synthetic branch ID injected at runtime when a Router has an upstream LLM
48
+ # Router available, letting the classifier pick "re-route to previous Router"
49
+ # without the author wiring an explicit edge.
50
+ BACK_BRANCH_ID = "__back_to_previous_router__"
51
+
47
52
  # JSON Schema scalar type translation for ExtractSpec.type values.
48
53
  _EXTRACT_TYPE_TO_JSONSCHEMA = {
49
54
  "string": "string",
@@ -573,6 +573,10 @@ class NodeConfig(NodeConfigRequired, total=False):
573
573
  router_exhausted_node: str
574
574
  """Target when ``router_max_unclear`` is exceeded. Typically Transfer or End."""
575
575
 
576
+ skip_wait_if_silent: bool
577
+ """If true and no user transcript has landed since this node was entered,
578
+ skip the wait + classifier call and route via ``router_unclear_node``."""
579
+
576
580
  # Router-Condition (consumed when auto_transition is the sentinel
577
581
  # ``"__condition__"`` AND ``conditions`` is set)
578
582
  conditions: List[Condition]