dv-pipecat-flows 0.0.0.dev2098__tar.gz → 0.0.0.dev2245__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.dev2098/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.0.dev2245}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/engine_primitives_plan.md +1 -1
  3. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  4. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/actions.py +332 -19
  5. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/adapters.py +268 -2
  6. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/condition_evaluator.py +9 -0
  7. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/flow_validator.py +8 -2
  8. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/manager.py +1001 -152
  9. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/processors/user_turn_observer.py +15 -0
  10. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/router_mode.py +107 -9
  11. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/types.py +16 -0
  12. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.agents/skills/loki-logs/SKILL.md +0 -0
  13. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.agents/skills/loki-logs/query-reference.md +0 -0
  14. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.claude/skills/loki-logs/SKILL.md +0 -0
  15. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.claude/skills/loki-logs/query-reference.md +0 -0
  16. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.gitattributes +0 -0
  17. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.gitignore +0 -0
  18. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.pre-commit-config.yaml +0 -0
  19. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.python-version +0 -0
  20. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/.readthedocs.yaml +0 -0
  21. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/AGENTS.md +0 -0
  22. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/CHANGELOG.md +0 -0
  23. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/CLAUDE.md +0 -0
  24. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/CONTRIBUTING.md +0 -0
  25. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/LICENSE +0 -0
  26. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/MANIFEST.in +0 -0
  27. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/README.md +0 -0
  28. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/dev-requirements.txt +0 -0
  29. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/docker-compose.dev.yml +0 -0
  30. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/.eslintrc.json +0 -0
  31. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/.prettierrc +0 -0
  32. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/css/tailwind.css +0 -0
  33. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/examples/food_ordering.json +0 -0
  34. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/examples/movie_explorer.json +0 -0
  35. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/examples/patient_intake.json +0 -0
  36. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/examples/restaurant_reservation.json +0 -0
  37. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/examples/travel_planner.json +0 -0
  38. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/favicon.png +0 -0
  39. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/favicon.svg +0 -0
  40. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/index.html +0 -0
  41. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/editor/canvas.js +0 -0
  42. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/editor/editorState.js +0 -0
  43. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/editor/sidePanel.js +0 -0
  44. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/editor/toolbar.js +0 -0
  45. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/main.js +0 -0
  46. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/baseNode.js +0 -0
  47. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/endNode.js +0 -0
  48. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/flowNode.js +0 -0
  49. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/functionNode.js +0 -0
  50. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/index.js +0 -0
  51. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/mergeNode.js +0 -0
  52. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/nodes/startNode.js +0 -0
  53. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/types.js +0 -0
  54. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/utils/export.js +0 -0
  55. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/utils/helpers.js +0 -0
  56. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/utils/import.js +0 -0
  57. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/js/utils/validation.js +0 -0
  58. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/jsdoc.json +0 -0
  59. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/package-lock.json +0 -0
  60. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/package.json +0 -0
  61. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/postcss.config.cjs +0 -0
  62. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/public/favicon.png +0 -0
  63. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/public/favicon.svg +0 -0
  64. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/tailwind.config.cjs +0 -0
  65. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/vercel.json +0 -0
  66. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/editor/vite.config.js +0 -0
  67. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/env.example +0 -0
  68. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/.gitignore +0 -0
  69. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/README.md +0 -0
  70. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/RESULTS.md +0 -0
  71. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -0
  72. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/chunkers.py +0 -0
  73. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/contracts.py +0 -0
  74. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datagen/generate_corpus.py +0 -0
  75. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datagen/generate_dataset.py +0 -0
  76. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datagen/generate_hybrid.py +0 -0
  77. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datagen/templates/insurance.yaml +0 -0
  78. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datasets/.gitignore +0 -0
  79. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datasets/v1_hybrid/manifest.json +0 -0
  80. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datasets/v1_retrieval/manifest.json +0 -0
  81. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -0
  82. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/datasets/v1_synthetic/manifest.json +0 -0
  83. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_filter_pick.py +0 -0
  84. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_filter_scaling.py +0 -0
  85. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_hybrid.py +0 -0
  86. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_latency.py +0 -0
  87. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_rerank.py +0 -0
  88. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/runners/run_retrieval.py +0 -0
  89. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/service/API.md +0 -0
  90. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/service/__init__.py +0 -0
  91. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/service/app.py +0 -0
  92. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/evals/kb/service/jobs.py +0 -0
  93. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/images/food-ordering-flow.png +0 -0
  94. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/pipecat-flows.png +0 -0
  95. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/pipecat_upgrade.md +0 -0
  96. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/pyproject.toml +0 -0
  97. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/remote-asterisk-code/README.md +0 -0
  98. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/remote-asterisk-code/extensions.conf +0 -0
  99. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/remote-asterisk-code/rtp.conf +0 -0
  100. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/requirements.txt +0 -0
  101. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/scripts/check-pypi-package.py +0 -0
  102. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/scripts/fix-ruff.sh +0 -0
  103. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/scripts/pre-commit.sh +0 -0
  104. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/setup.cfg +0 -0
  105. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  106. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  107. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  108. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  109. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/__init__.py +0 -0
  110. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/exceptions.py +0 -0
  111. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/processors/__init__.py +0 -0
  112. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  113. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  114. {dv_pipecat_flows-0.0.0.dev2098 → dv_pipecat_flows-0.0.0.dev2245}/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.dev2098
