dv-pipecat-flows 0.0.22.dev1900__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.dev1900/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev2100}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/engine_primitives_plan.md +1 -1
  3. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  4. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/actions.py +23 -1
  5. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/flow_validator.py +8 -2
  6. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/manager.py +183 -61
  7. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/uv.lock +109 -1185
  8. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.agents/skills/loki-logs/SKILL.md +0 -0
  9. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.agents/skills/loki-logs/query-reference.md +0 -0
  10. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.claude/skills/loki-logs/SKILL.md +0 -0
  11. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.claude/skills/loki-logs/query-reference.md +0 -0
  12. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.gitattributes +0 -0
  13. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.gitignore +0 -0
  14. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.pre-commit-config.yaml +0 -0
  15. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.python-version +0 -0
  16. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/.readthedocs.yaml +0 -0
  17. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/AGENTS.md +0 -0
  18. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/CHANGELOG.md +0 -0
  19. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/CLAUDE.md +0 -0
  20. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/CONTRIBUTING.md +0 -0
  21. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/LICENSE +0 -0
  22. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/MANIFEST.in +0 -0
  23. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/README.md +0 -0
  24. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/dev-requirements.txt +0 -0
  25. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/docker-compose.dev.yml +0 -0
  26. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/.eslintrc.json +0 -0
  27. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/.prettierrc +0 -0
  28. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/css/tailwind.css +0 -0
  29. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/food_ordering.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/movie_explorer.json +0 -0
  31. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/patient_intake.json +0 -0
  32. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/restaurant_reservation.json +0 -0
  33. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/examples/travel_planner.json +0 -0
  34. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/favicon.png +0 -0
  35. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/favicon.svg +0 -0
  36. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/index.html +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/canvas.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/editorState.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/sidePanel.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/editor/toolbar.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/main.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/baseNode.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/endNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/flowNode.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/functionNode.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/index.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/mergeNode.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/nodes/startNode.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/types.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/export.js +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/helpers.js +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/import.js +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/js/utils/validation.js +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/jsdoc.json +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/package-lock.json +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/package.json +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/postcss.config.cjs +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/public/favicon.png +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/public/favicon.svg +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/tailwind.config.cjs +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/vercel.json +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/editor/vite.config.js +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/env.example +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/images/food-ordering-flow.png +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/pipecat-flows.png +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/pipecat_upgrade.md +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/pyproject.toml +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/README.md +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/extensions.conf +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/remote-asterisk-code/rtp.conf +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/requirements.txt +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/scripts/check-pypi-package.py +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/scripts/fix-ruff.sh +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/scripts/pre-commit.sh +0 -0
  75. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/setup.cfg +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  77. {dv_pipecat_flows-0.0.22.dev1900 → 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.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  79. {dv_pipecat_flows-0.0.22.dev1900 → 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.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/__init__.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/condition_evaluator.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/exceptions.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/__init__.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1900 → dv_pipecat_flows-0.0.22.dev2100}/src/pipecat_flows/router_mode.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1900 → 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.dev1900
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.dev1900
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
@@ -2075,22 +2149,69 @@ In all of these cases, you can provide a `name` in your new node's config for de
2075
2149
  previous_node_id = self._current_node
2076
2150
  router_pick = self._last_router_pick
2077
2151
  self._last_router_pick = None
2152
+ # In v2 deterministic lowering, ``FlowConfig.nodes`` is keyed by
2153
+ # ``node.name`` (see calling/flow_builder_v2.py:214), and
2154
+ # ``self._current_node`` stores that same dict key — so
2155
+ # ``previous_node_id`` IS the previous node's name. No lookup
2156
+ # against ``self._nodes`` needed. Backend's
2157
+ # ``tools/helper.py:build_tool_call_log_response`` matches these
2158
+ # against ``AgentVersionNode.name → label`` to populate
2159
+ # source_node_label / target_node_label for the FE pill.
2078
2160
  transition_tool_name = f"transition_to_{node_id}"
2079
- transition_args: Dict[str, Any] = {
2080
- "from_node_id": previous_node_id,
2081
- "to_node_id": node_id,
2082
- }
2083
- if router_pick:
2084
- transition_args.update(router_pick)
2085
- transition_call_id = await start_tracking(
2086
- transcript_handler=getattr(self, "_transcript_handler", None),
2087
- tool_name=transition_tool_name,
2088
- args=transition_args,
2089
- tool_phase="on_call",
2090
- tool_type="transition",
2091
- current_node=previous_node_id,
2092
- logger=logger,
2093
- )
2161
+ transition_call_id: Optional[str] = None
2162
+ # Skip the initial node entry — it's not a transition (no prior
2163
+ # node) and the FE pill would render as "Unknown → X" with no
2164
+ # useful information. Also skip in static-flow init paths where
2165
+ # the engine briefly _set_nodes the same node twice during setup.
2166
+ # Log atomically (start + complete back-to-back at the TOP of
2167
+ # _set_node) so each entry always has executed_at set. Earlier
2168
+ # design held the start_tracking call open until the bottom of
2169
+ # _set_node, but in deep auto_transition chains (Speak→Router→
2170
+ # Speak→End) the outer _set_node calls often never reach their
2171
+ # complete_tracking because End's hangup tears down the call
2172
+ # mid-chain — leaving stub entries with null status/latency/
2173
+ # executed_at. Atomic logging makes every transition self-
2174
+ # contained: zero risk of half-baked entries shipping to history.
2175
+ if previous_node_id is not None and previous_node_id != node_id:
2176
+ transition_args: Dict[str, Any] = {
2177
+ "from_node_id": previous_node_id,
2178
+ "to_node_id": node_id,
2179
+ # FE pill key aliases consumed by CallTranscriptViewer.
2180
+ "source_node": previous_node_id,
2181
+ "target_node": node_id,
2182
+ }
2183
+ if router_pick:
2184
+ transition_args.update(router_pick)
2185
+ logger.info(
2186
+ f"[node_transition] from={previous_node_id} to={node_id} "
2187
+ f"router_pick={router_pick}"
2188
+ )
2189
+ transition_call_id = await start_tracking(
2190
+ transcript_handler=getattr(self, "_transcript_handler", None),
2191
+ tool_name=transition_tool_name,
2192
+ args=transition_args,
2193
+ tool_phase="on_call",
2194
+ # `node_transition` matches the FE's filter in
2195
+ # components/call-details/CallTranscriptViewer.tsx so this
2196
+ # renders as a NodeTransitionDivider pill rather than a
2197
+ # generic tool pill.
2198
+ tool_type="node_transition",
2199
+ current_node=previous_node_id,
2200
+ logger=logger,
2201
+ )
2202
+ # Atomic completion right after start — see comment above.
2203
+ await complete_tracking(
2204
+ transcript_handler=getattr(self, "_transcript_handler", None),
2205
+ function_call_id=transition_call_id,
2206
+ tool_name=transition_tool_name,
2207
+ function_start_time=transition_start_time,
2208
+ response_data={
2209
+ "to_node_id": node_id,
2210
+ "from_node_id": previous_node_id,
2211
+ },
2212
+ status_code=200,
2213
+ logger=logger,
2214
+ )
2094
2215
 
2095
2216
  try:
2096
2217
  # Clear any pending transition state when starting a new node
@@ -2154,6 +2275,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
2154
2275
  # Reset leaked action counts from interrupted actions in previous node
2155
2276
  self._action_manager.reset_ongoing_actions()
2156
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
+
2157
2284
  # Clear any deferred post-actions from previous node
2158
2285
  self._action_manager.clear_deferred_post_actions()
2159
2286
 
@@ -2391,12 +2518,29 @@ In all of these cases, you can provide a `name` in your new node's config for de
2391
2518
  router_target = node_config.get("router_exhausted_next")
2392
2519
  if router_target:
2393
2520
  await self._short_circuit_to(router_target)
2394
- else:
2395
- logger.error(
2396
- f"Router-mode node {node_id} returned no target and no "
2397
- f"router_exhausted_next configured; staying on current node"
2398
- )
2399
- 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
+ )
2400
2544
 
