dv-pipecat-flows 0.0.0.dev2087__tar.gz → 0.0.0.dev2091__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.
- dv_pipecat_flows-0.0.0.dev2091/.python-version +1 -0
- {dv_pipecat_flows-0.0.0.dev2087/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.0.dev2091}/PKG-INFO +1 -2
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/README.md +0 -1
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/engine_primitives_plan.md +1 -1
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -2
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -26
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/actions.py +332 -19
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/adapters.py +272 -7
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/condition_evaluator.py +9 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/flow_validator.py +8 -2
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/manager.py +1081 -152
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/processors/speak_interruption_guard.py +8 -8
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/processors/user_turn_observer.py +15 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/router_mode.py +112 -90
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/types.py +26 -0
- dv_pipecat_flows-0.0.0.dev2087/.python-version +0 -1
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/.gitignore +0 -2
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/README.md +0 -213
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/RESULTS.md +0 -133
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -713
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/chunkers.py +0 -179
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/contracts.py +0 -128
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datagen/generate_corpus.py +0 -281
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datagen/generate_dataset.py +0 -413
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datagen/generate_hybrid.py +0 -181
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datagen/templates/insurance.yaml +0 -40
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datasets/.gitignore +0 -9
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datasets/v1_hybrid/manifest.json +0 -33
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datasets/v1_retrieval/manifest.json +0 -16
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -17
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/datasets/v1_synthetic/manifest.json +0 -45
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_filter_pick.py +0 -515
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_filter_scaling.py +0 -286
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_hybrid.py +0 -205
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_latency.py +0 -232
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_rerank.py +0 -279
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/runners/run_retrieval.py +0 -340
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/service/API.md +0 -490
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/service/__init__.py +0 -0
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/service/app.py +0 -332
- dv_pipecat_flows-0.0.0.dev2087/evals/kb/service/jobs.py +0 -230
- dv_pipecat_flows-0.0.0.dev2087/src/pipecat_flows/data_extractor.py +0 -249
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.agents/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.agents/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.claude/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.claude/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.gitattributes +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.gitignore +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.pre-commit-config.yaml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/.readthedocs.yaml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/AGENTS.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/CHANGELOG.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/CLAUDE.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/CONTRIBUTING.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/LICENSE +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/MANIFEST.in +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/dev-requirements.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/docker-compose.dev.yml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/.eslintrc.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/.prettierrc +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/css/tailwind.css +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/examples/food_ordering.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/examples/movie_explorer.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/examples/patient_intake.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/examples/restaurant_reservation.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/examples/travel_planner.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/favicon.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/index.html +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/editor/canvas.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/editor/editorState.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/editor/sidePanel.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/editor/toolbar.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/main.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/baseNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/endNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/flowNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/functionNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/index.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/mergeNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/nodes/startNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/types.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/utils/export.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/utils/helpers.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/utils/import.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/js/utils/validation.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/jsdoc.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/package-lock.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/package.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/postcss.config.cjs +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/public/favicon.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/public/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/tailwind.config.cjs +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/vercel.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/editor/vite.config.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/env.example +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/images/food-ordering-flow.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/pipecat-flows.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/pipecat_upgrade.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/pyproject.toml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/remote-asterisk-code/README.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/remote-asterisk-code/extensions.conf +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/remote-asterisk-code/rtp.conf +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/requirements.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/scripts/check-pypi-package.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/scripts/fix-ruff.sh +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/scripts/pre-commit.sh +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/setup.cfg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/__init__.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/exceptions.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/processors/__init__.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/uv.lock +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dv-pipecat-flows
|
|
3
|
-
Version: 0.0.0.
|
|
3
|
+
Version: 0.0.0.dev2091
|
|
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
|
|
@@ -309,4 +309,3 @@ We aim to review all contributions promptly and provide constructive feedback to
|
|
|
309
309
|
➡️ [Reach us on X](https://x.com/pipecat_ai)
|
|
310
310
|
|
|
311
311
|
|
|
312
|
-
|
|
@@ -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
|
|
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.
|
|
3
|
+
Version: 0.0.0.dev2091
|
|
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
|
|
@@ -309,4 +309,3 @@ We aim to review all contributions promptly and provide constructive feedback to
|
|
|
309
309
|
➡️ [Reach us on X](https://x.com/pipecat_ai)
|
|
310
310
|
|
|
311
311
|
|
|
312
|
-
|
|
@@ -60,31 +60,6 @@ editor/js/utils/import.js
|
|
|
60
60
|
editor/js/utils/validation.js
|
|
61
61
|
editor/public/favicon.png
|
|
62
62
|
editor/public/favicon.svg
|
|
63
|
-
evals/kb/.gitignore
|
|
64
|
-
evals/kb/README.md
|
|
65
|
-
evals/kb/RESULTS.md
|
|
66
|
-
evals/kb/UNIFIED_KB_BENCHMARKS.md
|
|
67
|
-
evals/kb/chunkers.py
|
|
68
|
-
evals/kb/contracts.py
|
|
69
|
-
evals/kb/datagen/generate_corpus.py
|
|
70
|
-
evals/kb/datagen/generate_dataset.py
|
|
71
|
-
evals/kb/datagen/generate_hybrid.py
|
|
72
|
-
evals/kb/datagen/templates/insurance.yaml
|
|
73
|
-
evals/kb/datasets/.gitignore
|
|
74
|
-
evals/kb/datasets/v1_hybrid/manifest.json
|
|
75
|
-
evals/kb/datasets/v1_retrieval/manifest.json
|
|
76
|
-
evals/kb/datasets/v1_retrieval_long/manifest.json
|
|
77
|
-
evals/kb/datasets/v1_synthetic/manifest.json
|
|
78
|
-
evals/kb/runners/run_filter_pick.py
|
|
79
|
-
evals/kb/runners/run_filter_scaling.py
|
|
80
|
-
evals/kb/runners/run_hybrid.py
|
|
81
|
-
evals/kb/runners/run_latency.py
|
|
82
|
-
evals/kb/runners/run_rerank.py
|
|
83
|
-
evals/kb/runners/run_retrieval.py
|
|
84
|
-
evals/kb/service/API.md
|
|
85
|
-
evals/kb/service/__init__.py
|
|
86
|
-
evals/kb/service/app.py
|
|
87
|
-
evals/kb/service/jobs.py
|
|
88
63
|
images/food-ordering-flow.png
|
|
89
64
|
remote-asterisk-code/README.md
|
|
90
65
|
remote-asterisk-code/extensions.conf
|
|
@@ -101,7 +76,6 @@ src/pipecat_flows/__init__.py
|
|
|
101
76
|
src/pipecat_flows/actions.py
|
|
102
77
|
src/pipecat_flows/adapters.py
|
|
103
78
|
src/pipecat_flows/condition_evaluator.py
|
|
104
|
-
src/pipecat_flows/data_extractor.py
|
|
105
79
|
src/pipecat_flows/exceptions.py
|
|
106
80
|
src/pipecat_flows/flow_validator.py
|
|
107
81
|
src/pipecat_flows/manager.py
|
{dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2091}/src/pipecat_flows/actions.py
RENAMED
|
@@ -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
|
-
(
|
|
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
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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 =
|
|
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
|
-
|
|
347
|
-
|
|
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
|