3
+ Version: 0.0.0.dev2245
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.0.dev2098
3
+ Version: 0.0.0.dev2245
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
@@ -25,14 +25,17 @@ Actions are used to perform side effects during conversations, such as:
25
25
 
26
26
  import asyncio
27
27
  import inspect
28
+ import re
28
29
  from dataclasses import dataclass
29
- from typing import TYPE_CHECKING, Callable, Dict, List, Optional
30
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
30
31
 
31
32
  from loguru import logger
32
33
  from pipecat.frames.frames import (
33
34
  BotStoppedSpeakingFrame,
34
35
  ControlFrame,
35
36
  EndFrame,
37
+ InterruptionFrame,
38
+ ManuallySwitchServiceFrame,
36
39
  TTSSpeakFrame,
37
40
  )
38
41
  from pipecat.pipeline.task import PipelineTask
@@ -44,6 +47,58 @@ if TYPE_CHECKING:
44
47
  from pipecat_flows.manager import FlowManager
45
48
 
46
49
 
50
+ # Deadlock backstop for the "wait until ongoing actions finish" condition-wait.
51
+ # Normally `_ongoing_actions_finished_event` is set when the trailing action's
52
+ # frame (e.g. BotStoppedSpeakingFrame) arrives downstream. But a barge-in can
53
+ # flush that frame while the count stays >0, so the event is never set and the
54
+ # wait hangs forever (no `except` can rescue a never-returning await). This cap
55
+ # is generous on purpose — it must exceed any realistic TTS/function duration
56
+ # so it never truncates legitimate audio, only breaks a genuine hang.
57
+ ONGOING_ACTIONS_WAIT_TIMEOUT_S = 60.0
58
+
59
+
60
+ # Router/Agent-extracted variable token: ``[{name}]`` (distinct from custom
61
+ # ``{{name}}`` on the FE — see VariableSelect.tsx). Resolved at TTS time from
62
+ # ``flow_manager.state``, where ``_run_router_node`` (and the flattened
63
+ # agent_node→llm_router path) writes the LLM's extracted args. Backend
64
+ # lowering only substitutes compile-time vars (custom_args, api_responses);
65
+ # anything a router extracts at runtime needs this pass to reach TTS.
66
+ #
67
+ # Pattern matches BOTH the FE's dominant mention form ``@[{var}]`` (the
68
+ # ``@``-prefixed visible text inside a Tiptap mention span) AND the raw
69
+ # ``[{var}]`` form — mirrors how ``replace_custom_variables`` handles both
70
+ # ``@{{var}}`` and ``{{var}}`` for custom_args.
71
+ _EXTRACT_VAR_RE = re.compile(
72
+ r"@?\[\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\]"
73
+ )
74
+
75
+
76
+ def _substitute_extract_vars(text: str, state: dict) -> str:
77
+ """Replace ``[{var}]`` / ``@[{var}]`` occurrences with ``state[var]``.
78
+
79
+ Supports dotted paths (``[{user.name}]``). Unknown variables (key not
80
+ in state, or any path segment missing) are LEFT LITERAL — the marker
81
+ survives so downstream debugging can spot the misconfigured extract.
82
+ """
83
+ if not text or "[{" not in text or not isinstance(state, dict):
84
+ return text
85
+
86
+ def _resolve(key: str):
87
+ cur = state
88
+ for seg in key.split("."):
89
+ if isinstance(cur, dict) and seg in cur:
90
+ cur = cur[seg]
91
+ else:
92
+ return None
93
+ return cur
94
+
95
+ def _repl(match: "re.Match[str]") -> str:
96
+ value = _resolve(match.group(1))
97
+ return str(value) if value is not None else match.group(0)
98
+
99
+ return _EXTRACT_VAR_RE.sub(_repl, text)
100
+
101
+
47
102
  @dataclass
48
103
  class FunctionActionFrame(ControlFrame):
49
104
  """Frame containing a function action to be executed.
@@ -96,6 +151,14 @@ class ActionManager:
96
151
  self._ongoing_actions_count = 0
97
152
  self._ongoing_actions_finished_event = asyncio.Event()
98
153
  self._deferred_post_actions: List[ActionConfig] = []
154
+ # Tracks whether an InterruptionFrame has been observed since the
155
+ # last action increment. The BotStoppedSpeakingFrame "interruption
156
+ # recovery" branch must only fire when a real interruption actually
157
+ # happened — otherwise a stale BotStoppedSpeakingFrame from a prior
158
+ # speech (e.g. cached intro completing while the first Speak node
159
+ # is already starting) would zero a legitimate in-flight count and
160
+ # release _ongoing_actions_finished_event prematurely.
161
+ self._seen_interruption_since_increment = False
99
162
 
100
163
  # Register built-in actions
101
164
  self._register_action("tts_say", self._handle_tts_action)
@@ -104,7 +167,12 @@ class ActionManager:
104
167
 
105
168
  # Add pipeline observation
106
169
  task.set_reached_downstream_filter(
107
- (ActionFinishedFrame, FunctionActionFrame, BotStoppedSpeakingFrame)
170
+ (
171
+ ActionFinishedFrame,
172
+ FunctionActionFrame,
173
+ BotStoppedSpeakingFrame,
174
+ InterruptionFrame,
175
+ )
108
176
  )
109
177
 
110
178
  @task.event_handler("on_frame_reached_downstream")
@@ -130,11 +198,55 @@ class ActionManager:
130
198
  )
131
199
  self._decrement_ongoing_actions_count()
132
200
  elif isinstance(frame, BotStoppedSpeakingFrame):
133
- # Execute deferred post-actions if the bot's turn is over.
134
- # A BotStoppedSpeakingFrame only indicates that the bot's turn is over if there are
135
- # no ongoing actions (otherwise one of those actions may have been responsible for it).
136
- if self._ongoing_actions_count == 0:
201
+ # Two cases on this frame:
202
+ #
203
+ # 1. NATURAL COMPLETION: TTS finished generating, the trailing
204
+ # ActionFinishedFrame already reached us and decremented
205
+ # `_ongoing_actions_count` to 0. Run deferred post-actions
206
+ # now — the original intent of this branch.
207
+ #
208
+ # 2. INTERRUPTION RECOVERY: an upstream `InterruptionFrame`
209
+ # flushed the output queue, dropping the trailing
210
+ # `ActionFinishedFrame` before it could reach us. The count
211
+ # stays at >0 and `_ongoing_actions_finished_event` is
212
+ # never set → the next `_set_node`'s
213
+ # `_maybe_wait_for_ongoing_actions_to_finish` hangs forever.
214
+ # Defensively reset on this frame so the engine doesn't
215
+ # deadlock; skip the deferred post-actions since the action
216
+ # didn't complete naturally.
217
+ #
218
+ # Recovery is gated on `_seen_interruption_since_increment`:
219
+ # count>0 alone is ambiguous — a stale BotStoppedSpeakingFrame
220
+ # from a prior speech (e.g. cached intro completing after a
221
+ # new Speak's increment) would otherwise zero a legitimate
222
+ # in-flight count and unblock the next _set_node early.
223
+ if (
224
+ self._ongoing_actions_count > 0
225
+ and self._seen_interruption_since_increment
226
+ ):
227
+ logger.debug(
228
+ f"BotStoppedSpeaking with in-flight count="
229
+ f"{self._ongoing_actions_count} after InterruptionFrame; "
230
+ f"resetting to release _ongoing_actions_finished_event"
231
+ )
232
+ self._ongoing_actions_count = 0
233
+ self._ongoing_actions_finished_event.set()
234
+ self._seen_interruption_since_increment = False
235
+ elif self._ongoing_actions_count > 0:
236
+ # Stale frame from prior speech. Ignore — the real
237
+ # in-flight action's ActionFinishedFrame is still coming.
238
+ logger.debug(
239
+ f"BotStoppedSpeaking with in-flight count="
240
+ f"{self._ongoing_actions_count} but no InterruptionFrame "
241
+ f"observed since last increment; treating as stale, "
242
+ f"keeping counter intact"
243
+ )
244
+ else:
137
245
  await self._execute_deferred_post_actions()
246
+ elif isinstance(frame, InterruptionFrame):
247
+ # Mark — the very next BotStoppedSpeakingFrame is the truncated
248
+ # speech's stop and should trigger the recovery branch above.
249
+ self._seen_interruption_since_increment = True
138
250
  elif isinstance(frame, ActionFinishedFrame):
139
251
  # Handle action finished
140
252
  self._decrement_ongoing_actions_count()
@@ -305,32 +417,76 @@ class ActionManager:
305
417
  pass
306
418
 
307
419
  if needs_wait:
308
- await self._ongoing_actions_finished_event.wait()
420
+ try:
421
+ await asyncio.wait_for(
422
+ self._ongoing_actions_finished_event.wait(),
423
+ timeout=ONGOING_ACTIONS_WAIT_TIMEOUT_S,
424
+ )
425
+ except asyncio.TimeoutError:
426
+ logger.warning(
427
+ "Timed out after %ss waiting for ongoing actions to finish "
428
+ "(previous=%s); the finished-event was never set — continuing "
429
+ "to avoid a permanent hang.",
430
+ ONGOING_ACTIONS_WAIT_TIMEOUT_S,
431
+ previous_action_type,
432
+ )
309
433
 
310
434
  async def _handle_tts_action(self, action: dict) -> None:
311
435
  """Built-in handler for TTS actions.
312
436
 
313
437
  Args:
314
438
  action: Action configuration containing:
315
- - text: Text to speak (required, resolved)
316
- - text_raw: Raw template with {{var}} markers (optional)
317
- - use_cache: Use caching for TTS audio (optional, default: False)
318
-
319
- Caching is opt-in via dependency injection: when use_cache=True and
320
- flow_manager.state['say_with_cache_handler'] is set, the handler is
321
- invoked as `await handler(text=..., text_raw=...) -> bool`. Returning
439
+ - text: Text to speak (required when ``text_variants`` is
440
+ absent; serves as the forward-compat fallback when present).
441
+ - text_variants: ``{iso_lang: [variant_strings, ...]}``
442
+ multi-language Speak shape. The handler picks one variant
443
+ based on ``state["language"]`` (with primary fallback) and
444
+ cycles through variants via
445
+ :attr:`FlowManager._variant_usage` to avoid back-to-back
446
+ repeats per call.
447
+ - fallback_language: The Speak's primary ISO 639-1 code, used
448
+ when ``state["language"]`` is missing or has no variants.
449
+ - voice: Per-Speak voice id override (queues a
450
+ :class:`ManuallySwitchServiceFrame` before the TTS frame).
451
+ - language: Per-Speak language override (locale form, e.g.
452
+ ``en-US``). Combined with ``voice`` to build the
453
+ ``{provider}_{voice}_{language}`` lookup key against
454
+ ``state["tts_service_map"]``.
455
+ - text_raw: Raw template with ``{{var}}`` markers (optional).
456
+ - use_cache: Use caching for TTS audio (optional, default: False).
457
+
458
+ Caching is opt-in via dependency injection: when ``use_cache=True`` and
459
+ ``flow_manager.state['say_with_cache_handler']`` is set, the handler is
460
+ invoked as ``await handler(text=..., text_raw=...) -> bool``. Returning
322
461
  True means the handler served the audio downstream (and pushed an
323
462
  ActionFinishedFrame downstream itself). Returning False (or raising)
324
463
  falls back to live TTS via TTSSpeakFrame.
325
464
 
465
+ SSML detection happens DOWNSTREAM (in the TTS service's
466
+ ``format_tts_text`` formatter) — this handler passes ``text`` through
467
+ verbatim and relies on the formatter's regex to early-return when SSML
468
+ tags are present.
469
+
326
470
  To prevent the LLM from immediately responding after TTS in pre-actions,
327
- set respond_immediately=false in the node configuration.
471
+ set ``respond_immediately=false`` in the node configuration.
328
472
  """
329
- text = action.get("text")
473
+ text, text_raw, chosen_lang = self._pick_tts_variant(action)
330
474
  if not text:
331
- logger.error("TTS action missing 'text' field")
475
+ logger.error("TTS action missing 'text' / 'text_variants' field")
332
476
  return
333
477
 
478
+ # Runtime resolution of [{var}] / @[{var}] tokens against
479
+ # flow_manager.state. Backend lowering substitutes {{var}} (custom
480
+ # args) and ((tool.field)) (pre-call outputs) at compile time, but
481
+ # router/agent-extracted vars only exist at runtime — without this
482
+ # pass, the bot speaks literals like "@[{user_name}]" verbatim.
483
+ # text_raw stays unrendered so the split-cache's prefix derivation
484
+ # remains stable across calls.
485
+ state = self._flow_manager.state if isinstance(self._flow_manager.state, dict) else {}
486
+ text = _substitute_extract_vars(text, state)
487
+
488
+ await self._maybe_switch_tts_service_for_action(action)
489
+
334
490
  use_cache = action.get("use_cache", False)
335
491
  cache_handler = (
336
492
  self._flow_manager.state.get("say_with_cache_handler") if use_cache else None
@@ -343,8 +499,17 @@ class ActionManager:
343
499
  served_from_cache = False
344
500
  if cache_handler is not None:
345
501
  try:
346
- text_raw = action.get("text_raw") or text
347
- served_from_cache = bool(await cache_handler(text=text, text_raw=text_raw))
502
+ # Fall back to text when no raw template was emitted —
503
+ # cache key derivation will treat the rendered text as
504
+ # its own template (lower hit rate but always correct).
505
+ effective_raw = text_raw or text
506
+ served_from_cache = bool(
507
+ await cache_handler(
508
+ text=text,
509
+ text_raw=effective_raw,
510
+ language=chosen_lang,
511
+ )
512
+ )
348
513
  except Exception as e:
349
514
  logger.error(f"tts_say cache handler raised, falling back to live TTS: {e}")
350
515
  served_from_cache = False
@@ -364,6 +529,150 @@ class ActionManager:
364
529
  self._decrement_ongoing_actions_count()
365
530
  logger.error(f"TTS error: {e}")
366
531
 
532
+ def _pick_tts_variant(
533
+ self, action: dict
534
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
535
+ """Pick a variation string from ``text_variants``.
536
+
537
+ Returns ``(text, text_raw, chosen_lang)``:
538
+ * ``text`` — the picked variant (rendered string), or ``action["text"]``
539
+ when no variants are usable.
540
+ * ``text_raw`` — the matching unrendered template (with ``{{var}}``
541
+ markers intact) from ``action["text_raw_variants"][chosen_lang][idx]``
542
+ when present, else ``action["text_raw"]``, else mirrors ``text``.
543
+ Used by the split-cache to derive the stable prefix cache key.
544
+ * ``chosen_lang`` — the ISO key that won, or None when the legacy
545
+ ``text`` fallback was taken. Threaded down to the cache_handler
546
+ so the cache key can be language-scoped (different scripts must
547
+ not share a cache slot).
548
+
549
+ Resolution order for the language key:
550
+ 1. ``state["language"]`` (set by Router-LLM auto-injected extract).
551
+ 2. ``action["fallback_language"]`` (the Speak's primary ISO).
552
+ 3. First key in ``text_variants``.
553
+
554
+ Cycling state lives on :attr:`FlowManager._variant_usage` keyed by
555
+ ``(speak_node_id, lang)`` — every variant index is picked at most
556
+ once until the set has been exhausted, at which point it resets.
557
+ """
558
+ variants = action.get("text_variants")
559
+ if not isinstance(variants, dict) or not variants:
560
+ return action.get("text"), action.get("text_raw"), None
561
+
562
+ active = (self._flow_manager.state or {}).get("language") if isinstance(
563
+ self._flow_manager.state, dict
564
+ ) else None
565
+ fallback = action.get("fallback_language")
566
+ candidates: List[Optional[str]] = []
567
+ if isinstance(active, str) and active.strip():
568
+ candidates.append(active.strip().lower())
569
+ if isinstance(fallback, str) and fallback.strip():
570
+ candidates.append(fallback.strip().lower())
571
+ # Last resort: any language present.
572
+ candidates.append(next(iter(variants.keys()), None))
573
+
574
+ chosen_lang: Optional[str] = None
575
+ chosen_list: List[str] = []
576
+ for cand in candidates:
577
+ if not cand:
578
+ continue
579
+ lst = variants.get(cand)
580
+ if isinstance(lst, list) and lst:
581
+ chosen_lang = cand
582
+ chosen_list = [s for s in lst if isinstance(s, str) and s.strip()]
583
+ if chosen_list:
584
+ break
585
+
586
+ if not chosen_list:
587
+ # Variants dict is malformed (all keys empty); fall back to the
588
+ # forward-compat `text` field if the lowerer emitted one.
589
+ return action.get("text"), action.get("text_raw"), None
590
+
591
+ # Look up the parallel raw-template list emitted by the backend for
592
+ # this language. Same length as `chosen_list` at lowering time; if
593
+ # absent or short, the caller falls back to chosen_list itself
594
+ # (cache derivation will treat the rendered text as its own
595
+ # template — cache MAY miss but won't garble).
596
+ raw_variants = action.get("text_raw_variants")
597
+ raw_list: List[str] = []
598
+ if isinstance(raw_variants, dict):
599
+ cand_raw = raw_variants.get(chosen_lang or "")
600
+ if isinstance(cand_raw, list):
601
+ raw_list = [s for s in cand_raw if isinstance(s, str)]
602
+
603
+ speak_id = (
604
+ getattr(self._flow_manager, "_current_node", None)
605
+ or action.get("speak_id")
606
+ or "__unknown_speak__"
607
+ )
608
+ usage_key = (speak_id, chosen_lang or "")
609
+ usage = self._flow_manager._variant_usage
610
+ used = usage.get(usage_key, set())
611
+ # Pick the smallest index not yet used; reset the set when full.
612
+ picked_idx = 0
613
+ for idx in range(len(chosen_list)):
614
+ if idx not in used:
615
+ used = set(used) | {idx}
616
+ if len(used) >= len(chosen_list):
617
+ usage[usage_key] = set() # cycle complete; reset for next round
618
+ else:
619
+ usage[usage_key] = used
620
+ picked_idx = idx
621
+ break
622
+ else:
623
+ # Degenerate: usage was already saturated (race) — reset and pick 0.
624
+ usage[usage_key] = {0}
625
+ picked_idx = 0
626
+
627
+ picked_text = chosen_list[picked_idx]
628
+ picked_raw = (
629
+ raw_list[picked_idx]
630
+ if 0 <= picked_idx < len(raw_list)
631
+ else picked_text
632
+ )
633
+ return picked_text, picked_raw, chosen_lang
634
+
635
+ async def _maybe_switch_tts_service_for_action(self, action: dict) -> None:
636
+ """Queue a :class:`ManuallySwitchServiceFrame` when the action carries
637
+ an explicit ``voice`` and ``language`` override that maps to a known
638
+ service in ``state["tts_service_map"]``.
639
+
640
+ Silently no-ops when the action has no override, or when no matching
641
+ service is registered (bot author misconfiguration — logged at warning
642
+ level, audio plays through the active service unchanged).
643
+ """
644
+ voice = action.get("voice")
645
+ language = action.get("language")
646
+ if not voice or not language:
647
+ return
648
+ state = self._flow_manager.state or {}
649
+ tts_service_map = state.get("tts_service_map") or {}
650
+ task = state.get("task")
651
+ if not tts_service_map or not task:
652
+ return
653
+ # The map keys are `{provider}_{voice_id}_{locale}`; we don't know the
654
+ # provider from the action alone, so try every provider prefix until
655
+ # one matches. Cheap (typically 1–3 prefixes per agent).
656
+ candidate_keys = [
657
+ key for key in tts_service_map.keys()
658
+ if isinstance(key, str) and key.endswith(f"_{voice}_{language}")
659
+ ]
660
+ if not candidate_keys:
661
+ logger.warning(
662
+ f"tts_say per-action voice/language override ({voice}/{language}) "
663
+ f"has no matching service in tts_service_map; using active service"
664
+ )
665
+ return
666
+ target_service = tts_service_map.get(candidate_keys[0])
667
+ if target_service is None:
668
+ return
669
+ await task.queue_frames(
670
+ [ManuallySwitchServiceFrame(service=target_service)]
671
+ )
672
+ logger.debug(
673
+ f"tts_say per-action TTS switch: {candidate_keys[0]}"
674
+ )
675
+
367
676
  async def _handle_end_action(self, action: dict) -> None:
368
677
  """Built-in handler for ending the conversation.
369
678
 
@@ -416,6 +725,9 @@ class ActionManager:
416
725
  self._ongoing_actions_count += 1
417
726
  if self._ongoing_actions_count == 1:
418
727
  self._ongoing_actions_finished_event.clear()
728
+ # Reset the interruption flag — any prior InterruptionFrame applied to
729
+ # the previous speech, not this new one.
730
+ self._seen_interruption_since_increment = False
419
731
 
420
732
  def _decrement_ongoing_actions_count(self) -> None:
421
733
  """Decrement the count of ongoing actions and set the finished event if this was the last action."""
@@ -435,3 +747,4 @@ class ActionManager:
435
747
  )
436
748
  self._ongoing_actions_count = 0
437
749
  self._ongoing_actions_finished_event.set()
750
+ self._seen_interruption_since_increment = False