2401
2545
  # Register functions and prepare tools
2402
2546
  tools: List[FlowsFunctionSchema | FlowsDirectFunctionWrapper] = []
@@ -2508,19 +2652,10 @@ In all of these cases, you can provide a `name` in your new node's config for de
2508
2652
 
2509
2653
  # node_entered observer is now emitted at the *top* of _set_node
2510
2654
  # so short-circuit paths (auto_transition / router-mode) get it too.
2511
-
2512
- await complete_tracking(
2513
- transcript_handler=getattr(self, "_transcript_handler", None),
2514
- function_call_id=transition_call_id,
2515
- tool_name=transition_tool_name,
2516
- function_start_time=transition_start_time,
2517
- response_data={
2518
- "to_node_id": node_id,
2519
- "to_node_label": node_config.get("label"),
2520
- },
2521
- status_code=200,
2522
- logger=logger,
2523
- )
2655
+ # Transition tool-log is also emitted atomically at the top of
2656
+ # _set_node (right after `previous_node_id` capture) — no
2657
+ # complete_tracking needed here, deferred completion was
2658
+ # unreliable for outer nodes when the call ended mid-chain.
2524
2659
 
2525
2660
  except Exception as e:
2526
2661
  # If setup crashed inside an uninterruptible node, the guard is
@@ -2532,19 +2667,6 @@ In all of these cases, you can provide a `name` in your new node's config for de
2532
2667
  except Exception:
2533
2668
  pass
2534
2669
  logger.error(f"Error setting node {node_id}: {str(e)}")
2535
- try:
2536
- await complete_tracking(
2537
- transcript_handler=getattr(self, "_transcript_handler", None),
2538
- function_call_id=transition_call_id,
2539
- tool_name=transition_tool_name,
2540
- function_start_time=transition_start_time,
2541
- response_data=None,
2542
- status_code=500,
2543
- error_details={"error": str(e), "type": type(e).__name__},
2544
- logger=logger,
2545
- )
2546
- except Exception as track_exc:
2547
- logger.warning(f"complete_tracking failed for {transition_tool_name}: {track_exc!r}")
2548
2670
  raise FlowError(f"Failed to set node {node_id}: {str(e)}") from e
2549
2671
 
2550
2672
  def _schedule_deferred_post_actions(self, post_actions: List[ActionConfig]) -> None: