dv-pipecat-flows 0.0.22.dev2000__tar.gz → 0.0.22.dev2200__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.dev2200}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/engine_primitives_plan.md +1 -1
  3. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  4. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/actions.py +23 -1
  5. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/flow_validator.py +8 -2
  6. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/manager.py +172 -22
  7. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/types.py +6 -0
  8. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/uv.lock +109 -1185
  9. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.agents/skills/loki-logs/SKILL.md +0 -0
  10. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.agents/skills/loki-logs/query-reference.md +0 -0
  11. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.claude/skills/loki-logs/SKILL.md +0 -0
  12. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.claude/skills/loki-logs/query-reference.md +0 -0
  13. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.gitattributes +0 -0
  14. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.gitignore +0 -0
  15. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.pre-commit-config.yaml +0 -0
  16. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.python-version +0 -0
  17. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/.readthedocs.yaml +0 -0
  18. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/AGENTS.md +0 -0
  19. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/CHANGELOG.md +0 -0
  20. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/CLAUDE.md +0 -0
  21. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/CONTRIBUTING.md +0 -0
  22. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/LICENSE +0 -0
  23. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/MANIFEST.in +0 -0
  24. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/README.md +0 -0
  25. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/dev-requirements.txt +0 -0
  26. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/docker-compose.dev.yml +0 -0
  27. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/.eslintrc.json +0 -0
  28. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/.prettierrc +0 -0
  29. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/css/tailwind.css +0 -0
  30. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/examples/food_ordering.json +0 -0
  31. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/examples/movie_explorer.json +0 -0
  32. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/examples/patient_intake.json +0 -0
  33. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/examples/restaurant_reservation.json +0 -0
  34. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/examples/travel_planner.json +0 -0
  35. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/favicon.png +0 -0
  36. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/favicon.svg +0 -0
  37. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/index.html +0 -0
  38. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/editor/canvas.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/editor/editorState.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/editor/sidePanel.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/editor/toolbar.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/main.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/baseNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/endNode.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/flowNode.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/functionNode.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/index.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/mergeNode.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/nodes/startNode.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/types.js +0 -0
  51. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/utils/export.js +0 -0
  52. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/utils/helpers.js +0 -0
  53. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/utils/import.js +0 -0
  54. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/js/utils/validation.js +0 -0
  55. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/jsdoc.json +0 -0
  56. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/package-lock.json +0 -0
  57. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/package.json +0 -0
  58. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/postcss.config.cjs +0 -0
  59. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/public/favicon.png +0 -0
  60. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/public/favicon.svg +0 -0
  61. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/tailwind.config.cjs +0 -0
  62. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/vercel.json +0 -0
  63. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/editor/vite.config.js +0 -0
  64. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/env.example +0 -0
  65. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/images/food-ordering-flow.png +0 -0
  66. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/pipecat-flows.png +0 -0
  67. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/pipecat_upgrade.md +0 -0
  68. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/pyproject.toml +0 -0
  69. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/remote-asterisk-code/README.md +0 -0
  70. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/remote-asterisk-code/extensions.conf +0 -0
  71. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/remote-asterisk-code/rtp.conf +0 -0
  72. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/requirements.txt +0 -0
  73. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/scripts/check-pypi-package.py +0 -0
  74. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/scripts/fix-ruff.sh +0 -0
  75. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/scripts/pre-commit.sh +0 -0
  76. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/setup.cfg +0 -0
  77. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  78. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  79. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  80. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  81. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/__init__.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/adapters.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/condition_evaluator.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/exceptions.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/processors/__init__.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev2000 → dv_pipecat_flows-0.0.22.dev2200}/src/pipecat_flows/router_mode.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.dev2200
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.dev2200
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]:
@@ -609,6 +622,7 @@ class FlowManager:
609
622
  handler: Optional[Callable | FlowsDirectFunctionWrapper],
610
623
  transition_to: Optional[str],
611
624
  transition_callback: Optional[Callable] = None,
625
+ state_update_keys: Optional[List[str]] = None,
612
626
  ) -> Callable:
613
627
  """Create a transition function for the given name and handler.
614
628
 
@@ -617,6 +631,14 @@ class FlowManager:
617
631
  handler: Optional function to process data.
618
632
  transition_to: Optional node to transition to (static flows).
619
633
  transition_callback: Optional callback for dynamic transitions.
634
+ state_update_keys: Optional list of argument names — when the
635
+ LLM calls this function, the named keys from
636
+ ``params.arguments`` are copied into ``flow_manager.state``
637
+ before the transition resolves. Used by backend wiring to
638
+ flatten ``agent_node → llm_router`` transitions and still
639
+ propagate the router's ``router_extract`` variables (e.g.
640
+ ``language``) into state, preserving parity with
641
+ ``_run_router_node``'s ``state.update(extracted)``.
620
642
 
621
643
  Returns:
622
644
  Async function that handles the tool invocation.
@@ -639,6 +661,26 @@ class FlowManager:
639
661
  function_start_time = time.time()
640
662
  try:
641
663
  logger.debug(f"Function called: {name}")
664
+ # Flattened-router extract: copy LLM-supplied args into
665
+ # ``flow_manager.state`` BEFORE the transition resolves, so
666
+ # downstream nodes (e.g. Speak picking variant by
667
+ # ``state["language"]``) see the new values on their first
668
+ # ``_set_node`` after the transition. Mirrors the
669
+ # ``state.update(extracted)`` that ``_run_router_node`` does
670
+ # on the non-flattened path.
671
+ if state_update_keys and params.arguments:
672
+ extract_update = {
673
+ key: params.arguments[key]
674
+ for key in state_update_keys
675
+ if key in params.arguments
676
+ }
677
+ if extract_update:
678
+ self.state.update(extract_update)
679
+ logger.debug(
680
+ f"[FLATTENED_ROUTER_EXTRACT] state.update keys="
681
+ f"{list(extract_update.keys())} function={name}"
682
+ )
683
+
642
684
  function_call_id = await start_tracking(
643
685
  transcript_handler=self._transcript_handler,
644
686
  tool_name=name,
@@ -682,10 +724,6 @@ class FlowManager:
682
724
  f"{'Transition-only function called for' if is_transition_only_function else 'Function handler completed for'} {name}"
683
725
  )
684
726
 
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
727
  # Determine if this is an edge function
690
728
  is_edge_function = (
691
729
  bool(next_node) or bool(transition_to) or bool(transition_callback)
@@ -892,6 +930,7 @@ class FlowManager:
892
930
  handler: Optional[Callable | FlowsDirectFunctionWrapper],
893
931
  transition_to: Optional[str] = None,
894
932
  transition_callback: Optional[Callable] = None,
933
+ state_update_keys: Optional[List[str]] = None,
895
934
  ) -> None:
896
935
  """Register a function with the LLM if not already registered.
897
936
 
@@ -915,7 +954,11 @@ class FlowManager:
915
954
 
916
955
  # Create transition function
917
956
  transition_func = await self._create_transition_func(
918
- name, handler, transition_to, transition_callback
957
+ name,
958
+ handler,
959
+ transition_to,
960
+ transition_callback,
961
+ state_update_keys=state_update_keys,
919
962
  )
920
963
 
921
964
  # Register function with LLM (or LLMSwitcher).
@@ -1039,10 +1082,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
1039
1082
  default_next=default_next,
1040
1083
  )
1041
1084
 
1042
- # Case 2: Action node with success/failure dict.
1085
+ # Case 2: Action node with success/failure dict. `_last_action_result`
1086
+ # is reset to None on node entry and set by this node's own function
1087
+ # action; None (no action ran) and "success" both take the success
1088
+ # branch — only an explicit "failure" routes to the failure target.
1043
1089
  if isinstance(auto_transition, dict):
1044
- result = getattr(self, "_last_action_result", "success")
1045
- key = "failure" if result == "failure" else "success"
1090
+ key = "failure" if self._last_action_result == "failure" else "success"
1046
1091
  return auto_transition.get(key) or auto_transition.get("success")
1047
1092
 
1048
1093
  # Case 1: bare string (Speak / single-target Action).
@@ -1403,7 +1448,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
1403
1448
  # override=Gemini) isn't supported by this path yet.
1404
1449
  llm_service = self.state.get("interrupt_classifier_llm_service") or self._llm
1405
1450
  if isinstance(llm_service, LLMSwitcher):
1406
- llm_service = llm_service.active_service
1451
+ # LLMSwitcher exposes the live service via the `active_llm`
1452
+ # property (it proxies `self.strategy.active_service`). The
1453
+ # switcher itself has no `active_service` attribute — reading it
1454
+ # raises AttributeError. See pipecat/pipeline/llm_switcher.py.
1455
+ llm_service = llm_service.active_llm
1407
1456
 
1408
1457
  valid_ids: List[str] = [RESPEAK_BRANCH_ID]
1409
1458
  for r_id, brs in branches_by_source.items():
@@ -1450,6 +1499,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
1450
1499
  )
1451
1500
  return (natural_next or speak_id, [], False)
1452
1501
 
1502
+ # Allow-list extracted keys to the declared extract-spec names before
1503
+ # merging into shared `state`. The strict JSON schema constrains keys,
1504
+ # but every adapter falls back to schema-less json_object mode on
1505
+ # providers that reject strict schemas — on that path the model's keys
1506
+ # are unbounded. `state` is the same flat dict that holds live runtime
1507
+ # objects/secrets (task, tts_service_map, integration_credentials, ...
1508
+ # merged from flow_dependencies), so an unfiltered merge lets a
1509
+ # hallucinated reserved key clobber them.
1510
+ allowed_keys = {k for m in extract_by_source.values() for k in (m or {})}
1511
+ extracted = {k: v for k, v in extracted.items() if k in allowed_keys}
1453
1512
  if extracted:
1454
1513
  self.state.update(extracted)
1455
1514
  # Mirror the normal-router behavior: surface what the classifier
@@ -1683,7 +1742,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
1683
1742
  # override; otherwise the main LLM.
1684
1743
  llm_service = self._llm
1685
1744
  if isinstance(llm_service, LLMSwitcher):
1686
- llm_service = llm_service.active_service
1745
+ # LLMSwitcher exposes the live service via the `active_llm`
1746
+ # property (it proxies `self.strategy.active_service`). The
1747
+ # switcher itself has no `active_service` attribute — reading it
1748
+ # raises AttributeError. See pipecat/pipeline/llm_switcher.py.
1749
+ llm_service = llm_service.active_llm
1687
1750
 
