dv-pipecat-flows 0.0.22.dev2000__tar.gz → 0.0.22.dev2100__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 (89) hide show
  1. {dv_pipecat_flows-0.0.22.dev2000/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev2100}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/engine_primitives_plan.md +1 -1
  3. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  4. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/actions.py +23 -1
  5. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/flow_validator.py +8 -2
  6. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/manager.py +117 -20
  7. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/uv.lock +109 -1185
  8. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.agents/skills/loki-logs/SKILL.md +0 -0
  9. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.agents/skills/loki-logs/query-reference.md +0 -0
  10. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.claude/skills/loki-logs/SKILL.md +0 -0
  11. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.claude/skills/loki-logs/query-reference.md +0 -0
  12. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.gitattributes +0 -0
  13. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.gitignore +0 -0
  14. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.pre-commit-config.yaml +0 -0
  15. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.python-version +0 -0
  16. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/.readthedocs.yaml +0 -0
  17. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/AGENTS.md +0 -0
  18. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/CHANGELOG.md +0 -0
  19. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/CLAUDE.md +0 -0
  20. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/CONTRIBUTING.md +0 -0
  21. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/LICENSE +0 -0
  22. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/MANIFEST.in +0 -0
  23. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/README.md +0 -0
  24. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/dev-requirements.txt +0 -0
  25. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/docker-compose.dev.yml +0 -0
  26. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/.eslintrc.json +0 -0
  27. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/.prettierrc +0 -0
  28. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/css/tailwind.css +0 -0
  29. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/food_ordering.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/movie_explorer.json +0 -0
  31. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/patient_intake.json +0 -0
  32. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/restaurant_reservation.json +0 -0
  33. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/travel_planner.json +0 -0
  34. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/favicon.png +0 -0
  35. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/favicon.svg +0 -0
  36. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/index.html +0 -0
  37. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/canvas.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/editorState.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/sidePanel.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/toolbar.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/main.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/baseNode.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/endNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/flowNode.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/functionNode.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/index.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/mergeNode.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/startNode.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/types.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/export.js +0 -0
  51. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/helpers.js +0 -0
  52. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/import.js +0 -0
  53. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/validation.js +0 -0
  54. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/jsdoc.json +0 -0
  55. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/package-lock.json +0 -0
  56. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/package.json +0 -0
  57. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/postcss.config.cjs +0 -0
  58. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/public/favicon.png +0 -0
  59. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/public/favicon.svg +0 -0
  60. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/tailwind.config.cjs +0 -0
  61. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/vercel.json +0 -0
  62. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/editor/vite.config.js +0 -0
  63. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/env.example +0 -0
  64. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/images/food-ordering-flow.png +0 -0
  65. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/pipecat-flows.png +0 -0
  66. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/pipecat_upgrade.md +0 -0
  67. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/pyproject.toml +0 -0
  68. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/README.md +0 -0
  69. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/extensions.conf +0 -0
  70. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/rtp.conf +0 -0
  71. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/requirements.txt +0 -0
  72. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/scripts/check-pypi-package.py +0 -0
  73. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/scripts/fix-ruff.sh +0 -0
  74. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/scripts/pre-commit.sh +0 -0
  75. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/setup.cfg +0 -0
  76. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  77. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  78. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  79. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  80. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/__init__.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/condition_evaluator.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/exceptions.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/__init__.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/router_mode.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev2000
3
+ Version: 0.0.22.dev2100
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
@@ -48,7 +48,7 @@ Phase 1 of `../new_calling_agent_backend/deterministic_agents_plan.md` §6 maps
48
48
  ## 4. New files
49
49
 
50
50
  - `src/pipecat_flows/condition_evaluator.py` — pure-function rule evaluator + variable resolver (no I/O, no manager refs).
51
- - `src/pipecat_flows/flow_validator.py` — static graph validator invoked once at `FlowManager.__init__`; enforces primitive-combination rules and reserved keys.
51
+ - `src/pipecat_flows/flow_validator.py` — static graph validator (skeleton; rule bodies + wiring land in PR-2/3/4). Intended to be invoked once from `FlowManager.initialize()`; will enforce primitive-combination rules and reserved keys. Not yet called at runtime.
52
52
  - `src/pipecat_flows/router_mode.py` — schema builder, prompt builder, response parser, unclear-loop bookkeeping (keeps `manager.py` from ballooning further).
53
53
  - `src/pipecat_flows/processors/router_mode_guard.py` — `FrameProcessor` subclass dropping `LLMTextFrame` etc. when `current_node_config.suppress_llm_text` is true. Owned by flows because the policy is per-node.
54
54
  - `tests/conftest_primitives.py` — shared fixtures: `make_flow_manager(nodes, state=…, llm=FakeLLM())`, fake `LLMSwitcher`, fake adapter, frame-queue sink.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev2000
3
+ Version: 0.0.22.dev2100
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
@@ -46,6 +46,16 @@ if TYPE_CHECKING:
46
46
  from pipecat_flows.manager import FlowManager
47
47
 
48
48
 
49
+ # Deadlock backstop for the "wait until ongoing actions finish" condition-wait.
50
+ # Normally `_ongoing_actions_finished_event` is set when the trailing action's
51
+ # frame (e.g. BotStoppedSpeakingFrame) arrives downstream. But a barge-in can
52
+ # flush that frame while the count stays >0, so the event is never set and the
53
+ # wait hangs forever (no `except` can rescue a never-returning await). This cap
54
+ # is generous on purpose — it must exceed any realistic TTS/function duration
55
+ # so it never truncates legitimate audio, only breaks a genuine hang.
56
+ ONGOING_ACTIONS_WAIT_TIMEOUT_S = 60.0
57
+
58
+
49
59
  @dataclass
50
60
  class FunctionActionFrame(ControlFrame):
51
61
  """Frame containing a function action to be executed.
@@ -364,7 +374,19 @@ class ActionManager:
364
374
  pass
365
375
 
366
376
  if needs_wait:
367
- await self._ongoing_actions_finished_event.wait()
377
+ try:
378
+ await asyncio.wait_for(
379
+ self._ongoing_actions_finished_event.wait(),
380
+ timeout=ONGOING_ACTIONS_WAIT_TIMEOUT_S,
381
+ )
382
+ except asyncio.TimeoutError:
383
+ logger.warning(
384
+ "Timed out after %ss waiting for ongoing actions to finish "
385
+ "(previous=%s); the finished-event was never set — continuing "
386
+ "to avoid a permanent hang.",
387
+ ONGOING_ACTIONS_WAIT_TIMEOUT_S,
388
+ previous_action_type,
389
+ )
368
390
 
369
391
  async def _handle_tts_action(self, action: dict) -> None:
370
392
  """Built-in handler for TTS actions.
@@ -6,8 +6,14 @@
6
6
 
7
7
  """Static validation of FlowConfigs that use the engine-primitive fields.
8
8
 
9
- Invoked once at ``FlowManager.__init__`` (before any node is set) so that
10
- misconfigured graphs fail loudly at startup instead of mid-call. Rules cover
9
+ NOTE: This module is a SKELETON and is NOT yet wired in. ``validate_flow_config``
10
+ is not called from ``FlowManager`` today and every rule body is a stub — there
11
+ is currently no active static validation at startup. The intended design is to
12
+ invoke it once from ``FlowManager.initialize()`` (before any node is set) so
13
+ that misconfigured graphs fail loudly at startup instead of mid-call, raising
14
+ ``FlowInitializationError`` when ``result.errors`` is non-empty. That wiring,
15
+ and the rule bodies, land in PR-2/3/4 alongside the primitives they guard.
16
+ Until then, do not rely on this for any safety guarantee. Planned rules cover
11
17
  the combinatorics of the four primitives we add together:
12
18
 
13
19
  * auto_transition + post_actions (conflicting lifecycle)
@@ -185,7 +185,17 @@ class FlowManager:
185
185
  self._last_router_pick: Optional[Dict[str, Any]] = None
186
186
  self._context_aggregator = context_aggregator
187
187
  self._pending_transition: Optional[Dict[str, Any]] = None
188
- self._pending_function_results: Dict[str, Dict[str, Any]] = {} # Store function results for extraction
188
+ # Result ("success"/"failure") of the most recent function action, set
189
+ # by ActionManager and read by the dict-`auto_transition` resolver.
190
+ # Initialised to None and reset on node entry so a node with a
191
+ # success/failure dict that runs no function action this turn can't
192
+ # read a stale result from a previous node.
193
+ self._last_action_result: Optional[str] = None
194
+ # Strong references to detached fire-and-forget observability tasks.
195
+ # asyncio only holds weak refs to running tasks, so without this set a
196
+ # publish task can be GC'd mid-flight before its inner wait_for
197
+ # completes. Tasks discard themselves on completion (add_done_callback).
198
+ self._bg_tasks: set[asyncio.Task] = set()
189
199
  self._context_strategy = context_strategy or ContextStrategyConfig(
190
200
  strategy=ContextStrategy.APPEND
191
201
  )
@@ -331,10 +341,13 @@ class FlowManager:
331
341
  logger.warning(f"observability publish '{label}' failed: {exc!r}")
332
342
 
333
343
  task = asyncio.create_task(_wrapped())
334
- # Suppress "Task exception was never retrieved" `_wrapped` swallows
335
- # the exception itself, but `create_task` still wants the result
336
- # acknowledged. A no-op callback is enough.
337
- task.add_done_callback(lambda _t: None)
344
+ # Retain a strong reference so the loop's weak-ref bookkeeping can't GC
345
+ # this task before `_wrapped` finishes; the callback both discards the
346
+ # reference and acknowledges the result (suppressing the "Task
347
+ # exception was never retrieved" warning, though `_wrapped` already
348
+ # swallows it).
349
+ self._bg_tasks.add(task)
350
+ task.add_done_callback(self._bg_tasks.discard)
338
351
 
339
352
  @property
340
353
  def transport(self) -> Optional[BaseTransport]:
@@ -682,10 +695,6 @@ class FlowManager:
682
695
  f"{'Transition-only function called for' if is_transition_only_function else 'Function handler completed for'} {name}"
683
696
  )
684
697
 
685
- # Store function result for potential extraction
686
- if result and not is_transition_only_function:
687
- self._pending_function_results[name] = result
688
-
689
698
  # Determine if this is an edge function
690
699
  is_edge_function = (
691
700
  bool(next_node) or bool(transition_to) or bool(transition_callback)
@@ -1039,10 +1048,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
1039
1048
  default_next=default_next,
1040
1049
  )
1041
1050
 
1042
- # Case 2: Action node with success/failure dict.
1051
+ # Case 2: Action node with success/failure dict. `_last_action_result`
1052
+ # is reset to None on node entry and set by this node's own function
1053
+ # action; None (no action ran) and "success" both take the success
1054
+ # branch — only an explicit "failure" routes to the failure target.
1043
1055
  if isinstance(auto_transition, dict):
1044
- result = getattr(self, "_last_action_result", "success")
1045
- key = "failure" if result == "failure" else "success"
1056
+ key = "failure" if self._last_action_result == "failure" else "success"
1046
1057
  return auto_transition.get(key) or auto_transition.get("success")
1047
1058
 
1048
1059
  # Case 1: bare string (Speak / single-target Action).
@@ -1403,7 +1414,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
1403
1414
  # override=Gemini) isn't supported by this path yet.
1404
1415
  llm_service = self.state.get("interrupt_classifier_llm_service") or self._llm
1405
1416
  if isinstance(llm_service, LLMSwitcher):
1406
- llm_service = llm_service.active_service
1417
+ # LLMSwitcher exposes the live service via the `active_llm`
1418
+ # property (it proxies `self.strategy.active_service`). The
1419
+ # switcher itself has no `active_service` attribute — reading it
1420
+ # raises AttributeError. See pipecat/pipeline/llm_switcher.py.
1421
+ llm_service = llm_service.active_llm
1407
1422
 
1408
1423
  valid_ids: List[str] = [RESPEAK_BRANCH_ID]
1409
1424
  for r_id, brs in branches_by_source.items():
@@ -1450,6 +1465,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
1450
1465
  )
1451
1466
  return (natural_next or speak_id, [], False)
1452
1467
 
1468
+ # Allow-list extracted keys to the declared extract-spec names before
1469
+ # merging into shared `state`. The strict JSON schema constrains keys,
1470
+ # but every adapter falls back to schema-less json_object mode on
1471
+ # providers that reject strict schemas — on that path the model's keys
1472
+ # are unbounded. `state` is the same flat dict that holds live runtime
1473
+ # objects/secrets (task, tts_service_map, integration_credentials, ...
1474
+ # merged from flow_dependencies), so an unfiltered merge lets a
1475
+ # hallucinated reserved key clobber them.
1476
+ allowed_keys = {k for m in extract_by_source.values() for k in (m or {})}
1477
+ extracted = {k: v for k, v in extracted.items() if k in allowed_keys}
1453
1478
  if extracted:
1454
1479
  self.state.update(extracted)
1455
1480
  # Mirror the normal-router behavior: surface what the classifier
@@ -1683,7 +1708,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
1683
1708
  # override; otherwise the main LLM.
1684
1709
  llm_service = self._llm
1685
1710
  if isinstance(llm_service, LLMSwitcher):
1686
- llm_service = llm_service.active_service
1711
+ # LLMSwitcher exposes the live service via the `active_llm`
1712
+ # property (it proxies `self.strategy.active_service`). The
1713
+ # switcher itself has no `active_service` attribute — reading it
1714
+ # raises AttributeError. See pipecat/pipeline/llm_switcher.py.
1715
+ llm_service = llm_service.active_llm
1687
1716
 
1688
1717
  raw = await self._adapter.run_router_completion(
1689
1718
  llm_service=llm_service,
@@ -1737,6 +1766,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
1737
1766
  # source for extraction, and applying it would let stray values
1738
1767
  # overwrite legitimate prior extractions. The re-prompt loop will
1739
1768
  # capture the right reply on the next turn.
1769
+ # Allow-list to the declared `router_extract` names before merging —
1770
+ # the schema-less adapter fallback (see `parse_router_response`) can
1771
+ # emit arbitrary keys, and `state` holds live runtime objects/secrets
1772
+ # (task, tts_service_map, integration_credentials, ...) that a stray
1773
+ # reserved key would otherwise overwrite.
1774
+ allowed_keys = set((node_config.get("router_extract") or {}).keys())
1775
+ extracted = {k: v for k, v in extracted.items() if k in allowed_keys}
1740
1776
  if extracted and branch_id != UNCLEAR_BRANCH_ID:
1741
1777
  self.state.update(extracted)
1742
1778
  logger.debug(
@@ -2014,6 +2050,20 @@ In all of these cases, you can provide a `name` in your new node's config for de
2014
2050
  config_key = f"{provider}_{model}"
2015
2051
  target_service = llm_service_map.get(config_key)
2016
2052
  if target_service:
2053
+ # Snapshot the baseline (un-overridden) LLM the first
2054
+ # time we override, so we can switch back when a later
2055
+ # node has no llm_override. The baseline is whatever was
2056
+ # active before any override fired.
2057
+ if previous_llm is None and "llm_baseline" not in self._previous_overrides:
2058
+ baseline = None
2059
+ active = self._llm
2060
+ if isinstance(active, LLMSwitcher):
2061
+ active = active.active_llm
2062
+ for key, svc in llm_service_map.items():
2063
+ if svc is active:
2064
+ baseline = key
2065
+ break
2066
+ self._previous_overrides["llm_baseline"] = baseline
2017
2067
  await task.queue_frames(
2018
2068
  [ManuallySwitchServiceFrame(service=target_service)]
2019
2069
  )
@@ -2028,6 +2078,30 @@ In all of these cases, you can provide a `name` in your new node's config for de
2028
2078
  logger.warning(
2029
2079
  f"Incomplete LLM override config for node {node_id}: {current_llm}"
2030
2080
  )
2081
+ elif not current_llm and previous_llm and task and llm_service_map:
2082
+ # Exiting an llm_override node into one without an override:
2083
+ # restore the snapshotted baseline LLM. Without this the cheap
2084
+ # router model stays pinned for every subsequent generative
2085
+ # node (contract: "the previous LLM is restored on exit").
2086
+ baseline_key = self._previous_overrides.get("llm_baseline")
2087
+ baseline_service = (
2088
+ llm_service_map.get(baseline_key) if baseline_key else None
2089
+ )
2090
+ if baseline_service:
2091
+ await task.queue_frames(
2092
+ [ManuallySwitchServiceFrame(service=baseline_service)]
2093
+ )
2094
+ logger.debug(
2095
+ f"Restored baseline LLM '{baseline_key}' on exit from "
2096
+ f"override node (now at {node_id})"
2097
+ )
2098
+ else:
2099
+ logger.warning(
2100
+ f"Could not restore baseline LLM on exit from override "
2101
+ f"node {node_id}: baseline key '{baseline_key}' not in "
2102
+ f"llm_service_map"
2103
+ )
2104
+ self._previous_overrides.pop("llm_baseline", None)
2031
2105
  self._previous_overrides["llm"] = current_llm
2032
2106
 
2033
2107
  # Future: Handle other overrides (VAD, STT) here
@@ -2201,6 +2275,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
2201
2275
  # Reset leaked action counts from interrupted actions in previous node
2202
2276
  self._action_manager.reset_ongoing_actions()
2203
2277
 
2278
+ # Reset the per-node action result so a success/failure
2279
+ # `auto_transition` dict can't route on the PREVIOUS node's result
2280
+ # if this node runs no function action this turn. A function
2281
+ # pre-action below overwrites this before the resolver reads it.
2282
+ self._last_action_result = None
2283
+
2204
2284
  # Clear any deferred post-actions from previous node
2205
2285
  self._action_manager.clear_deferred_post_actions()
2206
2286
 
@@ -2438,12 +2518,29 @@ In all of these cases, you can provide a `name` in your new node's config for de
2438
2518
  router_target = node_config.get("router_exhausted_next")
2439
2519
  if router_target:
2440
2520
  await self._short_circuit_to(router_target)
2441
- else:
2442
- logger.error(
2443
- f"Router-mode node {node_id} returned no target and no "
2444
- f"router_exhausted_next configured; staying on current node"
2445
- )
2446
- return
2521
+ return
2522
+ # No branch target AND no router_exhausted_next resolved
2523
+ # (reachable from a single misconfigured node: a branch with an
2524
+ # unset `next`, the unclear cap hit with no router_exhausted_next,
2525
+ # or under-cap with neither reprompt nor router_unclear_next).
2526
+ # Silently returning would strand the call: `suppress_llm_text`
2527
+ # is latched on this node so RouterModeGuard gates the generative
2528
+ # LLM, and the StopUserIdleProcessorFrame queued at entry is
2529
+ # never matched by a StartUserIdleProcessorFrame below — idle
2530
+ # recovery is disabled too → permanent dead-air with no escape.
2531
+ # Fail loudly instead: clear the suppression latch, re-enable
2532
+ # idle recovery so teardown isn't wedged, then raise.
2533
+ logger.error(
2534
+ f"Router-mode node {node_id} returned no target and no "
2535
+ f"router_exhausted_next configured; failing the transition "
2536
+ f"to avoid permanent dead-air"
2537
+ )
2538
+ self._current_node = None # release is_suppressing_llm_text() latch
2539
+ await self._task.queue_frame(StartUserIdleProcessorFrame())
2540
+ raise FlowError(
2541
+ f"Router-mode node {node_id} has no resolvable target and no "
2542
+ f"router_exhausted_next; this is a flow misconfiguration"
2543
+ )
2447
2544
 
2448
2545
  # Register functions and prepare tools
2449
2546
  tools: List[FlowsFunctionSchema | FlowsDirectFunctionWrapper] = []