1688
1751
  raw = await self._adapter.run_router_completion(
1689
1752
  llm_service=llm_service,
@@ -1737,6 +1800,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
1737
1800
  # source for extraction, and applying it would let stray values
1738
1801
  # overwrite legitimate prior extractions. The re-prompt loop will
1739
1802
  # capture the right reply on the next turn.
1803
+ # Allow-list to the declared `router_extract` names before merging —
1804
+ # the schema-less adapter fallback (see `parse_router_response`) can
1805
+ # emit arbitrary keys, and `state` holds live runtime objects/secrets
1806
+ # (task, tts_service_map, integration_credentials, ...) that a stray
1807
+ # reserved key would otherwise overwrite.
1808
+ allowed_keys = set((node_config.get("router_extract") or {}).keys())
1809
+ extracted = {k: v for k, v in extracted.items() if k in allowed_keys}
1740
1810
  if extracted and branch_id != UNCLEAR_BRANCH_ID:
1741
1811
  self.state.update(extracted)
1742
1812
  logger.debug(
@@ -2014,6 +2084,20 @@ In all of these cases, you can provide a `name` in your new node's config for de
2014
2084
  config_key = f"{provider}_{model}"
2015
2085
  target_service = llm_service_map.get(config_key)
2016
2086
  if target_service:
2087
+ # Snapshot the baseline (un-overridden) LLM the first
2088
+ # time we override, so we can switch back when a later
2089
+ # node has no llm_override. The baseline is whatever was
2090
+ # active before any override fired.
2091
+ if previous_llm is None and "llm_baseline" not in self._previous_overrides:
2092
+ baseline = None
2093
+ active = self._llm
2094
+ if isinstance(active, LLMSwitcher):
2095
+ active = active.active_llm
2096
+ for key, svc in llm_service_map.items():
2097
+ if svc is active:
2098
+ baseline = key
2099
+ break
2100
+ self._previous_overrides["llm_baseline"] = baseline
2017
2101
  await task.queue_frames(
2018
2102
  [ManuallySwitchServiceFrame(service=target_service)]
2019
2103
  )
@@ -2028,6 +2112,30 @@ In all of these cases, you can provide a `name` in your new node's config for de
2028
2112
  logger.warning(
2029
2113
  f"Incomplete LLM override config for node {node_id}: {current_llm}"
2030
2114
  )
2115
+ elif not current_llm and previous_llm and task and llm_service_map:
2116
+ # Exiting an llm_override node into one without an override:
2117
+ # restore the snapshotted baseline LLM. Without this the cheap
2118
+ # router model stays pinned for every subsequent generative
2119
+ # node (contract: "the previous LLM is restored on exit").
2120
+ baseline_key = self._previous_overrides.get("llm_baseline")
2121
+ baseline_service = (
2122
+ llm_service_map.get(baseline_key) if baseline_key else None
2123
+ )
2124
+ if baseline_service:
2125
+ await task.queue_frames(
2126
+ [ManuallySwitchServiceFrame(service=baseline_service)]
2127
+ )
2128
+ logger.debug(
2129
+ f"Restored baseline LLM '{baseline_key}' on exit from "
2130
+ f"override node (now at {node_id})"
2131
+ )
2132
+ else:
2133
+ logger.warning(
2134
+ f"Could not restore baseline LLM on exit from override "
2135
+ f"node {node_id}: baseline key '{baseline_key}' not in "
2136
+ f"llm_service_map"
2137
+ )
2138
+ self._previous_overrides.pop("llm_baseline", None)
2031
2139
  self._previous_overrides["llm"] = current_llm
2032
2140
 
2033
2141
  # Future: Handle other overrides (VAD, STT) here
@@ -2201,6 +2309,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
2201
2309
  # Reset leaked action counts from interrupted actions in previous node
2202
2310
  self._action_manager.reset_ongoing_actions()
2203
2311
 
2312
+ # Reset the per-node action result so a success/failure
2313
+ # `auto_transition` dict can't route on the PREVIOUS node's result
2314
+ # if this node runs no function action this turn. A function
2315
+ # pre-action below overwrites this before the resolver reads it.
2316
+ self._last_action_result = None
2317
+
2204
2318
  # Clear any deferred post-actions from previous node
2205
2319
  self._action_manager.clear_deferred_post_actions()
2206
2320
 
@@ -2242,8 +2356,26 @@ In all of these cases, you can provide a `name` in your new node's config for de
2242
2356
  isinstance(a, dict) and a.get("type") == "tts_say"
2243
2357
  for a in pre_actions
2244
2358
  ):
2359
+ # ``Event.wait()`` that's never set hangs forever — a bare
2360
+ # ``await`` here was a transition-deadlock vector when the
2361
+ # ``BotStoppedSpeakingFrame`` never reaches ActionManager
2362
+ # (cancelled pipeline, dropped frame, abruptly closed
2363
+ # transport). Mirror ``actions.py:_maybe_wait_for_ongoing_
2364
+ # actions_to_finish``'s 60s ``wait_for`` backstop so the
2365
+ # node still transitions instead of permanently parking.
2366
+ from pipecat_flows.actions import ONGOING_ACTIONS_WAIT_TIMEOUT_S
2245
2367
  try:
2246
- await self._action_manager._ongoing_actions_finished_event.wait()
2368
+ await asyncio.wait_for(
2369
+ self._action_manager._ongoing_actions_finished_event.wait(),
2370
+ timeout=ONGOING_ACTIONS_WAIT_TIMEOUT_S,
2371
+ )
2372
+ except asyncio.TimeoutError:
2373
+ logger.warning(
2374
+ f"audio-wait on '{node_id}' timed out after "
2375
+ f"{ONGOING_ACTIONS_WAIT_TIMEOUT_S}s — "
2376
+ f"BotStoppedSpeakingFrame never arrived; continuing "
2377
+ f"with short-circuit to avoid a permanent hang"
2378
+ )
2247
2379
  except Exception as wait_exc:
2248
2380
  logger.warning(
2249
2381
  f"audio-wait on '{node_id}' failed ({wait_exc!r}); "
@@ -2438,12 +2570,29 @@ In all of these cases, you can provide a `name` in your new node's config for de
2438
2570
  router_target = node_config.get("router_exhausted_next")
2439
2571
  if router_target:
2440
2572
  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
2573
+ return
2574
+ # No branch target AND no router_exhausted_next resolved
2575
+ # (reachable from a single misconfigured node: a branch with an
2576
+ # unset `next`, the unclear cap hit with no router_exhausted_next,
2577
+ # or under-cap with neither reprompt nor router_unclear_next).
2578
+ # Silently returning would strand the call: `suppress_llm_text`
2579
+ # is latched on this node so RouterModeGuard gates the generative
2580
+ # LLM, and the StopUserIdleProcessorFrame queued at entry is
2581
+ # never matched by a StartUserIdleProcessorFrame below — idle
2582
+ # recovery is disabled too → permanent dead-air with no escape.
2583
+ # Fail loudly instead: clear the suppression latch, re-enable
2584
+ # idle recovery so teardown isn't wedged, then raise.
2585
+ logger.error(
2586
+ f"Router-mode node {node_id} returned no target and no "
2587
+ f"router_exhausted_next configured; failing the transition "
2588
+ f"to avoid permanent dead-air"
2589
+ )
2590
+ self._current_node = None # release is_suppressing_llm_text() latch
2591
+ await self._task.queue_frame(StartUserIdleProcessorFrame())
2592
+ raise FlowError(
2593
+ f"Router-mode node {node_id} has no resolvable target and no "
2594
+ f"router_exhausted_next; this is a flow misconfiguration"
2595
+ )
2447
2596
 
2448
2597
  # Register functions and prepare tools
2449
2598
  tools: List[FlowsFunctionSchema | FlowsDirectFunctionWrapper] = []
@@ -2461,6 +2610,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2461
2610
  handler=schema.handler,
2462
2611
  transition_to=schema.transition_to,
2463
2612
  transition_callback=schema.transition_callback,
2613
+ state_update_keys=schema.state_update_keys,
2464
2614
  )
2465
2615
 
2466
2616
  async def register_direct_function(func):
@@ -273,6 +273,12 @@ class FlowsFunctionSchema:
273
273
  handler: Optional[FunctionHandler] = None
274
274
  transition_to: Optional[str] = None
275
275
  transition_callback: Optional[Callable] = None
276
+ # When the LLM calls this function, copy these keys from
277
+ # ``params.arguments`` into ``flow_manager.state`` BEFORE the transition
278
+ # resolves. Used by backend wiring to flatten ``agent_node → llm_router``
279
+ # transitions and still propagate the router's ``router_extract``
280
+ # variables (e.g. ``language``) into state. Empty/None = no-op.
281
+ state_update_keys: Optional[List[str]] = None
276
282
 
277
283
  def __post_init__(self):
278
284
  """Validate the schema configuration.