├── .github └── workflows │ ├── publish-pypi.yml │ ├── publish-test.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dev-requirements.txt ├── editor ├── .eslintrc.json ├── .prettierrc ├── css │ └── tailwind.css ├── examples │ ├── food_ordering.json │ ├── movie_explorer.json │ ├── patient_intake.json │ ├── restaurant_reservation.json │ └── travel_planner.json ├── favicon.png ├── favicon.svg ├── index.html ├── js │ ├── editor │ │ ├── canvas.js │ │ ├── editorState.js │ │ ├── sidePanel.js │ │ └── toolbar.js │ ├── main.js │ ├── nodes │ │ ├── baseNode.js │ │ ├── endNode.js │ │ ├── flowNode.js │ │ ├── functionNode.js │ │ ├── index.js │ │ ├── mergeNode.js │ │ └── startNode.js │ ├── types.js │ └── utils │ │ ├── export.js │ │ ├── helpers.js │ │ ├── import.js │ │ └── validation.js ├── jsdoc.json ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public │ ├── favicon.png │ └── favicon.svg ├── tailwind.config.cjs ├── vercel.json └── vite.config.js ├── env.example ├── examples ├── assets │ └── hold_music │ │ ├── README.md │ │ ├── hold_music.py │ │ └── hold_music.wav ├── dynamic │ ├── insurance_anthropic.py │ ├── insurance_aws_bedrock.py │ ├── insurance_gemini.py │ ├── insurance_openai.py │ ├── restaurant_reservation.py │ └── warm_transfer.py ├── runner.py └── static │ ├── food_ordering.py │ ├── movie_explorer_anthropic.py │ ├── movie_explorer_gemini.py │ ├── movie_explorer_openai.py │ ├── patient_intake_anthropic.py │ ├── patient_intake_aws_bedrock.py │ ├── patient_intake_gemini.py │ ├── patient_intake_openai.py │ └── travel_planner.py ├── images └── food-ordering-flow.png ├── pipecat-flows.png ├── pyproject.toml ├── requirements.txt ├── src └── pipecat_flows │ ├── __init__.py │ ├── actions.py │ ├── adapters.py │ ├── exceptions.py │ ├── manager.py │ └── types.py ├── test-requirements.txt └── tests ├── __init__.py ├── test_actions.py ├── test_adapters.py ├── test_context_strategies.py └── test_manager.py /.github/workflows/publish-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to publish (must match pyproject.toml, e.g. 0.0.4)' 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | build-and-publish: 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/pipecat-ai-flows 17 | permissions: 18 | id-token: write # IMPORTANT: this permission is needed for trusted publishing 19 | contents: write # Needed for tagging 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: '3.10' 29 | 30 | - name: Verify version matches 31 | run: | 32 | pip install tomli 33 | PROJECT_VERSION=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['project']['version'])") 34 | if [ "$PROJECT_VERSION" != "${{ github.event.inputs.version }}" ]; then 35 | echo "Error: Input version (${{ github.event.inputs.version }}) does not match pyproject.toml version ($PROJECT_VERSION)" 36 | exit 1 37 | fi 38 | 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install -r requirements.txt 43 | pip install build twine 44 | 45 | - name: Build package 46 | run: python -m build 47 | 48 | - name: Create release tag 49 | run: | 50 | git config --local user.email "action@github.com" 51 | git config --local user.name "GitHub Action" 52 | git tag -a "v${{ github.event.inputs.version }}" -m "Version ${{ github.event.inputs.version }}" 53 | git push origin "v${{ github.event.inputs.version }}" 54 | 55 | - name: Publish to PyPI 56 | uses: pypa/gh-action-pypi-publish@release/v1 57 | with: 58 | verbose: true 59 | print-hash: true 60 | -------------------------------------------------------------------------------- /.github/workflows/publish-test.yml: -------------------------------------------------------------------------------- 1 | name: Publish to TestPyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to publish (must match pyproject.toml, e.g. 0.0.4)' 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | build-and-publish: 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: testpypi 16 | url: https://test.pypi.org/p/pipecat-ai-flows 17 | permissions: 18 | id-token: write # IMPORTANT: this permission is needed for trusted publishing 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: '3.10' 28 | 29 | - name: Verify version matches 30 | run: | 31 | pip install tomli 32 | PROJECT_VERSION=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['project']['version'])") 33 | if [ "$PROJECT_VERSION" != "${{ github.event.inputs.version }}" ]; then 34 | echo "Error: Input version (${{ github.event.inputs.version }}) does not match pyproject.toml version ($PROJECT_VERSION)" 35 | exit 1 36 | fi 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | pip install build twine 43 | 44 | - name: Build package 45 | run: python -m build 46 | 47 | - name: Publish to TestPyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | with: 50 | repository-url: https://test.pypi.org/legacy/ 51 | verbose: true 52 | print-hash: true 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - '**' 11 | 12 | concurrency: 13 | group: build-test-${{ github.event.pull_request.number || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | test: 18 | name: 'Unit Tests' 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | id: setup_python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: '3.10' 29 | 30 | - name: Cache virtual environment 31 | uses: actions/cache@v3 32 | with: 33 | key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version}}-${{ hashFiles('dev-requirements.txt') }}-${{ hashFiles('test-requirements.txt') }} 34 | path: .venv 35 | 36 | - name: Setup virtual environment 37 | run: | 38 | python -m venv .venv 39 | 40 | - name: Install dependencies 41 | run: | 42 | source .venv/bin/activate 43 | python -m pip install --upgrade pip 44 | pip install -r dev-requirements.txt -r test-requirements.txt 45 | pip uninstall -y google-generativeai google-genai google-ai-generativelanguage 46 | pip install "google-genai==0.7.0" 47 | pip install "pipecat-ai[google,openai,anthropic]" 48 | pip install -e . 49 | 50 | - name: Test with pytest 51 | run: | 52 | source .venv/bin/activate 53 | pytest tests/ --cov=pipecat_flows 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .pnpm-debug.log* 7 | 8 | # Production build 9 | dist/ 10 | build/ 11 | 12 | # IDE - VSCode 13 | .vscode/ 14 | *.code-workspace 15 | .history 16 | 17 | # IDE - WebStorm/IntelliJ 18 | .idea/ 19 | *.iml 20 | *.iws 21 | *.ipr 22 | 23 | # IDE - Sublime Text 24 | *.sublime-workspace 25 | *.sublime-project 26 | 27 | # macOS 28 | .DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | ._* 32 | 33 | # Windows 34 | Thumbs.db 35 | Thumbs.db:encryptable 36 | ehthumbs.db 37 | ehthumbs_vista.db 38 | *.lnk 39 | 40 | # Local development settings 41 | .env 42 | .env.local 43 | .env.development.local 44 | .env.test.local 45 | .env.production.local 46 | 47 | # Debug logs 48 | logs/ 49 | *.log 50 | npm-debug.log* 51 | 52 | # Optional: Cache directories 53 | .cache/ 54 | .npm/ 55 | .eslintcache 56 | 57 | # Optional: Editor directories and files 58 | .project 59 | .settings/ 60 | *.suo 61 | *.ntvs* 62 | *.njsproj 63 | *.sln 64 | *.sw? 65 | 66 | # JSDoc documentation 67 | docs/ 68 | 69 | 70 | # Python 71 | __pycache__/ 72 | *.py[cod] 73 | *$py.class 74 | *.so 75 | .Python 76 | develop-eggs/ 77 | downloads/ 78 | eggs/ 79 | .eggs/ 80 | lib/ 81 | lib64/ 82 | parts/ 83 | sdist/ 84 | var/ 85 | wheels/ 86 | *.egg-info/ 87 | .installed.cfg 88 | *.egg 89 | MANIFEST 90 | .python-version 91 | *.pyc 92 | 93 | # Virtual Environment 94 | venv/ 95 | ENV/ 96 | env/ 97 | .env/ 98 | .venv/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Testing 103 | .tox/ 104 | .coverage 105 | .coverage.* 106 | .cache 107 | nosetests.xml 108 | coverage.xml 109 | *.cover 110 | .hypothesis/ 111 | .pytest_cache/ 112 | htmlcov/ 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Jupyter Notebook 120 | .ipynb_checkpoints 121 | 122 | # IPython 123 | profile_default/ 124 | ipython_config.py 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # Distribution / packaging 134 | .Python 135 | build/ 136 | develop-eggs/ 137 | dist/ 138 | downloads/ 139 | eggs/ 140 | .eggs/ 141 | lib/ 142 | lib64/ 143 | parts/ 144 | sdist/ 145 | var/ 146 | wheels/ 147 | share/python-wheels/ 148 | *.egg-info/ 149 | .installed.cfg 150 | *.egg 151 | MANIFEST -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to Pipecat Flows 2 | 3 | We welcome contributions of all kinds! Your help is appreciated. Follow these steps to get involved: 4 | 5 | 1. **Fork this repository**: Start by forking the Pipecat Flows repository to your GitHub account. 6 | 7 | 2. **Clone the repository**: Clone your forked repository to your local machine. 8 | ```bash 9 | git clone https://github.com/your-username/pipecat-flows 10 | ``` 11 | 3. **Create a branch**: For your contribution, create a new branch. 12 | ```bash 13 | git checkout -b your-branch-name 14 | ``` 15 | 4. **Make your changes**: Edit or add files as necessary. 16 | 5. **Test your changes**: Ensure that your changes look correct and follow the style set in the codebase. 17 | 6. **Commit your changes**: Once you're satisfied with your changes, commit them with a meaningful message. 18 | 19 | ```bash 20 | git commit -m "Description of your changes" 21 | ``` 22 | 23 | 7. **Push your changes**: Push your branch to your forked repository. 24 | 25 | ```bash 26 | git push origin your-branch-name 27 | ``` 28 | 29 | 9. **Submit a Pull Request (PR)**: Open a PR from your forked repository to the main branch of this repo. 30 | > Important: Describe the changes you've made clearly! 31 | 32 | Our maintainers will review your PR, and once everything is good, your contributions will be merged! 33 | 34 | # Contributor Covenant Code of Conduct 35 | 36 | ## Our Pledge 37 | 38 | We as members, contributors, and leaders pledge to make participation in our 39 | community a harassment-free experience for everyone, regardless of age, body 40 | size, visible or invisible disability, ethnicity, sex characteristics, gender 41 | identity and expression, level of experience, education, socio-economic status, 42 | nationality, personal appearance, race, caste, color, religion, or sexual 43 | identity and orientation. 44 | 45 | We pledge to act and interact in ways that contribute to an open, welcoming, 46 | diverse, inclusive, and healthy community. 47 | 48 | ## Our Standards 49 | 50 | Examples of behavior that contributes to a positive environment for our 51 | community include: 52 | 53 | - Demonstrating empathy and kindness toward other people 54 | - Being respectful of differing opinions, viewpoints, and experiences 55 | - Giving and gracefully accepting constructive feedback 56 | - Accepting responsibility and apologizing to those affected by our mistakes, 57 | and learning from the experience 58 | - Focusing on what is best not just for us as individuals, but for the overall 59 | community 60 | 61 | Examples of unacceptable behavior include: 62 | 63 | - The use of sexualized language or imagery, and sexual attention or advances of 64 | any kind 65 | - Trolling, insulting or derogatory comments, and personal or political attacks 66 | - Public or private harassment 67 | - Publishing others' private information, such as a physical or email address, 68 | without their explicit permission 69 | - Other conduct which could reasonably be considered inappropriate in a 70 | professional setting 71 | 72 | ## Enforcement Responsibilities 73 | 74 | Community leaders are responsible for clarifying and enforcing our standards of 75 | acceptable behavior and will take appropriate and fair corrective action in 76 | response to any behavior that they deem inappropriate, threatening, offensive, 77 | or harmful. 78 | 79 | Community leaders have the right and responsibility to remove, edit, or reject 80 | comments, commits, code, wiki edits, issues, and other contributions that are 81 | not aligned to this Code of Conduct, and will communicate reasons for moderation 82 | decisions when appropriate. 83 | 84 | ## Scope 85 | 86 | This Code of Conduct applies within all community spaces, and also applies when 87 | an individual is officially representing the community in public spaces. 88 | Examples of representing our community include using an official email address, 89 | posting via an official social media account, or acting as an appointed 90 | representative at an online or offline event. 91 | 92 | ## Enforcement 93 | 94 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 95 | reported to the community leaders responsible for enforcement at pipecat-ai@daily.co. 96 | All complaints will be reviewed and investigated promptly and fairly. 97 | 98 | All community leaders are obligated to respect the privacy and security of the 99 | reporter of any incident. 100 | 101 | ## Enforcement Guidelines 102 | 103 | Community leaders will follow these Community Impact Guidelines in determining 104 | the consequences for any action they deem in violation of this Code of Conduct: 105 | 106 | ### 1. Correction 107 | 108 | **Community Impact**: Use of inappropriate language or other behavior deemed 109 | unprofessional or unwelcome in the community. 110 | 111 | **Consequence**: A private, written warning from community leaders, providing 112 | clarity around the nature of the violation and an explanation of why the 113 | behavior was inappropriate. A public apology may be requested. 114 | 115 | ### 2. Warning 116 | 117 | **Community Impact**: A violation through a single incident or series of 118 | actions. 119 | 120 | **Consequence**: A warning with consequences for continued behavior. No 121 | interaction with the people involved, including unsolicited interaction with 122 | those enforcing the Code of Conduct, for a specified period of time. This 123 | includes avoiding interactions in community spaces as well as external channels 124 | like social media. Violating these terms may lead to a temporary or permanent 125 | ban. 126 | 127 | ### 3. Temporary Ban 128 | 129 | **Community Impact**: A serious violation of community standards, including 130 | sustained inappropriate behavior. 131 | 132 | **Consequence**: A temporary ban from any sort of interaction or public 133 | communication with the community for a specified period of time. No public or 134 | private interaction with the people involved, including unsolicited interaction 135 | with those enforcing the Code of Conduct, is allowed during this period. 136 | Violating these terms may lead to a permanent ban. 137 | 138 | ### 4. Permanent Ban 139 | 140 | **Community Impact**: Demonstrating a pattern of violation of community 141 | standards, including sustained inappropriate behavior, harassment of an 142 | individual, or aggression toward or disparagement of classes of individuals. 143 | 144 | **Consequence**: A permanent ban from any sort of public interaction within the 145 | community. 146 | 147 | ## Attribution 148 | 149 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 150 | version 2.1, available at 151 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 152 | 153 | Community Impact Guidelines were inspired by 154 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 155 | 156 | For answers to common questions about this code of conduct, see the FAQ at 157 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 158 | [https://www.contributor-covenant.org/translations][translations]. 159 | 160 | [homepage]: https://www.contributor-covenant.org 161 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 162 | [Mozilla CoC]: https://github.com/mozilla/diversity 163 | [FAQ]: https://www.contributor-covenant.org/faq 164 | [translations]: https://www.contributor-covenant.org/translations 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2024, Daily 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | build~=1.2.1 2 | pip-tools~=7.4.1 3 | pytest~=8.3.2 4 | pytest-asyncio~=0.23.5 5 | pytest-cov~=4.1.0 6 | ruff~=0.9.1 7 | setuptools~=72.2.0 8 | python-dotenv~=1.0.1 -------------------------------------------------------------------------------- /editor/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "prettier"], 3 | "rules": { 4 | "import/order": [ 5 | "error", 6 | { 7 | "groups": [ 8 | ["builtin", "external"], 9 | "internal", 10 | "parent", 11 | "sibling", 12 | "index", 13 | "object", 14 | "type" 15 | ], 16 | "newlines-between": "always" 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /editor/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /editor/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .json-error-message { 7 | @apply text-error text-sm mt-1 mb-2; 8 | } 9 | 10 | .textarea-invalid { 11 | @apply textarea-error; 12 | } 13 | 14 | .textarea-unsaved { 15 | @apply border-dashed; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /editor/examples/food_ordering.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_node": "start", 3 | "nodes": { 4 | "start": { 5 | "role_messages": [ 6 | { 7 | "role": "system", 8 | "content": "You are an order-taking assistant. You must ALWAYS use the available functions to progress the conversation. This is a phone conversation and your responses will be converted to audio. Keep the conversation friendly, casual, and polite. Avoid outputting special characters and emojis." 9 | } 10 | ], 11 | "task_messages": [ 12 | { 13 | "role": "system", 14 | "content": "For this step, ask the user if they want pizza or sushi, and wait for them to use a function to choose. Start off by greeting them. Be friendly and casual; you're taking an order for food over the phone." 15 | } 16 | ], 17 | "functions": [ 18 | { 19 | "type": "function", 20 | "function": { 21 | "name": "choose_pizza", 22 | "description": "User wants to order pizza. Let's get that order started.", 23 | "parameters": { 24 | "type": "object", 25 | "properties": {} 26 | }, 27 | "transition_to": "choose_pizza" 28 | } 29 | }, 30 | { 31 | "type": "function", 32 | "function": { 33 | "name": "choose_sushi", 34 | "description": "User wants to order sushi. Let's get that order started.", 35 | "parameters": { 36 | "type": "object", 37 | "properties": {} 38 | }, 39 | "transition_to": "choose_sushi" 40 | } 41 | } 42 | ] 43 | }, 44 | "choose_pizza": { 45 | "task_messages": [ 46 | { 47 | "role": "system", 48 | "content": "You are handling a pizza order. Use the available functions:\n\n- Use select_pizza_order when the user specifies both size AND type\n\n- Use confirm_order when the user confirms they are satisfied with their selection\n\nPricing:\n\n- Small: $10\n\n- Medium: $15\n\n- Large: $20\n\nAfter selection, confirm both the size and type, state the price, and ask if they want to confirm their order. Remember to be friendly and casual." 49 | } 50 | ], 51 | "functions": [ 52 | { 53 | "type": "function", 54 | "function": { 55 | "name": "select_pizza_order", 56 | "handler": "__function__:select_pizza_order", 57 | "description": "Record the pizza order details", 58 | "parameters": { 59 | "type": "object", 60 | "properties": { 61 | "size": { 62 | "type": "string", 63 | "enum": ["small", "medium", "large"], 64 | "description": "Size of the pizza" 65 | }, 66 | "type": { 67 | "type": "string", 68 | "enum": ["pepperoni", "cheese", "supreme", "vegetarian"], 69 | "description": "Type of pizza" 70 | } 71 | }, 72 | "required": ["size", "type"] 73 | } 74 | } 75 | }, 76 | { 77 | "type": "function", 78 | "function": { 79 | "name": "confirm_order", 80 | "description": "Proceed to order confirmation", 81 | "parameters": { 82 | "type": "object", 83 | "properties": {} 84 | }, 85 | "transition_to": "confirm" 86 | } 87 | } 88 | ] 89 | }, 90 | "choose_sushi": { 91 | "task_messages": [ 92 | { 93 | "role": "system", 94 | "content": "You are handling a sushi order. Use the available functions:\n\n- Use select_sushi_order when the user specifies both count AND type\n\n- Use confirm_order when the user confirms they are satisfied with their selection\n\nPricing:\n\n- $8 per roll\n\nAfter selection, confirm both the count and type, state the price, and ask if they want to confirm their order. Remember to be friendly and casual." 95 | } 96 | ], 97 | "functions": [ 98 | { 99 | "type": "function", 100 | "function": { 101 | "name": "select_sushi_order", 102 | "handler": "__function__:select_sushi_order", 103 | "description": "Record the sushi order details", 104 | "parameters": { 105 | "type": "object", 106 | "properties": { 107 | "count": { 108 | "type": "integer", 109 | "minimum": 1, 110 | "maximum": 10, 111 | "description": "Number of rolls to order" 112 | }, 113 | "type": { 114 | "type": "string", 115 | "enum": ["california", "spicy tuna", "rainbow", "dragon"], 116 | "description": "Type of sushi roll" 117 | } 118 | }, 119 | "required": ["count", "type"] 120 | } 121 | } 122 | }, 123 | { 124 | "type": "function", 125 | "function": { 126 | "name": "confirm_order", 127 | "description": "Proceed to order confirmation", 128 | "parameters": { 129 | "type": "object", 130 | "properties": {} 131 | }, 132 | "transition_to": "confirm" 133 | } 134 | } 135 | ] 136 | }, 137 | "confirm": { 138 | "task_messages": [ 139 | { 140 | "role": "system", 141 | "content": "Read back the complete order details to the user and ask for final confirmation. Use the available functions:\n\n- Use complete_order when the user confirms\n\n- Use revise_order if they want to change something\n\nBe friendly and clear when reading back the order details." 142 | } 143 | ], 144 | "functions": [ 145 | { 146 | "type": "function", 147 | "function": { 148 | "name": "complete_order", 149 | "description": "User confirms the order is correct", 150 | "parameters": { 151 | "type": "object", 152 | "properties": {} 153 | }, 154 | "transition_to": "end" 155 | } 156 | } 157 | ] 158 | }, 159 | "end": { 160 | "task_messages": [ 161 | { 162 | "role": "system", 163 | "content": "Concisely end the conversation—1-3 words is appropriate. Just say 'Bye' or something similarly short." 164 | } 165 | ], 166 | "functions": [], 167 | "post_actions": [ 168 | { 169 | "type": "end_conversation" 170 | } 171 | ] 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /editor/examples/movie_explorer.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_node": "greeting", 3 | "nodes": { 4 | "greeting": { 5 | "role_messages": [ 6 | { 7 | "role": "system", 8 | "content": "You are a friendly movie expert. Your responses will be converted to audio, so avoid special characters. Always use the available functions to progress the conversation naturally." 9 | } 10 | ], 11 | "task_messages": [ 12 | { 13 | "role": "system", 14 | "content": "Start by greeting the user and asking if they'd like to know about movies currently in theaters or upcoming releases. Wait for their choice before using either get_current_movies or get_upcoming_movies." 15 | } 16 | ], 17 | "functions": [ 18 | { 19 | "type": "function", 20 | "function": { 21 | "name": "get_current_movies", 22 | "handler": "__function__:get_movies", 23 | "description": "Fetch movies currently playing in theaters", 24 | "parameters": { 25 | "type": "object", 26 | "properties": {} 27 | }, 28 | "transition_to": "explore_movie" 29 | } 30 | }, 31 | { 32 | "type": "function", 33 | "function": { 34 | "name": "get_upcoming_movies", 35 | "handler": "__function__:get_upcoming_movies", 36 | "description": "Fetch movies coming soon to theaters", 37 | "parameters": { 38 | "type": "object", 39 | "properties": {} 40 | }, 41 | "transition_to": "explore_movie" 42 | } 43 | } 44 | ] 45 | }, 46 | "explore_movie": { 47 | "task_messages": [ 48 | { 49 | "role": "system", 50 | "content": "Help the user learn more about movies. You can:\n\n- Use get_movie_details when they express interest in a specific movie\n\n- Use get_similar_movies to show recommendations\n\n- Use get_current_movies to see what's playing now\n\n- Use get_upcoming_movies to see what's coming soon\n\n- Use end_conversation when they're done exploring\n\nAfter showing details or recommendations, ask if they'd like to explore another movie or end the conversation." 51 | } 52 | ], 53 | "functions": [ 54 | { 55 | "type": "function", 56 | "function": { 57 | "name": "get_movie_details", 58 | "handler": "__function__:get_movie_details", 59 | "description": "Get details about a specific movie including cast", 60 | "parameters": { 61 | "type": "object", 62 | "properties": { 63 | "movie_id": { 64 | "type": "integer", 65 | "description": "TMDB movie ID" 66 | } 67 | }, 68 | "required": ["movie_id"] 69 | } 70 | } 71 | }, 72 | { 73 | "type": "function", 74 | "function": { 75 | "name": "get_similar_movies", 76 | "handler": "__function__:get_similar_movies", 77 | "description": "Get similar movies as recommendations", 78 | "parameters": { 79 | "type": "object", 80 | "properties": { 81 | "movie_id": { 82 | "type": "integer", 83 | "description": "TMDB movie ID" 84 | } 85 | }, 86 | "required": ["movie_id"] 87 | } 88 | } 89 | }, 90 | { 91 | "type": "function", 92 | "function": { 93 | "name": "get_current_movies", 94 | "handler": "__function__:get_movies", 95 | "description": "Show current movies in theaters", 96 | "parameters": { 97 | "type": "object", 98 | "properties": {} 99 | } 100 | } 101 | }, 102 | { 103 | "type": "function", 104 | "function": { 105 | "name": "get_upcoming_movies", 106 | "handler": "__function__:get_upcoming_movies", 107 | "description": "Show movies coming soon", 108 | "parameters": { 109 | "type": "object", 110 | "properties": {} 111 | } 112 | } 113 | }, 114 | { 115 | "type": "function", 116 | "function": { 117 | "name": "end_conversation", 118 | "description": "End the conversation", 119 | "parameters": { 120 | "type": "object", 121 | "properties": {} 122 | }, 123 | "transition_to": "end" 124 | } 125 | } 126 | ] 127 | }, 128 | "end": { 129 | "task_messages": [ 130 | { 131 | "role": "system", 132 | "content": "Thank the user warmly and mention they can return anytime to discover more movies." 133 | } 134 | ], 135 | "functions": [], 136 | "post_actions": [ 137 | { 138 | "type": "end_conversation" 139 | } 140 | ] 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /editor/examples/patient_intake.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_node": "start", 3 | "nodes": { 4 | "start": { 5 | "role_messages": [ 6 | { 7 | "role": "system", 8 | "content": "You are Jessica, an agent for Tri-County Health Services. You must ALWAYS use one of the available functions to progress the conversation. Be professional but friendly." 9 | } 10 | ], 11 | "task_messages": [ 12 | { 13 | "role": "system", 14 | "content": "Start by introducing yourself to Chad Bailey, then ask for their date of birth, including the year. Once they provide their birthday, use verify_birthday to check it. If verified (1983-01-01), proceed to prescriptions." 15 | } 16 | ], 17 | "functions": [ 18 | { 19 | "type": "function", 20 | "function": { 21 | "name": "verify_birthday", 22 | "handler": "__function__:verify_birthday", 23 | "description": "Verify the user has provided their correct birthday. Once confirmed, the next step is to recording the user's prescriptions.", 24 | "parameters": { 25 | "type": "object", 26 | "properties": { 27 | "birthday": { 28 | "type": "string", 29 | "description": "The user's birthdate (convert to YYYY-MM-DD format)" 30 | } 31 | }, 32 | "required": ["birthday"] 33 | }, 34 | "transition_to": "get_prescriptions" 35 | } 36 | } 37 | ] 38 | }, 39 | "get_prescriptions": { 40 | "task_messages": [ 41 | { 42 | "role": "system", 43 | "content": "This step is for collecting prescriptions. Ask them what prescriptions they're taking, including the dosage. After recording prescriptions (or confirming none), proceed to allergies." 44 | } 45 | ], 46 | "functions": [ 47 | { 48 | "type": "function", 49 | "function": { 50 | "name": "record_prescriptions", 51 | "handler": "__function__:record_prescriptions", 52 | "description": "Record the user's prescriptions. Once confirmed, the next step is to collect allergy information.", 53 | "parameters": { 54 | "type": "object", 55 | "properties": { 56 | "prescriptions": { 57 | "type": "array", 58 | "items": { 59 | "type": "object", 60 | "properties": { 61 | "medication": { 62 | "type": "string", 63 | "description": "The medication's name" 64 | }, 65 | "dosage": { 66 | "type": "string", 67 | "description": "The prescription's dosage" 68 | } 69 | }, 70 | "required": ["medication", "dosage"] 71 | } 72 | } 73 | }, 74 | "required": ["prescriptions"] 75 | }, 76 | "transition_to": "get_allergies" 77 | } 78 | } 79 | ] 80 | }, 81 | "get_allergies": { 82 | "task_messages": [ 83 | { 84 | "role": "system", 85 | "content": "Collect allergy information. Ask about any allergies they have. After recording allergies (or confirming none), proceed to medical conditions." 86 | } 87 | ], 88 | "functions": [ 89 | { 90 | "type": "function", 91 | "function": { 92 | "name": "record_allergies", 93 | "handler": "__function__:record_allergies", 94 | "description": "Record the user's allergies. Once confirmed, then next step is to collect medical conditions.", 95 | "parameters": { 96 | "type": "object", 97 | "properties": { 98 | "allergies": { 99 | "type": "array", 100 | "items": { 101 | "type": "object", 102 | "properties": { 103 | "name": { 104 | "type": "string", 105 | "description": "What the user is allergic to" 106 | } 107 | }, 108 | "required": ["name"] 109 | } 110 | } 111 | }, 112 | "required": ["allergies"] 113 | }, 114 | "transition_to": "get_conditions" 115 | } 116 | } 117 | ] 118 | }, 119 | "get_conditions": { 120 | "task_messages": [ 121 | { 122 | "role": "system", 123 | "content": "Collect medical condition information. Ask about any medical conditions they have. After recording conditions (or confirming none), proceed to visit reasons." 124 | } 125 | ], 126 | "functions": [ 127 | { 128 | "type": "function", 129 | "function": { 130 | "name": "record_conditions", 131 | "handler": "__function__:record_conditions", 132 | "description": "Record the user's medical conditions. Once confirmed, the next step is to collect visit reasons.", 133 | "parameters": { 134 | "type": "object", 135 | "properties": { 136 | "conditions": { 137 | "type": "array", 138 | "items": { 139 | "type": "object", 140 | "properties": { 141 | "name": { 142 | "type": "string", 143 | "description": "The user's medical condition" 144 | } 145 | }, 146 | "required": ["name"] 147 | } 148 | } 149 | }, 150 | "required": ["conditions"] 151 | }, 152 | "transition_to": "get_visit_reasons" 153 | } 154 | } 155 | ] 156 | }, 157 | "get_visit_reasons": { 158 | "task_messages": [ 159 | { 160 | "role": "system", 161 | "content": "Collect information about the reason for their visit. Ask what brings them to the doctor today. After recording their reasons, proceed to verification." 162 | } 163 | ], 164 | "functions": [ 165 | { 166 | "type": "function", 167 | "function": { 168 | "name": "record_visit_reasons", 169 | "handler": "__function__:record_visit_reasons", 170 | "description": "Record the reasons for their visit. Once confirmed, the next step is to verify all information.", 171 | "parameters": { 172 | "type": "object", 173 | "properties": { 174 | "visit_reasons": { 175 | "type": "array", 176 | "items": { 177 | "type": "object", 178 | "properties": { 179 | "name": { 180 | "type": "string", 181 | "description": "The user's reason for visiting" 182 | } 183 | }, 184 | "required": ["name"] 185 | } 186 | } 187 | }, 188 | "required": ["visit_reasons"] 189 | }, 190 | "transition_to": "verify" 191 | } 192 | } 193 | ] 194 | }, 195 | "verify": { 196 | "task_messages": [ 197 | { 198 | "role": "system", 199 | "content": "Review all collected information with the patient. Follow these steps:\n\n1. Summarize their prescriptions, allergies, conditions, and visit reasons\n\n2. Ask if everything is correct\n\n3. Use the appropriate function based on their response\n\nBe thorough in reviewing all details and wait for explicit confirmation." 200 | } 201 | ], 202 | "functions": [ 203 | { 204 | "type": "function", 205 | "function": { 206 | "name": "revise_information", 207 | "description": "Return to prescriptions to revise information", 208 | "parameters": { 209 | "type": "object", 210 | "properties": {} 211 | }, 212 | "transition_to": "get_prescriptions" 213 | } 214 | }, 215 | { 216 | "type": "function", 217 | "function": { 218 | "name": "confirm_information", 219 | "description": "Proceed with confirmed information", 220 | "parameters": { 221 | "type": "object", 222 | "properties": {} 223 | }, 224 | "transition_to": "confirm" 225 | } 226 | } 227 | ] 228 | }, 229 | "confirm": { 230 | "task_messages": [ 231 | { 232 | "role": "system", 233 | "content": "Once confirmed, thank them, then use the complete_intake function to end the conversation." 234 | } 235 | ], 236 | "functions": [ 237 | { 238 | "type": "function", 239 | "function": { 240 | "name": "complete_intake", 241 | "description": "Complete the intake process", 242 | "parameters": { 243 | "type": "object", 244 | "properties": {} 245 | }, 246 | "transition_to": "end" 247 | } 248 | } 249 | ] 250 | }, 251 | "end": { 252 | "task_messages": [ 253 | { 254 | "role": "system", 255 | "content": "Thank them for their time and end the conversation." 256 | } 257 | ], 258 | "functions": [], 259 | "post_actions": [ 260 | { 261 | "type": "end_conversation" 262 | } 263 | ] 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /editor/examples/restaurant_reservation.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_node": "start", 3 | "nodes": { 4 | "start": { 5 | "role_messages": [ 6 | { 7 | "role": "system", 8 | "content": "You are a restaurant reservation assistant for La Maison, an upscale French restaurant. You must ALWAYS use one of the available functions to progress the conversation. This is a phone conversations and your responses will be converted to audio. Avoid outputting special characters and emojis. Be causal and friendly." 9 | } 10 | ], 11 | "task_messages": [ 12 | { 13 | "role": "system", 14 | "content": "Warmly greet the customer and ask how many people are in their party." 15 | } 16 | ], 17 | "functions": [ 18 | { 19 | "type": "function", 20 | "function": { 21 | "name": "record_party_size", 22 | "handler": "__function__:record_party_size", 23 | "description": "Record the number of people in the party", 24 | "parameters": { 25 | "type": "object", 26 | "properties": { 27 | "size": { 28 | "type": "integer", 29 | "minimum": 1, 30 | "maximum": 12 31 | } 32 | }, 33 | "required": ["size"] 34 | }, 35 | "transition_to": "get_time" 36 | } 37 | } 38 | ] 39 | }, 40 | "get_time": { 41 | "task_messages": [ 42 | { 43 | "role": "system", 44 | "content": "Ask what time they'd like to dine. Restaurant is open 5 PM to 10 PM. After they provide a time, confirm it's within operating hours before recording. Use 24-hour format for internal recording (e.g., 17:00 for 5 PM)." 45 | } 46 | ], 47 | "functions": [ 48 | { 49 | "type": "function", 50 | "function": { 51 | "name": "record_time", 52 | "handler": "__function__:record_time", 53 | "description": "Record the requested time", 54 | "parameters": { 55 | "type": "object", 56 | "properties": { 57 | "time": { 58 | "type": "string", 59 | "pattern": "^(17|18|19|20|21|22):([0-5][0-9])$", 60 | "description": "Reservation time in 24-hour format (17:00-22:00)" 61 | } 62 | }, 63 | "required": ["time"] 64 | }, 65 | "transition_to": "confirm" 66 | } 67 | } 68 | ] 69 | }, 70 | "confirm": { 71 | "task_messages": [ 72 | { 73 | "role": "system", 74 | "content": "Confirm the reservation details and end the conversation." 75 | } 76 | ], 77 | "functions": [ 78 | { 79 | "type": "function", 80 | "function": { 81 | "name": "end", 82 | "description": "End the conversation", 83 | "parameters": { 84 | "type": "object", 85 | "properties": {} 86 | }, 87 | "transition_to": "end" 88 | } 89 | } 90 | ] 91 | }, 92 | "end": { 93 | "task_messages": [ 94 | { 95 | "role": "system", 96 | "content": "Thank them and end the conversation." 97 | } 98 | ], 99 | "functions": [], 100 | "post_actions": [ 101 | { 102 | "type": "end_conversation" 103 | } 104 | ] 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /editor/examples/travel_planner.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_node": "start", 3 | "nodes": { 4 | "start": { 5 | "role_messages": [ 6 | { 7 | "role": "system", 8 | "content": "You are a travel planning assistant with Summit & Sand Getaways. You must ALWAYS use one of the available functions to progress the conversation. This is a phone conversation and your responses will be converted to audio. Avoid outputting special characters and emojis." 9 | } 10 | ], 11 | "task_messages": [ 12 | { 13 | "role": "system", 14 | "content": "For this step, ask if they're interested in planning a beach vacation or a mountain retreat, and wait for them to choose. Start with an enthusiastic greeting and be conversational; you're helping them plan their dream vacation." 15 | } 16 | ], 17 | "functions": [ 18 | { 19 | "type": "function", 20 | "function": { 21 | "name": "choose_beach", 22 | "description": "User wants to plan a beach vacation", 23 | "parameters": { 24 | "type": "object", 25 | "properties": {} 26 | }, 27 | "transition_to": "choose_beach" 28 | } 29 | }, 30 | { 31 | "type": "function", 32 | "function": { 33 | "name": "choose_mountain", 34 | "description": "User wants to plan a mountain retreat", 35 | "parameters": { 36 | "type": "object", 37 | "properties": {} 38 | }, 39 | "transition_to": "choose_mountain" 40 | } 41 | } 42 | ] 43 | }, 44 | "choose_beach": { 45 | "task_messages": [ 46 | { 47 | "role": "system", 48 | "content": "You are handling beach vacation planning. Use the available functions:\n - Use select_destination when the user chooses their preferred beach location\n - After destination is selected, dates will be collected automatically\n\nAvailable beach destinations are: 'Maui', 'Cancun', or 'Maldives'. After they choose, confirm their selection. Be enthusiastic and paint a picture of each destination." 49 | } 50 | ], 51 | "functions": [ 52 | { 53 | "type": "function", 54 | "function": { 55 | "name": "select_destination", 56 | "handler": "__function__:select_destination", 57 | "description": "Record the selected beach destination", 58 | "parameters": { 59 | "type": "object", 60 | "properties": { 61 | "destination": { 62 | "type": "string", 63 | "enum": ["Maui", "Cancun", "Maldives"], 64 | "description": "Selected beach destination" 65 | } 66 | }, 67 | "required": ["destination"] 68 | }, 69 | "transition_to": "get_dates" 70 | } 71 | } 72 | ] 73 | }, 74 | "choose_mountain": { 75 | "task_messages": [ 76 | { 77 | "role": "system", 78 | "content": "You are handling mountain retreat planning. Use the available functions:\n - Use select_destination when the user chooses their preferred mountain location\n - After destination is selected, dates will be collected automatically\n\nAvailable mountain destinations are: 'Swiss Alps', 'Rocky Mountains', or 'Himalayas'. After they choose, confirm their selection. Be enthusiastic and paint a picture of each destination." 79 | } 80 | ], 81 | "functions": [ 82 | { 83 | "type": "function", 84 | "function": { 85 | "name": "select_destination", 86 | "handler": "__function__:select_destination", 87 | "description": "Record the selected mountain destination", 88 | "parameters": { 89 | "type": "object", 90 | "properties": { 91 | "destination": { 92 | "type": "string", 93 | "enum": ["Swiss Alps", "Rocky Mountains", "Himalayas"], 94 | "description": "Selected mountain destination" 95 | } 96 | }, 97 | "required": ["destination"] 98 | }, 99 | "transition_to": "get_dates" 100 | } 101 | } 102 | ] 103 | }, 104 | "get_dates": { 105 | "task_messages": [ 106 | { 107 | "role": "system", 108 | "content": "Handle travel date selection. Use the available functions:\n - Use record_dates when the user specifies their travel dates (can be used multiple times if they change their mind)\n - After dates are recorded, activities will be collected automatically\n\nAsk for their preferred travel dates within the next 6 months. After recording dates, confirm the selection." 109 | } 110 | ], 111 | "functions": [ 112 | { 113 | "type": "function", 114 | "function": { 115 | "name": "record_dates", 116 | "handler": "__function__:record_dates", 117 | "description": "Record the selected travel dates", 118 | "parameters": { 119 | "type": "object", 120 | "properties": { 121 | "check_in": { 122 | "type": "string", 123 | "format": "date", 124 | "description": "Check-in date (YYYY-MM-DD)" 125 | }, 126 | "check_out": { 127 | "type": "string", 128 | "format": "date", 129 | "description": "Check-out date (YYYY-MM-DD)" 130 | } 131 | }, 132 | "required": ["check_in", "check_out"] 133 | }, 134 | "transition_to": "get_activities" 135 | } 136 | } 137 | ] 138 | }, 139 | "get_activities": { 140 | "task_messages": [ 141 | { 142 | "role": "system", 143 | "content": "Handle activity preferences. Use the available functions:\n - Use record_activities to save their activity preferences\n - After activities are recorded, verification will happen automatically\n\nFor beach destinations, suggest: snorkeling, surfing, sunset cruise\nFor mountain destinations, suggest: hiking, skiing, mountain biking\n\nAfter they choose, confirm their selections." 144 | } 145 | ], 146 | "functions": [ 147 | { 148 | "type": "function", 149 | "function": { 150 | "name": "record_activities", 151 | "handler": "__function__:record_activities", 152 | "description": "Record selected activities", 153 | "parameters": { 154 | "type": "object", 155 | "properties": { 156 | "activities": { 157 | "type": "array", 158 | "items": { 159 | "type": "string" 160 | }, 161 | "minItems": 1, 162 | "maxItems": 3, 163 | "description": "Selected activities" 164 | } 165 | }, 166 | "required": ["activities"] 167 | }, 168 | "transition_to": "verify_itinerary" 169 | } 170 | } 171 | ] 172 | }, 173 | "verify_itinerary": { 174 | "task_messages": [ 175 | { 176 | "role": "system", 177 | "content": "Review the complete itinerary with the user. Summarize their destination, dates, and chosen activities. Use revise_plan to make changes or confirm_booking if they're happy. Be thorough in reviewing all details and ask for their confirmation." 178 | } 179 | ], 180 | "functions": [ 181 | { 182 | "type": "function", 183 | "function": { 184 | "name": "revise_plan", 185 | "description": "Return to date selection to revise the plan", 186 | "parameters": { 187 | "type": "object", 188 | "properties": {} 189 | }, 190 | "transition_to": "get_dates" 191 | } 192 | }, 193 | { 194 | "type": "function", 195 | "function": { 196 | "name": "confirm_booking", 197 | "description": "Confirm the booking and proceed to end", 198 | "parameters": { 199 | "type": "object", 200 | "properties": {} 201 | }, 202 | "transition_to": "confirm_booking" 203 | } 204 | } 205 | ] 206 | }, 207 | "confirm_booking": { 208 | "task_messages": [ 209 | { 210 | "role": "system", 211 | "content": "The booking is confirmed. Share some relevant tips about their chosen destination, thank them warmly, and use end to complete the conversation." 212 | } 213 | ], 214 | "functions": [ 215 | { 216 | "type": "function", 217 | "function": { 218 | "name": "end", 219 | "description": "End the conversation", 220 | "parameters": { 221 | "type": "object", 222 | "properties": {} 223 | }, 224 | "transition_to": "end" 225 | } 226 | } 227 | ], 228 | "pre_actions": [ 229 | { 230 | "type": "tts_say", 231 | "text": "Fantastic! Your dream vacation is confirmed!" 232 | } 233 | ] 234 | }, 235 | "end": { 236 | "task_messages": [ 237 | { 238 | "role": "system", 239 | "content": "Wish them a wonderful trip and end the conversation." 240 | } 241 | ], 242 | "functions": [], 243 | "post_actions": [ 244 | { 245 | "type": "end_conversation" 246 | } 247 | ] 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /editor/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/pipecat-flows/12bb9f5b356e2905ae4bdaf5b26cd2505cd3e2ff/editor/favicon.png -------------------------------------------------------------------------------- /editor/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /editor/index.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | Pipecat Flow Editor 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 |

40 | Node Editor 41 |

42 | 43 | 44 |
45 | Select a node to edit its contents 46 |
47 | 48 | 49 | 102 |
103 |
104 |
105 | 106 | 107 |
108 | 109 | 110 | 111 |
112 |
113 | 120 | 127 |
128 |
129 | 130 |
131 |
132 | 133 | 134 | 135 | 136 | 137 | 138 | 146 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /editor/js/editor/canvas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LGraphCanvas } from "litegraph.js"; 8 | 9 | /** 10 | * Sets up the canvas and its event handlers 11 | * @param {LGraph} graph - The LiteGraph instance 12 | * @returns {LGraphCanvas} The configured canvas instance 13 | */ 14 | export function setupCanvas(graph) { 15 | const canvas = new LGraphCanvas("#graph-canvas", graph); 16 | 17 | document.getElementById("zoom-in").onclick = () => { 18 | if (canvas.ds.scale < 2) { 19 | // Limit max zoom 20 | canvas.ds.scale *= 1.2; 21 | canvas.setDirty(true, true); 22 | } 23 | }; 24 | 25 | document.getElementById("zoom-out").onclick = () => { 26 | if (canvas.ds.scale > 0.2) { 27 | // Limit min zoom 28 | canvas.ds.scale *= 0.8; 29 | canvas.setDirty(true, true); 30 | } 31 | }; 32 | 33 | /** 34 | * Resizes the canvas to fit its container 35 | */ 36 | function resizeCanvas() { 37 | const canvasElement = document.getElementById("graph-canvas"); 38 | const container = document.getElementById("graph-container"); 39 | canvasElement.width = container.offsetWidth; 40 | canvasElement.height = container.offsetHeight; 41 | } 42 | 43 | window.addEventListener("resize", resizeCanvas); 44 | resizeCanvas(); 45 | 46 | return canvas; 47 | } 48 | -------------------------------------------------------------------------------- /editor/js/editor/editorState.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | /** 8 | * Singleton class to manage global editor state 9 | */ 10 | class EditorState { 11 | /** @type {EditorState|null} */ 12 | static instance = null; 13 | 14 | /** 15 | * Creates or returns the EditorState singleton 16 | */ 17 | constructor() { 18 | if (EditorState.instance) { 19 | return EditorState.instance; 20 | } 21 | /** @type {SidePanel|null} */ 22 | this.sidePanel = null; 23 | EditorState.instance = this; 24 | } 25 | 26 | /** 27 | * Sets the side panel instance 28 | * @param {SidePanel} sidePanel 29 | */ 30 | setSidePanel(sidePanel) { 31 | this.sidePanel = sidePanel; 32 | } 33 | 34 | /** 35 | * Updates the side panel with node data 36 | * @param {PipecatBaseNode|null} node 37 | */ 38 | updateSidePanel(node) { 39 | if (this.sidePanel) { 40 | this.sidePanel.updatePanel(node); 41 | } 42 | } 43 | } 44 | 45 | export const editorState = new EditorState(); 46 | -------------------------------------------------------------------------------- /editor/js/editor/toolbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { generateFlowConfig } from "../utils/export.js"; 8 | import { createFlowFromConfig } from "../utils/import.js"; 9 | import { validateFlow } from "../utils/validation.js"; 10 | import { editorState } from "../editor/editorState.js"; 11 | 12 | /** 13 | * Manages the toolbar UI and actions 14 | */ 15 | export class Toolbar { 16 | /** 17 | * Creates a new Toolbar instance 18 | * @param {LGraph} graph - The LiteGraph instance 19 | */ 20 | constructor(graph) { 21 | this.graph = graph; 22 | this.setupButtons(); 23 | } 24 | 25 | /** 26 | * Sets up toolbar button event listeners 27 | */ 28 | setupButtons() { 29 | // Get modal elements 30 | const newFlowModal = document.getElementById("new-flow-modal"); 31 | const cancelNewFlow = document.getElementById("cancel-new-flow"); 32 | const confirmNewFlow = document.getElementById("confirm-new-flow"); 33 | 34 | // New Flow button now opens the modal 35 | document.getElementById("new-flow").onclick = () => { 36 | newFlowModal.showModal(); 37 | }; 38 | 39 | // Cancel button closes the modal 40 | cancelNewFlow.onclick = () => { 41 | newFlowModal.close(); 42 | }; 43 | 44 | // Confirm button clears the graph and closes the modal 45 | confirmNewFlow.onclick = () => { 46 | this.handleNew(); 47 | newFlowModal.close(); 48 | }; 49 | 50 | document.getElementById("import-flow").onclick = () => this.handleImport(); 51 | document.getElementById("export-flow").onclick = () => this.handleExport(); 52 | } 53 | 54 | /** 55 | * Handles creating a new flow 56 | */ 57 | handleNew() { 58 | // Clear the graph 59 | this.graph.clear(); 60 | 61 | // Reset sidebar state 62 | editorState.updateSidePanel(null); 63 | } 64 | 65 | /** 66 | * Handles importing a flow configuration 67 | */ 68 | handleImport() { 69 | const input = document.createElement("input"); 70 | input.type = "file"; 71 | input.accept = ".json"; 72 | input.onchange = (e) => { 73 | const file = e.target.files[0]; 74 | const reader = new FileReader(); 75 | reader.onload = (event) => { 76 | try { 77 | // Clean the input string 78 | const cleanInput = event.target.result 79 | .replace(/[\u0000-\u001F\u007F-\u009F]/g, "") 80 | .replace(/\r\n/g, "\n") 81 | .replace(/\r/g, "\n"); 82 | 83 | console.debug("Cleaned input:", cleanInput); 84 | 85 | const flowConfig = JSON.parse(cleanInput); 86 | console.debug("Parsed config:", flowConfig); 87 | 88 | // Validate imported flow 89 | const validation = validateFlow(flowConfig); 90 | if (!validation.valid) { 91 | console.error("Flow validation errors:", validation.errors); 92 | if ( 93 | !confirm("Imported flow has validation errors. Import anyway?") 94 | ) { 95 | return; 96 | } 97 | } 98 | 99 | createFlowFromConfig(this.graph, flowConfig); 100 | console.log("Successfully imported flow configuration"); 101 | } catch (error) { 102 | console.error("Error importing flow:", error); 103 | console.error("Error details:", { 104 | message: error.message, 105 | position: error.position, 106 | stack: error.stack, 107 | }); 108 | alert("Error importing flow: " + error.message); 109 | } 110 | }; 111 | reader.readAsText(file); 112 | }; 113 | input.click(); 114 | } 115 | 116 | /** 117 | * Handles exporting the current flow 118 | */ 119 | handleExport() { 120 | try { 121 | const flowConfig = generateFlowConfig(this.graph); 122 | 123 | // Validate before export 124 | const validation = validateFlow(flowConfig); 125 | if (!validation.valid) { 126 | console.error("Flow validation errors:", validation.errors); 127 | if (!confirm("Flow has validation errors. Export anyway?")) { 128 | return; 129 | } 130 | } 131 | 132 | console.log("Generated Flow Configuration:"); 133 | console.log(JSON.stringify(flowConfig, null, 2)); 134 | 135 | // Generate timestamp 136 | const timestamp = new Date() 137 | .toISOString() 138 | .replace(/[:.]/g, "-") 139 | .replace("T", "_") 140 | .slice(0, -5); 141 | 142 | // Create a clean JSON string 143 | const jsonString = JSON.stringify(flowConfig, null, 2); 144 | 145 | const blob = new Blob([jsonString], { type: "application/json" }); 146 | const url = URL.createObjectURL(blob); 147 | const a = document.createElement("a"); 148 | a.href = url; 149 | a.download = `flow_config_${timestamp}.json`; 150 | document.body.appendChild(a); 151 | a.click(); 152 | document.body.removeChild(a); 153 | URL.revokeObjectURL(url); 154 | } catch (error) { 155 | console.error("Error generating flow configuration:", error); 156 | alert("Error generating flow configuration: " + error.message); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /editor/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LGraph, LiteGraph } from "litegraph.js"; 8 | import 'litegraph.js/css/litegraph.css'; 9 | 10 | import { registerNodes } from "./nodes/index.js"; 11 | import { SidePanel } from "./editor/sidePanel.js"; 12 | import { Toolbar } from "./editor/toolbar.js"; 13 | import { setupCanvas } from "./editor/canvas.js"; 14 | import { editorState } from "./editor/editorState.js"; 15 | 16 | // Clear all default node types 17 | LiteGraph.clearRegisteredTypes(); 18 | 19 | /** 20 | * Initializes the flow editor 21 | */ 22 | document.addEventListener("DOMContentLoaded", function () { 23 | // Initialize graph 24 | const graph = new LGraph(); 25 | 26 | // Register node types 27 | registerNodes(); 28 | 29 | // Setup UI components 30 | const canvas = setupCanvas(graph); 31 | const sidePanel = new SidePanel(graph); 32 | const toolbar = new Toolbar(graph); 33 | 34 | // Register side panel with editor state 35 | editorState.setSidePanel(sidePanel); 36 | 37 | // Add graph change listener 38 | graph.onAfterChange = () => { 39 | graph._nodes.forEach((node) => { 40 | if (node.onAfterGraphChange) { 41 | node.onAfterGraphChange(); 42 | } 43 | }); 44 | }; 45 | 46 | // Handle node selection 47 | graph.onNodeSelected = (node) => { 48 | editorState.updateSidePanel(node); 49 | }; 50 | 51 | graph.onNodeDeselected = () => { 52 | editorState.updateSidePanel(null); 53 | }; 54 | 55 | // Start the graph 56 | graph.start(); 57 | }); 58 | -------------------------------------------------------------------------------- /editor/js/nodes/baseNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LGraphNode } from "litegraph.js"; 8 | 9 | import { editorState } from "../editor/editorState.js"; 10 | import { formatActions } from "../utils/helpers.js"; 11 | 12 | /** 13 | * @typedef {Object} Message 14 | * @property {string} role - Message role (e.g., 'system', 'user', 'assistant') 15 | * @property {string} content - Message content 16 | */ 17 | 18 | /** 19 | * @typedef {Object} Action 20 | * @property {string} type - Action type (e.g., 'tts_say', 'end_conversation') 21 | * @property {string} [text] - Text content for text-based actions 22 | */ 23 | 24 | /** 25 | * @typedef {Object} NodeProperties 26 | * @property {Array} [role_messages] - Messages defining bot's role/personality 27 | * @property {Array} [task_messages] - Messages defining the node's task 28 | * @property {Array} [pre_actions] - Actions to execute before node processing 29 | * @property {Array} [post_actions] - Actions to execute after node processing 30 | */ 31 | 32 | /** 33 | * Base class for all Pipecat nodes 34 | * @extends LGraphNode 35 | */ 36 | export class PipecatBaseNode extends LGraphNode { 37 | /** 38 | * Creates a new PipecatBaseNode 39 | * @param {string} title - The display title of the node 40 | * @param {string} color - The color of the node 41 | * @param {string} [defaultContent='Enter message...'] - Default message content 42 | */ 43 | constructor(title, color, defaultContent) { 44 | super(); 45 | this.title = title; 46 | this.color = color; 47 | this.size = [400, 200]; 48 | 49 | /** @type {NodeProperties} */ 50 | this.properties = { 51 | task_messages: [ 52 | { 53 | role: "system", 54 | content: defaultContent || "Enter task message...", 55 | }, 56 | ], 57 | pre_actions: [], 58 | post_actions: [], 59 | }; 60 | 61 | // Force minimum width 62 | this.computeSize = function () { 63 | return [400, this.size[1]]; 64 | }; 65 | } 66 | 67 | /** 68 | * Forces a minimum width for the node 69 | * @returns {Array} The minimum dimensions [width, height] 70 | */ 71 | computeSize() { 72 | return [400, this.size[1]]; 73 | } 74 | 75 | /** 76 | * Draws the node's content 77 | * @param {CanvasRenderingContext2D} ctx - The canvas rendering context 78 | */ 79 | onDrawForeground(ctx) { 80 | const padding = 15; 81 | const textColor = "#ddd"; 82 | const labelColor = "#aaa"; 83 | let currentY = 40; 84 | 85 | /** 86 | * Draws wrapped text with a label 87 | * @param {string} text - The text to draw 88 | * @param {number} startY - Starting Y position 89 | * @param {string} label - Label for the text section 90 | * @returns {number} The Y position after drawing 91 | */ 92 | const drawWrappedText = (text, startY, label) => { 93 | ctx.fillStyle = labelColor; 94 | ctx.font = "12px Arial"; 95 | ctx.fillText(label, padding, startY + 5); 96 | 97 | ctx.fillStyle = textColor; 98 | ctx.font = "12px monospace"; 99 | 100 | const words = text.split(" "); 101 | let line = ""; 102 | let y = startY + 25; 103 | const maxWidth = this.size[0] - padding * 3; 104 | 105 | words.forEach((word) => { 106 | const testLine = line + word + " "; 107 | const metrics = ctx.measureText(testLine); 108 | if (metrics.width > maxWidth) { 109 | ctx.fillText(line, padding * 1.5, y); 110 | line = word + " "; 111 | y += 20; 112 | } else { 113 | line = testLine; 114 | } 115 | }); 116 | ctx.fillText(line, padding * 1.5, y); 117 | 118 | return y + 25; 119 | }; 120 | 121 | // Draw role messages if present 122 | if (this.properties.role_messages?.length > 0) { 123 | this.properties.role_messages.forEach((message) => { 124 | currentY = drawWrappedText(message.content, currentY, "Role Message"); 125 | currentY += 10; 126 | }); 127 | } 128 | 129 | // Draw task messages 130 | if (this.properties.task_messages) { 131 | this.properties.task_messages.forEach((message) => { 132 | currentY = drawWrappedText(message.content, currentY, "Task Message"); 133 | currentY += 10; 134 | }); 135 | } 136 | 137 | // Draw pre-actions 138 | if (this.properties.pre_actions?.length > 0) { 139 | currentY = drawWrappedText( 140 | formatActions(this.properties.pre_actions), 141 | currentY + 15, 142 | "Pre-actions", 143 | ); 144 | } 145 | 146 | // Draw post-actions 147 | if (this.properties.post_actions?.length > 0) { 148 | currentY = drawWrappedText( 149 | formatActions(this.properties.post_actions), 150 | currentY + 15, 151 | "Post-actions", 152 | ); 153 | } 154 | 155 | const desiredHeight = currentY + padding * 2; 156 | if (Math.abs(this.size[1] - desiredHeight) > 10) { 157 | this.size[1] = desiredHeight; 158 | this.setDirtyCanvas(true, true); 159 | } 160 | } 161 | 162 | /** 163 | * Handles node selection 164 | * Updates the side panel with this node's properties 165 | */ 166 | onSelected() { 167 | editorState.updateSidePanel(this); 168 | } 169 | 170 | /** 171 | * Serializes the node for saving 172 | * @returns {Object} Serialized node data 173 | */ 174 | serialize() { 175 | const data = super.serialize(); 176 | data.properties = { 177 | role_messages: this.properties.role_messages, 178 | task_messages: this.properties.task_messages, 179 | pre_actions: this.properties.pre_actions, 180 | post_actions: this.properties.post_actions, 181 | }; 182 | return data; 183 | } 184 | 185 | /** 186 | * Deserializes saved node data 187 | * @param {Object} data - The saved node data 188 | */ 189 | configure(data) { 190 | super.configure(data); 191 | if (data.properties) { 192 | this.properties = { 193 | role_messages: data.properties.role_messages || [], 194 | task_messages: data.properties.task_messages || [ 195 | { role: "system", content: "Enter task message..." }, 196 | ], 197 | pre_actions: data.properties.pre_actions || [], 198 | post_actions: data.properties.post_actions || [], 199 | }; 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /editor/js/nodes/endNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { PipecatBaseNode } from "./baseNode.js"; 8 | import { LiteGraph } from "litegraph.js"; 9 | 10 | /** 11 | * Represents the end node of a flow 12 | * @extends PipecatBaseNode 13 | */ 14 | export class PipecatEndNode extends PipecatBaseNode { 15 | /** 16 | * Creates a new end node 17 | */ 18 | constructor() { 19 | super("End Node", "#e74c3c"); 20 | this.addInput("In", "flow", { 21 | multipleConnections: true, 22 | linkType: LiteGraph.MULTIPLE_LINK, 23 | }); 24 | 25 | // Initialize with only task messages for the end node 26 | this.properties = { 27 | task_messages: [ 28 | { 29 | role: "system", 30 | content: "Enter final task message...", 31 | }, 32 | ], 33 | pre_actions: [], 34 | post_actions: [], 35 | }; 36 | } 37 | 38 | /** 39 | * Handles input connection 40 | * @param {number} targetSlot - Input slot index 41 | * @param {string} type - Type of connection 42 | * @param {Object} output - Output connection information 43 | * @param {LGraphNode} input_node - Node being connected 44 | * @param {number} input_slot - Slot being connected to 45 | * @returns {boolean} Whether the connection is allowed 46 | */ 47 | onConnectInput(targetSlot, type, output, input_node, input_slot) { 48 | if (this.inputs[0].link == null) { 49 | this.inputs[0].link = []; 50 | } 51 | return true; 52 | } 53 | 54 | /** 55 | * Handles node connection 56 | * @param {number} slot - Input slot index 57 | * @param {LGraphNode} targetNode - Node to connect to 58 | * @param {number} targetSlot - Target node's slot index 59 | * @returns {boolean} Whether the connection was successful 60 | */ 61 | connect(slot, targetNode, targetSlot) { 62 | if (this.inputs[slot].link == null) { 63 | this.inputs[slot].link = []; 64 | } 65 | return super.connect(slot, targetNode, targetSlot); 66 | } 67 | 68 | /** 69 | * Handles connection changes 70 | * @param {string} type - Type of connection change 71 | * @param {number} slot - Slot index 72 | * @param {boolean} connected - Whether connection was made or removed 73 | * @param {Object} link_info - Information about the connection 74 | */ 75 | onConnectionsChange(type, slot, connected, link_info) { 76 | if (type === LiteGraph.INPUT && this.inputs[slot].link == null) { 77 | this.inputs[slot].link = []; 78 | } 79 | super.onConnectionsChange && 80 | super.onConnectionsChange(type, slot, connected, link_info); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /editor/js/nodes/flowNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { PipecatBaseNode } from "./baseNode.js"; 8 | 9 | /** 10 | * Represents an intermediate flow node 11 | * @extends PipecatBaseNode 12 | */ 13 | export class PipecatFlowNode extends PipecatBaseNode { 14 | /** 15 | * Creates a new flow node 16 | */ 17 | constructor() { 18 | super("Flow Node", "#3498db"); 19 | this.addInput("In", "flow"); 20 | this.addOutput("Out", "flow"); 21 | 22 | // Initialize with only task messages since role is inherited 23 | this.properties = { 24 | task_messages: [ 25 | { 26 | role: "system", 27 | content: "Enter task message...", 28 | }, 29 | ], 30 | pre_actions: [], 31 | post_actions: [], 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /editor/js/nodes/functionNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LGraphNode, LiteGraph } from "litegraph.js"; 8 | 9 | import { editorState } from "../editor/editorState.js"; 10 | 11 | /** 12 | * @typedef {Object} FunctionParameter 13 | * @property {string} type - Parameter type (e.g., 'string', 'integer') 14 | * @property {string} [description] - Parameter description 15 | * @property {Array} [enum] - Possible values for enum types 16 | * @property {number} [minimum] - Minimum value for numeric types 17 | * @property {number} [maximum] - Maximum value for numeric types 18 | */ 19 | 20 | /** 21 | * @typedef {Object} FunctionDefinition 22 | * @property {string} name - Function name 23 | * @property {string} description - Function description 24 | * @property {Object} parameters - Function parameters 25 | * @property {Object.} parameters.properties - Parameter definitions 26 | * @property {Array} [parameters.required] - Required parameter names 27 | */ 28 | 29 | /** 30 | * @typedef {Object} FunctionNodeProperties 31 | * @property {string} type - Always 'function' 32 | * @property {FunctionDefinition} function - The function definition 33 | * @property {boolean} isNodeFunction - Whether this is a node function 34 | */ 35 | 36 | /** 37 | * Represents a function node in the flow 38 | * @extends LGraphNode 39 | */ 40 | export class PipecatFunctionNode extends LGraphNode { 41 | /** 42 | * Creates a new function node 43 | */ 44 | constructor() { 45 | super(); 46 | this.title = "Function"; 47 | this.addInput("From", "flow"); 48 | this.addOutput("To", "flow", { 49 | linkType: LiteGraph.MULTIPLE_LINK, 50 | }); 51 | 52 | /** @type {FunctionNodeProperties} */ 53 | this.properties = { 54 | type: "function", 55 | function: { 56 | name: "function_name", 57 | description: "Function description", 58 | parameters: { 59 | type: "object", 60 | properties: {}, 61 | }, 62 | }, 63 | isNodeFunction: false, 64 | }; 65 | 66 | this.color = "#9b59b6"; 67 | this.size = [400, 150]; 68 | } 69 | 70 | /** 71 | * Forces a minimum width for the node 72 | * @returns {Array} The minimum dimensions [width, height] 73 | */ 74 | computeSize() { 75 | return [400, this.size[1]]; 76 | } 77 | 78 | /** 79 | * Updates the node status of the function based on its connections 80 | */ 81 | updateNodeFunctionStatus() { 82 | const hasOutputConnection = 83 | this.outputs[0].links && this.outputs[0].links.length > 0; 84 | const newStatus = !hasOutputConnection; 85 | 86 | if (this.properties.isNodeFunction !== newStatus) { 87 | this.properties.isNodeFunction = newStatus; 88 | this.setDirtyCanvas(true, true); 89 | } 90 | } 91 | 92 | /** 93 | * Handles node connection 94 | * @param {number} slot - Output slot index 95 | * @param {LGraphNode} targetNode - Node to connect to 96 | * @param {number} targetSlot - Target node's input slot index 97 | * @returns {boolean} Whether the connection was successful 98 | */ 99 | connect(slot, targetNode, targetSlot) { 100 | if (slot === 1 && this.outputs[slot].links == null) { 101 | this.outputs[slot].links = []; 102 | } 103 | const result = super.connect(slot, targetNode, targetSlot); 104 | this.updateNodeFunctionStatus(); 105 | return result; 106 | } 107 | 108 | /** 109 | * Handles output disconnection 110 | * @param {number} slot - Output slot index 111 | */ 112 | disconnectOutput(slot) { 113 | if (this.outputs[slot].links == null) { 114 | this.outputs[slot].links = []; 115 | } 116 | super.disconnectOutput(slot); 117 | this.updateNodeFunctionStatus(); 118 | } 119 | 120 | /** 121 | * Handles input disconnection 122 | * @param {number} slot - Input slot index 123 | */ 124 | disconnectInput(slot) { 125 | super.disconnectInput(slot); 126 | this.updateNodeFunctionStatus(); 127 | } 128 | 129 | /** 130 | * Handles connection changes 131 | * @param {string} type - Type of connection change 132 | * @param {number} slot - Slot index 133 | * @param {boolean} connected - Whether connection was made or removed 134 | * @param {Object} link_info - Information about the connection 135 | */ 136 | onConnectionsChange(type, slot, connected, link_info) { 137 | if (type === LiteGraph.OUTPUT && this.outputs[slot].links == null) { 138 | this.outputs[slot].links = []; 139 | } 140 | super.onConnectionsChange && 141 | super.onConnectionsChange(type, slot, connected, link_info); 142 | this.updateNodeFunctionStatus(); 143 | } 144 | 145 | /** 146 | * Draws the node's content 147 | * @param {CanvasRenderingContext2D} ctx - The canvas rendering context 148 | */ 149 | onDrawForeground(ctx) { 150 | this.updateNodeFunctionStatus(); 151 | 152 | const padding = 15; 153 | const textColor = "#ddd"; 154 | const labelColor = "#aaa"; 155 | 156 | this.color = this.properties.isNodeFunction ? "#e67e22" : "#9b59b6"; 157 | 158 | // Draw node/edge indicator 159 | ctx.fillStyle = labelColor; 160 | ctx.font = "11px Arial"; 161 | const typeText = this.properties.isNodeFunction 162 | ? "[Node Function]" 163 | : "[Edge Function]"; 164 | ctx.fillText(typeText, padding, 35); 165 | 166 | // Draw function name 167 | ctx.fillStyle = textColor; 168 | ctx.font = "14px monospace"; 169 | ctx.fillText(this.properties.function.name, padding, 60); 170 | 171 | // Draw description 172 | ctx.fillStyle = labelColor; 173 | ctx.font = "12px Arial"; 174 | const description = this.properties.function.description; 175 | 176 | // Word wrap description 177 | const words = description.split(" "); 178 | let line = ""; 179 | let y = 80; 180 | const maxWidth = this.size[0] - padding * 3; 181 | 182 | words.forEach((word) => { 183 | const testLine = line + word + " "; 184 | const metrics = ctx.measureText(testLine); 185 | if (metrics.width > maxWidth) { 186 | ctx.fillText(line, padding, y); 187 | line = word + " "; 188 | y += 20; 189 | } else { 190 | line = testLine; 191 | } 192 | }); 193 | ctx.fillText(line, padding, y); 194 | 195 | // Draw parameters indicator 196 | const hasParameters = 197 | Object.keys(this.properties.function.parameters.properties).length > 0; 198 | if (hasParameters) { 199 | ctx.fillStyle = "#666"; 200 | ctx.font = "11px Arial"; 201 | ctx.fillText("Has Parameters ⚙️", padding, y + 25); 202 | } 203 | 204 | // Optional: Draw transition indicator 205 | if (this.properties.function.transition_to) { 206 | ctx.fillStyle = "#666"; 207 | ctx.font = "11px Arial"; 208 | ctx.fillText( 209 | `→ ${this.properties.function.transition_to}`, 210 | padding, 211 | y + (hasParameters ? 45 : 25), 212 | ); 213 | y += 20; // Increase y for the new line 214 | } 215 | 216 | // Adjust node height 217 | const desiredHeight = y + (hasParameters ? 45 : 25); 218 | if (Math.abs(this.size[1] - desiredHeight) > 10) { 219 | this.size[1] = desiredHeight; 220 | this.setDirtyCanvas(true, true); 221 | } 222 | } 223 | 224 | /** 225 | * Handles node selection 226 | */ 227 | onSelected() { 228 | editorState.updateSidePanel(this); 229 | } 230 | 231 | /** 232 | * Handles graph changes 233 | */ 234 | onAfterGraphChange() { 235 | this.updateNodeFunctionStatus(); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /editor/js/nodes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LiteGraph } from "litegraph.js"; 8 | 9 | import { PipecatStartNode } from "./startNode.js"; 10 | import { PipecatFlowNode } from "./flowNode.js"; 11 | import { PipecatEndNode } from "./endNode.js"; 12 | import { PipecatFunctionNode } from "./functionNode.js"; 13 | import { PipecatMergeNode } from "./mergeNode.js"; 14 | 15 | /** 16 | * Registers all node types with LiteGraph 17 | */ 18 | export function registerNodes() { 19 | LiteGraph.registerNodeType("nodes/Start", PipecatStartNode); 20 | LiteGraph.registerNodeType("nodes/Flow", PipecatFlowNode); 21 | LiteGraph.registerNodeType("nodes/End", PipecatEndNode); 22 | LiteGraph.registerNodeType("nodes/Function", PipecatFunctionNode); 23 | LiteGraph.registerNodeType("flow/Merge", PipecatMergeNode); 24 | } 25 | 26 | export { 27 | PipecatStartNode, 28 | PipecatFlowNode, 29 | PipecatEndNode, 30 | PipecatFunctionNode, 31 | PipecatMergeNode, 32 | }; 33 | -------------------------------------------------------------------------------- /editor/js/nodes/mergeNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { LGraphNode } from "litegraph.js"; 8 | 9 | /** 10 | * @typedef {Object} MergeNodeSize 11 | * @property {number} width - Node width 12 | * @property {number} height - Node height 13 | */ 14 | 15 | /** 16 | * Represents a merge node that combines multiple inputs into one output 17 | * @extends LGraphNode 18 | */ 19 | export class PipecatMergeNode extends LGraphNode { 20 | /** 21 | * Creates a new merge node 22 | */ 23 | constructor() { 24 | super(); 25 | this.title = "Merge"; 26 | 27 | // Start with two input slots 28 | this.addInput("In 1", "flow"); 29 | this.addInput("In 2", "flow"); 30 | 31 | this.addOutput("Out", "flow"); 32 | this.color = "#95a5a6"; 33 | /** @type {MergeNodeSize} */ 34 | this.size = [140, 60]; 35 | 36 | // Add buttons for managing inputs 37 | this.addWidget("button", "+ Add input", null, () => { 38 | this.addInput(`In ${this.inputs.length + 1}`, "flow"); 39 | this.size[1] += 20; // Increase height to accommodate new input 40 | }); 41 | 42 | this.addWidget("button", "- Remove input", null, () => { 43 | if (this.inputs.length > 2) { 44 | // Maintain minimum of 2 inputs 45 | // Disconnect any existing connection to the last input 46 | if (this.inputs[this.inputs.length - 1].link != null) { 47 | this.disconnectInput(this.inputs.length - 1); 48 | } 49 | // Remove the last input 50 | this.removeInput(this.inputs.length - 1); 51 | this.size[1] -= 20; // Decrease height 52 | } 53 | }); 54 | } 55 | 56 | /** 57 | * Draws the node's content 58 | * @param {CanvasRenderingContext2D} ctx - The canvas rendering context 59 | */ 60 | onDrawForeground(ctx) { 61 | const activeConnections = this.inputs.filter( 62 | (input) => input.link != null, 63 | ).length; 64 | if (activeConnections > 0) { 65 | ctx.fillStyle = "#ddd"; 66 | ctx.font = "11px Arial"; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /editor/js/nodes/startNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { PipecatBaseNode } from "./baseNode.js"; 8 | 9 | /** 10 | * Represents the starting node of a flow 11 | * @extends PipecatBaseNode 12 | */ 13 | export class PipecatStartNode extends PipecatBaseNode { 14 | /** 15 | * Creates a new start node 16 | * @param {string} [title="Start"] - Optional custom title for the node 17 | */ 18 | constructor(title = "Start") { 19 | super(title, "#2ecc71"); 20 | this.addOutput("Out", "flow"); 21 | 22 | // Initialize with both role and task messages for the start node 23 | this.properties = { 24 | role_messages: [ 25 | { 26 | role: "system", 27 | content: "Enter bot's personality/role...", 28 | }, 29 | ], 30 | task_messages: [ 31 | { 32 | role: "system", 33 | content: "Enter initial task...", 34 | }, 35 | ], 36 | pre_actions: [], 37 | post_actions: [], 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /editor/js/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} SidePanel 3 | */ 4 | 5 | /** 6 | * @typedef {Object} PipecatBaseNode 7 | */ 8 | 9 | /** 10 | * @typedef {Object} FlowConfig 11 | * @property {string} initial_node 12 | * @property {Object.} nodes 13 | */ 14 | 15 | /** 16 | * @typedef {Object} NodeConfig 17 | * @property {Array} [role_messages] - Optional messages defining bot's role/personality 18 | * @property {Array} task_messages - Required messages defining the node's task 19 | * @property {Array} functions 20 | * @property {Array} [pre_actions] 21 | * @property {Array} [post_actions] 22 | */ 23 | 24 | /** 25 | * @typedef {Object} Message 26 | * @property {string} role 27 | * @property {string} content 28 | */ 29 | 30 | /** 31 | * @typedef {Object} Action 32 | * @property {string} type 33 | * @property {string} [text] 34 | */ 35 | 36 | /** 37 | * @typedef {Object} Function 38 | * @property {string} type 39 | * @property {FunctionDefinition} function 40 | */ 41 | 42 | /** 43 | * @typedef {Object} FunctionDefinition 44 | * @property {string} name 45 | * @property {string} description 46 | * @property {Object} parameters 47 | * @property {string} [transition_to] 48 | */ 49 | -------------------------------------------------------------------------------- /editor/js/utils/export.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | /** 8 | * Generates a flow configuration from the graph 9 | * @param {LGraph} graphInstance - The LiteGraph instance 10 | * @returns {FlowConfig} The generated flow configuration 11 | * @throws {Error} If the graph is invalid 12 | */ 13 | export function generateFlowConfig(graphInstance) { 14 | if (!graphInstance) { 15 | throw new Error("No graph instance provided"); 16 | } 17 | 18 | if (!graphInstance._nodes || !Array.isArray(graphInstance._nodes)) { 19 | throw new Error("No nodes found in the graph"); 20 | } 21 | 22 | const nodes = graphInstance._nodes; 23 | let startNode = nodes.find( 24 | (node) => node.constructor.name === "PipecatStartNode", 25 | ); 26 | 27 | if (!startNode) { 28 | throw new Error("No start node found in the flow"); 29 | } 30 | 31 | /** 32 | * Adds handler token if needed 33 | * @param {Object} functionConfig - Function configuration to process 34 | * @param {Object} sourceNode - Node containing the function 35 | */ 36 | function processHandler(functionConfig, sourceNode) { 37 | if (sourceNode.properties.function.handler) { 38 | const handlerName = sourceNode.properties.function.handler; 39 | if (!handlerName.startsWith("__function__:")) { 40 | functionConfig.function.handler = `__function__:${handlerName}`; 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Finds all functions connected to a node 47 | * @param {LGraphNode} node - The node to find functions for 48 | * @returns {Array} Array of function configurations 49 | */ 50 | function findConnectedFunctions(node) { 51 | const functions = []; 52 | 53 | if (node.outputs && node.outputs[0] && node.outputs[0].links) { 54 | node.outputs[0].links.forEach((linkId) => { 55 | const link = graphInstance.links[linkId]; 56 | if (!link) return; 57 | 58 | const targetNode = nodes.find((n) => n.id === link.target_id); 59 | if (!targetNode) return; 60 | 61 | if (targetNode.constructor.name === "PipecatFunctionNode") { 62 | // Create base function configuration 63 | const funcConfig = { 64 | type: "function", 65 | function: { ...targetNode.properties.function }, 66 | }; 67 | 68 | processHandler(funcConfig, targetNode); 69 | 70 | // Find where this function connects to (if anywhere) 71 | const functionTargets = targetNode.outputs[0].links || []; 72 | if (functionTargets.length > 0) { 73 | // Look through all connections to find the actual target node 74 | // (skipping merge nodes) 75 | for (const targetLinkId of functionTargets) { 76 | const targetLink = graphInstance.links[targetLinkId]; 77 | if (!targetLink) continue; 78 | 79 | const nextNode = nodes.find((n) => n.id === targetLink.target_id); 80 | if (!nextNode) continue; 81 | 82 | // If it connects to a merge node, follow through to final target 83 | if (nextNode.constructor.name === "PipecatMergeNode") { 84 | const mergeOutput = nextNode.outputs[0].links?.[0]; 85 | if (!mergeOutput) continue; 86 | 87 | const mergeLink = graphInstance.links[mergeOutput]; 88 | if (!mergeLink) continue; 89 | 90 | const finalNode = nodes.find( 91 | (n) => n.id === mergeLink.target_id, 92 | ); 93 | if (finalNode) { 94 | funcConfig.function.transition_to = finalNode.title; 95 | break; // Use first valid target found 96 | } 97 | } else { 98 | // Direct connection to target node 99 | funcConfig.function.transition_to = nextNode.title; 100 | break; // Use first valid target found 101 | } 102 | } 103 | } 104 | 105 | functions.push(funcConfig); 106 | } else if (targetNode.constructor.name === "PipecatMergeNode") { 107 | // Find all functions that connect to this merge node 108 | const connectedFunctions = nodes.filter( 109 | (n) => 110 | n.constructor.name === "PipecatFunctionNode" && 111 | n.outputs[0].links?.some((l) => { 112 | const funcLink = graphInstance.links[l]; 113 | return funcLink && funcLink.target_id === targetNode.id; 114 | }), 115 | ); 116 | 117 | // Find the final target of the merge node 118 | const mergeOutput = targetNode.outputs[0].links?.[0]; 119 | if (!mergeOutput) return; 120 | 121 | const mergeLink = graphInstance.links[mergeOutput]; 122 | if (!mergeLink) return; 123 | 124 | const finalNode = nodes.find((n) => n.id === mergeLink.target_id); 125 | if (!finalNode) return; 126 | 127 | // Add all functions with their transition to the final target 128 | connectedFunctions.forEach((funcNode) => { 129 | const funcConfig = { 130 | type: "function", 131 | function: { 132 | ...funcNode.properties.function, 133 | transition_to: finalNode.title, 134 | }, 135 | }; 136 | 137 | processHandler(funcConfig, funcNode); 138 | functions.push(funcConfig); 139 | }); 140 | } 141 | }); 142 | } 143 | 144 | return functions; 145 | } 146 | 147 | // Build the flow configuration using the start node's title as initial_node 148 | const flowConfig = { 149 | initial_node: startNode.title, 150 | nodes: {}, 151 | }; 152 | 153 | // Process all nodes 154 | nodes.forEach((node) => { 155 | if ( 156 | node.constructor.name === "PipecatFunctionNode" || 157 | node.constructor.name === "PipecatMergeNode" 158 | ) { 159 | return; 160 | } 161 | 162 | // Create node configuration with new message structure 163 | const nodeConfig = { 164 | task_messages: node.properties.task_messages, 165 | functions: findConnectedFunctions(node), 166 | }; 167 | 168 | // Add role_messages if present 169 | if (node.properties.role_messages?.length > 0) { 170 | nodeConfig.role_messages = node.properties.role_messages; 171 | } 172 | 173 | // Add actions if present 174 | if (node.properties.pre_actions?.length > 0) { 175 | nodeConfig.pre_actions = node.properties.pre_actions; 176 | } 177 | if (node.properties.post_actions?.length > 0) { 178 | nodeConfig.post_actions = node.properties.post_actions; 179 | } 180 | 181 | // Use node.title as the node ID 182 | flowConfig.nodes[node.title] = nodeConfig; 183 | }); 184 | 185 | return flowConfig; 186 | } 187 | -------------------------------------------------------------------------------- /editor/js/utils/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | /** 8 | * Formats actions for display 9 | * @param {Array} actions - Array of actions to format 10 | * @returns {string} Formatted string representation of actions 11 | */ 12 | export function formatActions(actions) { 13 | return actions 14 | .map((action) => { 15 | if (action.text) { 16 | return `${action.type}: "${action.text}"`; 17 | } 18 | const { type, ...rest } = action; 19 | return `${type}: ${JSON.stringify(rest)}`; 20 | }) 21 | .join("\n"); 22 | } 23 | -------------------------------------------------------------------------------- /editor/js/utils/import.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | import { 8 | PipecatStartNode, 9 | PipecatFlowNode, 10 | PipecatEndNode, 11 | PipecatFunctionNode, 12 | PipecatMergeNode, 13 | } from "../nodes/index.js"; 14 | import dagre from "dagre"; 15 | 16 | /** 17 | * Creates a graph from a flow configuration 18 | * @param {LGraph} graph - The LiteGraph instance 19 | * @param {FlowConfig} flowConfig - The flow configuration 20 | */ 21 | export function createFlowFromConfig(graph, flowConfig) { 22 | // Clear existing graph 23 | graph.clear(); 24 | 25 | const nodeSpacing = { 26 | horizontal: 400, 27 | vertical: 150, 28 | }; 29 | /** @type {Object.} */ 30 | const nodes = {}; 31 | 32 | // Create dagre graph 33 | const g = new dagre.graphlib.Graph(); 34 | g.setGraph({ 35 | rankdir: "LR", // Left to right layout 36 | nodesep: nodeSpacing.vertical, 37 | ranksep: nodeSpacing.horizontal, 38 | edgesep: 50, 39 | marginx: 100, 40 | marginy: 100, 41 | }); 42 | g.setDefaultEdgeLabel(() => ({})); 43 | 44 | // Create nodes based on configuration 45 | Object.entries(flowConfig.nodes).forEach(([nodeId, nodeConfig]) => { 46 | let node; 47 | 48 | if (nodeId === flowConfig.initial_node) { 49 | // Create start node with the initial_node name 50 | node = new PipecatStartNode(); 51 | node.title = nodeId; 52 | } else if (nodeId === "end") { 53 | node = new PipecatEndNode(); 54 | node.title = nodeId; 55 | } else { 56 | node = new PipecatFlowNode(); 57 | node.title = nodeId; 58 | } 59 | 60 | // Set node properties 61 | node.properties = { 62 | task_messages: nodeConfig.task_messages, 63 | pre_actions: nodeConfig.pre_actions || [], 64 | post_actions: nodeConfig.post_actions || [], 65 | }; 66 | 67 | if (nodeConfig.role_messages?.length > 0) { 68 | node.properties.role_messages = nodeConfig.role_messages; 69 | } 70 | 71 | graph.add(node); 72 | nodes[nodeId] = { node, config: nodeConfig }; 73 | 74 | // Add to dagre graph 75 | g.setNode(nodeId, { 76 | width: node.size[0], 77 | height: node.size[1], 78 | node: node, 79 | }); 80 | }); 81 | 82 | // Track function nodes and merge nodes for edge creation 83 | const functionNodes = new Map(); 84 | const mergeNodes = new Map(); 85 | 86 | // Create function nodes and analyze connections 87 | Object.entries(flowConfig.nodes).forEach(([sourceNodeId, nodeConfig]) => { 88 | if (nodeConfig.functions) { 89 | nodeConfig.functions.forEach((funcConfig) => { 90 | const functionNode = new PipecatFunctionNode(); 91 | functionNode.properties.function = { ...funcConfig.function }; 92 | 93 | graph.add(functionNode); 94 | 95 | // Add function node to dagre graph 96 | const funcNodeId = `func_${sourceNodeId}_${functionNode.properties.function.name}`; 97 | g.setNode(funcNodeId, { 98 | width: functionNode.size[0], 99 | height: functionNode.size[1], 100 | node: functionNode, 101 | }); 102 | 103 | // Connect source to function node 104 | g.setEdge(sourceNodeId, funcNodeId); 105 | 106 | // If has transition_to, connect to target node 107 | if (funcConfig.function.transition_to) { 108 | g.setEdge(funcNodeId, funcConfig.function.transition_to); 109 | } 110 | 111 | functionNodes.set(functionNode, { 112 | source: nodes[sourceNodeId].node, 113 | target: nodes[funcConfig.function.transition_to]?.node, 114 | targetName: funcConfig.function.transition_to, 115 | funcNodeId: funcNodeId, 116 | }); 117 | }); 118 | } 119 | }); 120 | 121 | // Group function nodes by target for merge nodes 122 | const targetToFunctions = new Map(); 123 | functionNodes.forEach((data, functionNode) => { 124 | if (!targetToFunctions.has(data.targetName)) { 125 | targetToFunctions.set(data.targetName, []); 126 | } 127 | targetToFunctions.get(data.targetName).push({ functionNode, ...data }); 128 | }); 129 | 130 | // Create merge nodes where needed and connect in dagre 131 | targetToFunctions.forEach((functions, targetName) => { 132 | if (functions.length > 1 && nodes[targetName]) { 133 | // Create merge node 134 | const mergeNode = new PipecatMergeNode(); 135 | while (mergeNode.inputs.length < functions.length) { 136 | mergeNode.addInput(`In ${mergeNode.inputs.length + 1}`, "flow"); 137 | mergeNode.size[1] += 20; 138 | } 139 | graph.add(mergeNode); 140 | 141 | // Add merge node to dagre 142 | const mergeNodeId = `merge_${targetName}`; 143 | g.setNode(mergeNodeId, { 144 | width: mergeNode.size[0], 145 | height: mergeNode.size[1], 146 | node: mergeNode, 147 | }); 148 | 149 | // Connect function nodes to merge node in dagre 150 | functions.forEach(({ funcNodeId }) => { 151 | g.setEdge(funcNodeId, mergeNodeId); 152 | }); 153 | 154 | // Connect merge node to target in dagre 155 | g.setEdge(mergeNodeId, targetName); 156 | 157 | // Store for later LiteGraph connection 158 | mergeNodes.set(mergeNode, { 159 | sources: functions.map((f) => f.functionNode), 160 | target: nodes[targetName].node, 161 | }); 162 | } else if (nodes[targetName]) { 163 | // Direct connection in dagre 164 | g.setEdge(functions[0].funcNodeId, targetName); 165 | } 166 | }); 167 | 168 | // Apply dagre layout 169 | dagre.layout(g); 170 | 171 | // Apply positions from dagre to nodes 172 | g.nodes().forEach((nodeId) => { 173 | const dagreNode = g.node(nodeId); 174 | if (dagreNode.node) { 175 | dagreNode.node.pos = [dagreNode.x, dagreNode.y]; 176 | } 177 | }); 178 | 179 | // Create LiteGraph connections 180 | functionNodes.forEach((connections, functionNode) => { 181 | connections.source.connect(0, functionNode, 0); 182 | }); 183 | 184 | mergeNodes.forEach((connections, mergeNode) => { 185 | connections.sources.forEach((source, index) => { 186 | source.connect(0, mergeNode, index); 187 | }); 188 | mergeNode.connect(0, connections.target, 0); 189 | }); 190 | 191 | // Connect function nodes to their targets when no merge node is involved 192 | functionNodes.forEach((connections, functionNode) => { 193 | if ( 194 | connections.target && 195 | !Array.from(mergeNodes.values()).some((mergeData) => 196 | mergeData.sources.includes(functionNode), 197 | ) 198 | ) { 199 | functionNode.connect(0, connections.target, 0); 200 | } 201 | }); 202 | 203 | graph.setDirtyCanvas(true, true); 204 | } 205 | -------------------------------------------------------------------------------- /editor/js/utils/validation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2024, Daily 3 | * 4 | * SPDX-License-Identifier: BSD 2-Clause License 5 | */ 6 | 7 | /** 8 | * @typedef {NodeProperties} 9 | * @typedef {FunctionDefinition} 10 | */ 11 | 12 | /** 13 | * @typedef {Object} FlowConfig 14 | * @property {string} initial_node - ID of the starting node 15 | * @property {Object.} nodes - Map of node IDs to node configurations 16 | */ 17 | 18 | /** 19 | * @typedef {Object} ValidationResult 20 | * @property {boolean} valid - Whether the flow is valid 21 | * @property {string[]} errors - Array of validation error messages 22 | */ 23 | 24 | /** 25 | * Validates flow configurations 26 | */ 27 | export class FlowValidator { 28 | /** 29 | * Creates a new flow validator 30 | * @param {FlowConfig} flowConfig - The flow configuration to validate 31 | */ 32 | constructor(flowConfig) { 33 | this.flow = flowConfig; 34 | this.errors = []; 35 | } 36 | 37 | /** 38 | * Performs all validation checks 39 | * @returns {string[]} Array of validation error messages 40 | */ 41 | validate() { 42 | this.errors = []; 43 | 44 | this._validateInitialNode(); 45 | this._validateNodeReferences(); 46 | this._validateNodeContents(); 47 | this._validateTransitions(); 48 | this._validateMessageStructure(); 49 | 50 | return this.errors; 51 | } 52 | 53 | /** 54 | * Validates the initial node configuration 55 | * @private 56 | */ 57 | _validateInitialNode() { 58 | if (!this.flow.initial_node) { 59 | this.errors.push("Initial node must be specified"); 60 | } else if (!this.flow.nodes[this.flow.initial_node]) { 61 | this.errors.push( 62 | `Initial node '${this.flow.initial_node}' not found in nodes`, 63 | ); 64 | } 65 | 66 | // Validate that initial node has role_messages if it's the start node 67 | const initialNode = this.flow.nodes[this.flow.initial_node]; 68 | if ( 69 | initialNode && 70 | (!initialNode.role_messages || initialNode.role_messages.length === 0) 71 | ) { 72 | this.errors.push("Initial node must define role_messages"); 73 | } 74 | } 75 | 76 | /** 77 | * Determines if a function is node function based on its parameters 78 | * @param {string} funcName - Name of the function to check 79 | * @returns {boolean} Whether the function is a node function 80 | * @private 81 | */ 82 | isNodeFunction(funcName) { 83 | // Find the function definition in any node 84 | for (const node of Object.values(this.flow.nodes)) { 85 | const func = node.functions?.find((f) => f.function.name === funcName); 86 | if (func) { 87 | // Node functions are those that have a handler (indicated by parameters) 88 | // Edge functions are those that have transition_to 89 | const params = func.function.parameters; 90 | const hasProperties = Object.keys(params.properties || {}).length > 0; 91 | const hasRequired = 92 | Array.isArray(params.required) && params.required.length > 0; 93 | const hasConstraints = Object.values(params.properties || {}).some( 94 | (prop) => 95 | prop.enum || 96 | prop.minimum !== undefined || 97 | prop.maximum !== undefined, 98 | ); 99 | 100 | // Function is a node function if it has parameters 101 | // Edge functions should only have transition_to 102 | return hasProperties && (hasRequired || hasConstraints); 103 | } 104 | } 105 | return false; 106 | } 107 | 108 | /** 109 | * Validates node references in functions 110 | * @private 111 | */ 112 | _validateNodeReferences() { 113 | Object.entries(this.flow.nodes).forEach(([nodeId, node]) => { 114 | if (node.functions) { 115 | node.functions.forEach((func) => { 116 | // Get the transition target from transition_to property 117 | const transitionTo = func.function?.transition_to; 118 | const hasHandler = func.function?.handler; 119 | 120 | // If there's a transition_to, validate it points to a valid node 121 | if (transitionTo && !this.flow.nodes[transitionTo]) { 122 | this.errors.push( 123 | `Node '${nodeId}' has function '${func.function.name}' with invalid transition_to: '${transitionTo}'`, 124 | ); 125 | } 126 | 127 | // Skip validation for functions that: 128 | // - have parameters (node functions) 129 | // - have a handler 130 | // - have a transition_to 131 | // - are end functions 132 | const funcName = func.function?.name; 133 | if ( 134 | !this.isNodeFunction(funcName) && 135 | !hasHandler && 136 | !transitionTo && 137 | funcName !== "end" && 138 | !this.flow.nodes[funcName] 139 | ) { 140 | this.errors.push( 141 | `Node '${nodeId}' has function '${funcName}' that doesn't reference a valid node`, 142 | ); 143 | } 144 | }); 145 | } 146 | }); 147 | } 148 | 149 | _validateTransitions() { 150 | Object.entries(this.flow.nodes).forEach(([nodeId, node]) => { 151 | if (node.functions) { 152 | node.functions.forEach((func) => { 153 | const transition_to = func.function.transition_to; 154 | if (transition_to && !this.flow.nodes[transition_to]) { 155 | this.errors.push( 156 | `Node '${nodeId}' has function '${func.function.name}' with invalid transition_to: '${transition_to}'`, 157 | ); 158 | } 159 | }); 160 | } 161 | }); 162 | } 163 | 164 | /** 165 | * Validates node contents 166 | * @private 167 | */ 168 | _validateNodeContents() { 169 | Object.entries(this.flow.nodes).forEach(([nodeId, node]) => { 170 | // Validate task_messages (required) 171 | if (!node.task_messages || node.task_messages.length === 0) { 172 | this.errors.push( 173 | `Node '${nodeId}' must have at least one task message`, 174 | ); 175 | } 176 | 177 | // Validate functions 178 | node.functions?.forEach((func) => { 179 | if (!func.function) { 180 | this.errors.push( 181 | `Function in node '${nodeId}' missing 'function' object`, 182 | ); 183 | } else if (!func.function.name) { 184 | this.errors.push(`Function in node '${nodeId}' missing 'name'`); 185 | } 186 | }); 187 | 188 | // Validate actions if present 189 | if (node.pre_actions && !Array.isArray(node.pre_actions)) { 190 | this.errors.push(`Node '${nodeId}' pre_actions must be an array`); 191 | } 192 | if (node.post_actions && !Array.isArray(node.post_actions)) { 193 | this.errors.push(`Node '${nodeId}' post_actions must be an array`); 194 | } 195 | }); 196 | } 197 | 198 | /** 199 | * Validates the message structure of all nodes 200 | * @private 201 | */ 202 | _validateMessageStructure() { 203 | Object.entries(this.flow.nodes).forEach(([nodeId, node]) => { 204 | // Validate task_messages (required) 205 | if ( 206 | !node.task_messages || 207 | !Array.isArray(node.task_messages) || 208 | node.task_messages.length === 0 209 | ) { 210 | this.errors.push( 211 | `Node '${nodeId}' must have at least one task message`, 212 | ); 213 | } 214 | 215 | // Validate role_messages if present 216 | if (node.role_messages !== undefined) { 217 | if (!Array.isArray(node.role_messages)) { 218 | this.errors.push(`Node '${nodeId}' role_messages must be an array`); 219 | } 220 | } 221 | 222 | // Validate message content 223 | if (node.role_messages) { 224 | node.role_messages.forEach((msg, index) => { 225 | if (!msg.role || !msg.content) { 226 | this.errors.push( 227 | `Role message ${index} in node '${nodeId}' missing required fields (role, content)`, 228 | ); 229 | } 230 | }); 231 | } 232 | 233 | node.task_messages?.forEach((msg, index) => { 234 | if (!msg.role || !msg.content) { 235 | this.errors.push( 236 | `Task message ${index} in node '${nodeId}' missing required fields (role, content)`, 237 | ); 238 | } 239 | }); 240 | }); 241 | } 242 | } 243 | 244 | /** 245 | * Validates a flow configuration 246 | * @param {FlowConfig} flowConfig - The flow configuration to validate 247 | * @returns {ValidationResult} Validation result 248 | */ 249 | export function validateFlow(flowConfig) { 250 | const validator = new FlowValidator(flowConfig); 251 | return { 252 | valid: validator.validate().length === 0, 253 | errors: validator.errors, 254 | }; 255 | } 256 | -------------------------------------------------------------------------------- /editor/jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["js"], 4 | "includePattern": ".js$", 5 | "excludePattern": "(node_modules/|docs)" 6 | }, 7 | "plugins": ["plugins/markdown"], 8 | "opts": { 9 | "destination": "./docs", 10 | "recurse": true, 11 | "readme": "./README.md" 12 | }, 13 | "tags": { 14 | "allowUnknownTags": true 15 | }, 16 | "templates": { 17 | "cleverLinks": false, 18 | "monospaceLinks": false, 19 | "default": { 20 | "outputSourceFiles": true, 21 | "includeDate": false 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pipecat-flow-editor", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "start": "vite", 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "preview:prod": "vite preview --base=/pipecat-flows/", 11 | "watch": "vite build --watch", 12 | "lint": "eslint js/", 13 | "lint:fix": "eslint js/ --fix", 14 | "format": "prettier --write .", 15 | "format:check": "prettier --check .", 16 | "docs": "jsdoc -c jsdoc.json", 17 | "docs:serve": "http-server docs" 18 | }, 19 | "dependencies": { 20 | "dagre": "^0.8.5", 21 | "jsdoc": "^4.0.4", 22 | "litegraph.js": "^0.7.14" 23 | }, 24 | "devDependencies": { 25 | "autoprefixer": "^10.4.20", 26 | "daisyui": "^4.12.14", 27 | "eslint": "^8.56.0", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-plugin-import": "^2.29.1", 30 | "eslint-plugin-jsdoc": "^48.0.2", 31 | "http-server": "^14.1.1", 32 | "postcss": "^8.4.49", 33 | "prettier": "^3.1.1", 34 | "tailwindcss": "^3.4.15", 35 | "vite": "^5.4.11" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /editor/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /editor/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/pipecat-flows/12bb9f5b356e2905ae4bdaf5b26cd2505cd3e2ff/editor/public/favicon.png -------------------------------------------------------------------------------- /editor/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /editor/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./js/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("daisyui")], 8 | daisyui: { 9 | themes: [ 10 | { 11 | dark: { 12 | ...require("daisyui/src/theming/themes")["dark"], 13 | primary: "#4F46E5", // Custom primary color 14 | "primary-focus": "#4338CA", // Darker shade for hover/focus 15 | "primary-content": "#ffffff", // Text on primary background 16 | 17 | secondary: "#6B7280", // Gray-500 18 | "secondary-focus": "#4B5563", // Gray-600 for hover 19 | "secondary-content": "#ffffff", // Text on secondary background 20 | 21 | // Background colors 22 | "base-100": "#111111", // Darkest - for sidebar 23 | "base-200": "#1a1a1a", // Dark - for content areas 24 | "base-300": "#2a2a2a", // Lighter - for borders etc 25 | 26 | // Text colors 27 | "base-content": "#ffffff", // Primary text color 28 | "text-base": "#ffffff", // Alternative text color 29 | "text-muted": "#a3a3a3", // Muted text color 30 | }, 31 | }, 32 | ], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /editor/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build", 3 | "outputDirectory": "../dist", 4 | "installCommand": "npm install", 5 | "framework": "vite" 6 | } 7 | -------------------------------------------------------------------------------- /editor/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | base: "/", 5 | build: { 6 | outDir: "../dist", 7 | emptyOutDir: true, 8 | }, 9 | server: { 10 | port: 5173, 11 | }, 12 | publicDir: "public", 13 | }); 14 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | DEEPGRAM_API_KEY= 2 | CARTESIA_API_KEY= 3 | OPENAI_API_KEY= 4 | ANTHROPIC_API_KEY= 5 | GOOGLE_API_KEY= 6 | DAILY_API_KEY= 7 | TMDB_API_KEY= -------------------------------------------------------------------------------- /examples/assets/hold_music/README.md: -------------------------------------------------------------------------------- 1 | # Hold Music Player 2 | 3 | This project is a hold music player, based on the `wav_audio_send` example from the [daily-python repository](https://github.com/daily-co/daily-python/blob/main/demos/audio/wav_audio_send.py). It is designed to serve as a helper for other examples, providing a reusable component for scenarios that require hold music functionality. 4 | 5 | The hold music WAV file used in this example was sourced from [No Copyright Music](https://www.no-copyright-music.com/). 6 | 7 | To see this hold music player in action, check out the [warm transfer example](../warm_transfer.py). 8 | -------------------------------------------------------------------------------- /examples/assets/hold_music/hold_music.py: -------------------------------------------------------------------------------- 1 | # 2 | # This demo will join a Daily meeting and send the audio from a WAV file into 3 | # the meeting. It uses the asyncio library. 4 | # 5 | # Usage: python3 hold_music.py -m MEETING_URL -i FILE.wav 6 | # 7 | 8 | import argparse 9 | import asyncio 10 | import signal 11 | import wave 12 | 13 | from daily import * 14 | 15 | SAMPLE_RATE = 16000 16 | NUM_CHANNELS = 1 17 | 18 | 19 | class AsyncSendWavApp: 20 | def __init__(self, input_file_name, sample_rate, num_channels): 21 | self.__mic_device = Daily.create_microphone_device( 22 | "my-mic", 23 | sample_rate=sample_rate, 24 | channels=num_channels, 25 | non_blocking=True, 26 | ) 27 | 28 | self.__client = CallClient() 29 | 30 | self.__client.update_subscription_profiles( 31 | {"base": {"camera": "unsubscribed", "microphone": "unsubscribed"}} 32 | ) 33 | 34 | self.__app_error = None 35 | 36 | self.__start_event = asyncio.Event() 37 | self.__task = asyncio.get_running_loop().create_task(self.send_wav_file(input_file_name)) 38 | 39 | async def run(self, meeting_url, meeting_token): 40 | (data, error) = await self.join(meeting_url, meeting_token) 41 | 42 | if error: 43 | print(f"Unable to join meeting: {error}") 44 | self.__app_error = error 45 | 46 | self.__start_event.set() 47 | 48 | await self.__task 49 | 50 | async def join(self, meeting_url, meeting_token): 51 | future = asyncio.get_running_loop().create_future() 52 | 53 | def join_completion(data, error): 54 | future.get_loop().call_soon_threadsafe(future.set_result, (data, error)) 55 | 56 | self.__client.join( 57 | meeting_url, 58 | meeting_token, 59 | client_settings={ 60 | "inputs": { 61 | "camera": False, 62 | "microphone": {"isEnabled": True, "settings": {"deviceId": "my-mic"}}, 63 | } 64 | }, 65 | completion=join_completion, 66 | ) 67 | 68 | return await future 69 | 70 | async def leave(self): 71 | future = asyncio.get_running_loop().create_future() 72 | 73 | def leave_completion(error): 74 | future.get_loop().call_soon_threadsafe(future.set_result, error) 75 | 76 | self.__client.leave(completion=leave_completion) 77 | 78 | await future 79 | 80 | self.__client.release() 81 | 82 | self.__task.cancel() 83 | await self.__task 84 | 85 | async def write_frames(self, frames): 86 | future = asyncio.get_running_loop().create_future() 87 | 88 | def write_completion(count): 89 | future.get_loop().call_soon_threadsafe(future.set_result, count) 90 | 91 | self.__mic_device.write_frames(frames, completion=write_completion) 92 | 93 | await future 94 | 95 | async def send_wav_file(self, file_name): 96 | await self.__start_event.wait() 97 | 98 | if self.__app_error: 99 | print(f"Unable to send WAV file!") 100 | return 101 | 102 | try: 103 | wav = wave.open(file_name, "rb") 104 | 105 | sent_frames = 0 106 | total_frames = wav.getnframes() 107 | sample_rate = wav.getframerate() 108 | while sent_frames < total_frames: 109 | # Read 100ms worth of audio frames. 110 | frames = wav.readframes(int(sample_rate / 10)) 111 | if len(frames) > 0: 112 | await self.write_frames(frames) 113 | sent_frames += sample_rate / 10 114 | except asyncio.CancelledError: 115 | pass 116 | 117 | 118 | async def sig_handler(app): 119 | print("Ctrl-C detected. Exiting!") 120 | await app.leave() 121 | 122 | 123 | async def main(): 124 | parser = argparse.ArgumentParser() 125 | parser.add_argument("-m", "--meeting", required=True, help="Meeting URL") 126 | parser.add_argument("-t", "--token", required=True, help="Meeting token") 127 | parser.add_argument("-i", "--input", required=True, help="WAV input file") 128 | parser.add_argument( 129 | "-c", "--channels", type=int, default=NUM_CHANNELS, help="Number of channels" 130 | ) 131 | parser.add_argument("-r", "--rate", type=int, default=SAMPLE_RATE, help="Sample rate") 132 | 133 | args = parser.parse_args() 134 | 135 | Daily.init() 136 | 137 | app = AsyncSendWavApp(args.input, args.rate, args.channels) 138 | 139 | loop = asyncio.get_running_loop() 140 | 141 | loop.add_signal_handler(signal.SIGINT, lambda *args: asyncio.create_task(sig_handler(app))) 142 | 143 | await app.run(args.meeting, args.token) 144 | 145 | 146 | if __name__ == "__main__": 147 | asyncio.run(main()) 148 | -------------------------------------------------------------------------------- /examples/assets/hold_music/hold_music.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/pipecat-flows/12bb9f5b356e2905ae4bdaf5b26cd2505cd3e2ff/examples/assets/hold_music/hold_music.wav -------------------------------------------------------------------------------- /examples/dynamic/insurance_gemini.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | """Insurance Quote Example using Pipecat Dynamic Flows with Google Gemini. 7 | 8 | This example demonstrates how to create a conversational insurance quote bot using: 9 | - Dynamic flow management for flexible conversation paths 10 | - Google Gemini for natural language understanding 11 | - Simple function handlers for processing user input 12 | - Node configurations for different conversation states 13 | - Pre/post actions for user feedback 14 | 15 | The flow allows users to: 16 | 1. Provide their age 17 | 2. Specify marital status 18 | 3. Get an insurance quote 19 | 4. Adjust coverage options 20 | 5. Complete the quote process 21 | 22 | Requirements: 23 | - Daily room URL 24 | - Google API key 25 | - Deepgram API key 26 | """ 27 | 28 | import asyncio 29 | import os 30 | import sys 31 | from pathlib import Path 32 | from typing import Dict, TypedDict, Union 33 | 34 | import aiohttp 35 | from dotenv import load_dotenv 36 | from loguru import logger 37 | from pipecat.audio.vad.silero import SileroVADAnalyzer 38 | from pipecat.pipeline.pipeline import Pipeline 39 | from pipecat.pipeline.runner import PipelineRunner 40 | from pipecat.pipeline.task import PipelineParams, PipelineTask 41 | from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext 42 | from pipecat.services.deepgram.stt import DeepgramSTTService 43 | from pipecat.services.deepgram.tts import DeepgramTTSService 44 | from pipecat.services.google.llm import GoogleLLMService 45 | from pipecat.transports.services.daily import DailyParams, DailyTransport 46 | 47 | from pipecat_flows import FlowArgs, FlowManager, FlowResult, FlowsFunctionSchema, NodeConfig 48 | 49 | sys.path.append(str(Path(__file__).parent.parent)) 50 | from runner import configure 51 | 52 | load_dotenv(override=True) 53 | 54 | logger.remove(0) 55 | logger.add(sys.stderr, level="DEBUG") 56 | 57 | 58 | # Type definitions 59 | class InsuranceQuote(TypedDict): 60 | monthly_premium: float 61 | coverage_amount: int 62 | deductible: int 63 | 64 | 65 | class AgeCollectionResult(FlowResult): 66 | age: int 67 | 68 | 69 | class MaritalStatusResult(FlowResult): 70 | marital_status: str 71 | 72 | 73 | class QuoteCalculationResult(FlowResult, InsuranceQuote): 74 | pass 75 | 76 | 77 | class CoverageUpdateResult(FlowResult, InsuranceQuote): 78 | pass 79 | 80 | 81 | # Simulated insurance data 82 | INSURANCE_RATES = { 83 | "young_single": {"base_rate": 150, "risk_multiplier": 1.5}, 84 | "young_married": {"base_rate": 130, "risk_multiplier": 1.3}, 85 | "adult_single": {"base_rate": 100, "risk_multiplier": 1.0}, 86 | "adult_married": {"base_rate": 90, "risk_multiplier": 0.9}, 87 | } 88 | 89 | 90 | # Function handlers 91 | async def collect_age(args: FlowArgs) -> AgeCollectionResult: 92 | """Process age collection.""" 93 | age = args["age"] 94 | logger.debug(f"collect_age handler executing with age: {age}") 95 | return AgeCollectionResult(age=age) 96 | 97 | 98 | async def collect_marital_status(args: FlowArgs) -> MaritalStatusResult: 99 | """Process marital status collection.""" 100 | status = args["marital_status"] 101 | logger.debug(f"collect_marital_status handler executing with status: {status}") 102 | return MaritalStatusResult(marital_status=status) 103 | 104 | 105 | async def calculate_quote(args: FlowArgs) -> QuoteCalculationResult: 106 | """Calculate insurance quote based on age and marital status.""" 107 | age = args["age"] 108 | marital_status = args["marital_status"] 109 | logger.debug(f"calculate_quote handler executing with age: {age}, status: {marital_status}") 110 | 111 | # Determine rate category 112 | age_category = "young" if age < 25 else "adult" 113 | rate_key = f"{age_category}_{marital_status}" 114 | rates = INSURANCE_RATES.get(rate_key, INSURANCE_RATES["adult_single"]) 115 | 116 | # Calculate quote 117 | monthly_premium = rates["base_rate"] * rates["risk_multiplier"] 118 | 119 | return { 120 | "monthly_premium": monthly_premium, 121 | "coverage_amount": 250000, 122 | "deductible": 1000, 123 | } 124 | 125 | 126 | async def update_coverage(args: FlowArgs) -> CoverageUpdateResult: 127 | """Update coverage options and recalculate premium.""" 128 | coverage_amount = args["coverage_amount"] 129 | deductible = args["deductible"] 130 | logger.debug( 131 | f"update_coverage handler executing with amount: {coverage_amount}, deductible: {deductible}" 132 | ) 133 | 134 | # Calculate adjusted quote 135 | monthly_premium = (coverage_amount / 250000) * 100 136 | if deductible > 1000: 137 | monthly_premium *= 0.9 # 10% discount for higher deductible 138 | 139 | return { 140 | "monthly_premium": monthly_premium, 141 | "coverage_amount": coverage_amount, 142 | "deductible": deductible, 143 | } 144 | 145 | 146 | async def end_quote() -> FlowResult: 147 | """Handle quote completion.""" 148 | logger.debug("end_quote handler executing") 149 | return {"status": "completed"} 150 | 151 | 152 | # Transition callbacks and handlers 153 | async def handle_age_collection(args: Dict, result: AgeCollectionResult, flow_manager: FlowManager): 154 | flow_manager.state["age"] = result["age"] 155 | await flow_manager.set_node("marital_status", create_marital_status_node()) 156 | 157 | 158 | async def handle_marital_status_collection( 159 | args: Dict, result: MaritalStatusResult, flow_manager: FlowManager 160 | ): 161 | flow_manager.state["marital_status"] = result["marital_status"] 162 | await flow_manager.set_node( 163 | "quote_calculation", 164 | create_quote_calculation_node( 165 | flow_manager.state["age"], flow_manager.state["marital_status"] 166 | ), 167 | ) 168 | 169 | 170 | async def handle_quote_calculation( 171 | args: Dict, result: QuoteCalculationResult, flow_manager: FlowManager 172 | ): 173 | await flow_manager.set_node("quote_results", create_quote_results_node(result)) 174 | 175 | 176 | async def handle_end_quote(_: Dict, result: FlowResult, flow_manager: FlowManager): 177 | await flow_manager.set_node("end", create_end_node()) 178 | 179 | 180 | # Node configurations using FlowsFunctionSchema 181 | def create_initial_node() -> NodeConfig: 182 | """Create the initial node asking for age.""" 183 | return { 184 | "role_messages": [ 185 | { 186 | "role": "system", 187 | "content": ( 188 | "You are a friendly insurance agent. Your responses will be " 189 | "converted to audio, so avoid special characters. " 190 | "Always wait for customer responses before calling functions. " 191 | "Only call functions after receiving relevant information from the customer." 192 | ), 193 | } 194 | ], 195 | "task_messages": [ 196 | { 197 | "role": "system", 198 | "content": "Start by asking for the customer's age.", 199 | } 200 | ], 201 | "functions": [ 202 | FlowsFunctionSchema( 203 | name="collect_age", 204 | description="Record customer's age", 205 | properties={"age": {"type": "integer"}}, 206 | required=["age"], 207 | handler=collect_age, 208 | transition_callback=handle_age_collection, 209 | ) 210 | ], 211 | } 212 | 213 | 214 | def create_marital_status_node() -> NodeConfig: 215 | """Create node for collecting marital status.""" 216 | return { 217 | "task_messages": [ 218 | { 219 | "role": "system", 220 | "content": ( 221 | "Ask about the customer's marital status (single or married). " 222 | "Wait for their response before calling collect_marital_status. " 223 | "Only call the function after they provide their status." 224 | ), 225 | } 226 | ], 227 | "functions": [ 228 | FlowsFunctionSchema( 229 | name="collect_marital_status", 230 | description="Record marital status after customer provides it", 231 | properties={"marital_status": {"type": "string", "enum": ["single", "married"]}}, 232 | required=["marital_status"], 233 | handler=collect_marital_status, 234 | transition_callback=handle_marital_status_collection, 235 | ) 236 | ], 237 | } 238 | 239 | 240 | def create_quote_calculation_node(age: int, marital_status: str) -> NodeConfig: 241 | """Create node for calculating initial quote.""" 242 | return { 243 | "task_messages": [ 244 | { 245 | "role": "system", 246 | "content": ( 247 | f"Calculate a quote for {age} year old {marital_status} customer. " 248 | "Call calculate_quote with their information. " 249 | "After receiving the quote, explain the details and ask if they'd like to adjust coverage." 250 | ), 251 | } 252 | ], 253 | "functions": [ 254 | FlowsFunctionSchema( 255 | name="calculate_quote", 256 | description="Calculate initial insurance quote", 257 | properties={ 258 | "age": {"type": "integer"}, 259 | "marital_status": {"type": "string", "enum": ["single", "married"]}, 260 | }, 261 | required=["age", "marital_status"], 262 | handler=calculate_quote, 263 | transition_callback=handle_quote_calculation, 264 | ) 265 | ], 266 | } 267 | 268 | 269 | def create_quote_results_node( 270 | quote: Union[QuoteCalculationResult, CoverageUpdateResult], 271 | ) -> NodeConfig: 272 | """Create node for showing quote and adjustment options.""" 273 | return { 274 | "task_messages": [ 275 | { 276 | "role": "system", 277 | "content": ( 278 | f"Quote details:\n" 279 | f"Monthly Premium: ${quote['monthly_premium']:.2f}\n" 280 | f"Coverage Amount: ${quote['coverage_amount']:,}\n" 281 | f"Deductible: ${quote['deductible']:,}\n\n" 282 | "Explain these quote details to the customer. When they request changes, " 283 | "use update_coverage to recalculate their quote. Explain how their " 284 | "changes affected the premium and compare it to their previous quote. " 285 | "Ask if they'd like to make any other adjustments or if they're ready " 286 | "to end the quote process." 287 | ), 288 | } 289 | ], 290 | "functions": [ 291 | FlowsFunctionSchema( 292 | name="update_coverage", 293 | description="Recalculate quote with new coverage options", 294 | properties={ 295 | "coverage_amount": {"type": "integer"}, 296 | "deductible": {"type": "integer"}, 297 | }, 298 | required=["coverage_amount", "deductible"], 299 | handler=update_coverage, 300 | ), 301 | FlowsFunctionSchema( 302 | name="end_quote", 303 | description="Complete the quote process when customer is satisfied", 304 | properties={"status": {"type": "string", "enum": ["completed"]}}, 305 | required=["status"], 306 | handler=end_quote, 307 | transition_callback=handle_end_quote, 308 | ), 309 | ], 310 | } 311 | 312 | 313 | def create_end_node() -> NodeConfig: 314 | """Create the final node.""" 315 | return { 316 | "task_messages": [ 317 | { 318 | "role": "system", 319 | "content": ( 320 | "Thank the customer for their time and end the conversation. " 321 | "Mention that a representative will contact them about the quote." 322 | ), 323 | } 324 | ], 325 | "post_actions": [{"type": "end_conversation"}], 326 | } 327 | 328 | 329 | async def main(): 330 | """Main function to set up and run the insurance quote bot.""" 331 | async with aiohttp.ClientSession() as session: 332 | (room_url, _) = await configure(session) 333 | 334 | # Initialize services 335 | transport = DailyTransport( 336 | room_url, 337 | None, 338 | "Insurance Quote Bot", 339 | DailyParams( 340 | audio_in_enabled=True, 341 | audio_out_enabled=True, 342 | vad_analyzer=SileroVADAnalyzer(), 343 | ), 344 | ) 345 | 346 | stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) 347 | tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-helios-en") 348 | llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash-exp") 349 | 350 | context = OpenAILLMContext() 351 | context_aggregator = llm.create_context_aggregator(context) 352 | 353 | # Create pipeline 354 | pipeline = Pipeline( 355 | [ 356 | transport.input(), 357 | stt, 358 | context_aggregator.user(), 359 | llm, 360 | tts, 361 | transport.output(), 362 | context_aggregator.assistant(), 363 | ] 364 | ) 365 | 366 | task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) 367 | 368 | # Initialize flow manager with transition callback 369 | flow_manager = FlowManager( 370 | task=task, 371 | llm=llm, 372 | context_aggregator=context_aggregator, 373 | tts=tts, 374 | ) 375 | 376 | @transport.event_handler("on_first_participant_joined") 377 | async def on_first_participant_joined(transport, participant): 378 | await transport.capture_participant_transcription(participant["id"]) 379 | # Initialize flow 380 | await flow_manager.initialize() 381 | # Set initial node 382 | await flow_manager.set_node("initial", create_initial_node()) 383 | 384 | # Run the pipeline 385 | runner = PipelineRunner() 386 | await runner.run(task) 387 | 388 | 389 | if __name__ == "__main__": 390 | asyncio.run(main()) 391 | -------------------------------------------------------------------------------- /examples/dynamic/restaurant_reservation.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | import asyncio 8 | import os 9 | import sys 10 | from pathlib import Path 11 | from typing import Dict 12 | 13 | import aiohttp 14 | from dotenv import load_dotenv 15 | from loguru import logger 16 | from pipecat.audio.vad.silero import SileroVADAnalyzer 17 | from pipecat.pipeline.pipeline import Pipeline 18 | from pipecat.pipeline.runner import PipelineRunner 19 | from pipecat.pipeline.task import PipelineParams, PipelineTask 20 | from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext 21 | from pipecat.services.cartesia.tts import CartesiaTTSService 22 | from pipecat.services.deepgram.stt import DeepgramSTTService 23 | from pipecat.services.openai.llm import OpenAILLMService 24 | from pipecat.transports.services.daily import DailyParams, DailyTransport 25 | 26 | from pipecat_flows import FlowArgs, FlowManager, FlowResult, FlowsFunctionSchema, NodeConfig 27 | 28 | sys.path.append(str(Path(__file__).parent.parent)) 29 | import argparse 30 | 31 | from runner import configure 32 | 33 | load_dotenv(override=True) 34 | 35 | logger.remove(0) 36 | logger.add(sys.stderr, level="DEBUG") 37 | 38 | 39 | # Mock reservation system 40 | class MockReservationSystem: 41 | """Simulates a restaurant reservation system API.""" 42 | 43 | def __init__(self): 44 | # Mock data: Times that are "fully booked" 45 | self.booked_times = {"7:00 PM", "8:00 PM"} # Changed to AM/PM format 46 | 47 | async def check_availability( 48 | self, party_size: int, requested_time: str 49 | ) -> tuple[bool, list[str]]: 50 | """Check if a table is available for the given party size and time.""" 51 | # Simulate API call delay 52 | await asyncio.sleep(0.5) 53 | 54 | # Check if time is booked 55 | is_available = requested_time not in self.booked_times 56 | 57 | # If not available, suggest alternative times 58 | alternatives = [] 59 | if not is_available: 60 | base_times = ["5:00 PM", "6:00 PM", "7:00 PM", "8:00 PM", "9:00 PM", "10:00 PM"] 61 | alternatives = [t for t in base_times if t not in self.booked_times] 62 | 63 | return is_available, alternatives 64 | 65 | 66 | # Initialize mock system 67 | reservation_system = MockReservationSystem() 68 | 69 | 70 | # Type definitions for function results 71 | class PartySizeResult(FlowResult): 72 | size: int 73 | status: str 74 | 75 | 76 | class TimeResult(FlowResult): 77 | status: str 78 | time: str 79 | available: bool 80 | alternative_times: list[str] 81 | 82 | 83 | # Function handlers 84 | async def collect_party_size(args: FlowArgs) -> PartySizeResult: 85 | """Process party size collection.""" 86 | size = args["size"] 87 | return PartySizeResult(size=size, status="success") 88 | 89 | 90 | async def check_availability(args: FlowArgs) -> TimeResult: 91 | """Check reservation availability and return result.""" 92 | time = args["time"] 93 | party_size = args["party_size"] 94 | 95 | # Check availability with mock API 96 | is_available, alternative_times = await reservation_system.check_availability(party_size, time) 97 | 98 | result = TimeResult( 99 | status="success", time=time, available=is_available, alternative_times=alternative_times 100 | ) 101 | return result 102 | 103 | 104 | # Transition handlers 105 | async def handle_party_size_collection( 106 | args: Dict, result: PartySizeResult, flow_manager: FlowManager 107 | ): 108 | """Handle party size collection and transition to time selection.""" 109 | # Store party size in flow state 110 | flow_manager.state["party_size"] = result["size"] 111 | await flow_manager.set_node("get_time", create_time_selection_node()) 112 | 113 | 114 | async def handle_availability_check(args: Dict, result: TimeResult, flow_manager: FlowManager): 115 | """Handle availability check result and transition based on availability.""" 116 | # Store reservation details in flow state 117 | flow_manager.state["requested_time"] = args["time"] 118 | 119 | # Use result directly instead of accessing state 120 | if result["available"]: 121 | logger.debug("Time is available, transitioning to confirmation node") 122 | await flow_manager.set_node("confirm", create_confirmation_node()) 123 | else: 124 | logger.debug(f"Time not available, storing alternatives: {result['alternative_times']}") 125 | await flow_manager.set_node( 126 | "no_availability", create_no_availability_node(result["alternative_times"]) 127 | ) 128 | 129 | 130 | async def handle_end(_: Dict, result: FlowResult, flow_manager: FlowManager): 131 | """Handle conversation end.""" 132 | await flow_manager.set_node("end", create_end_node()) 133 | 134 | 135 | # Create function schemas 136 | party_size_schema = FlowsFunctionSchema( 137 | name="collect_party_size", 138 | description="Record the number of people in the party", 139 | properties={"size": {"type": "integer", "minimum": 1, "maximum": 12}}, 140 | required=["size"], 141 | handler=collect_party_size, 142 | transition_callback=handle_party_size_collection, 143 | ) 144 | 145 | availability_schema = FlowsFunctionSchema( 146 | name="check_availability", 147 | description="Check availability for requested time", 148 | properties={ 149 | "time": { 150 | "type": "string", 151 | "pattern": "^([5-9]|10):00 PM$", # Matches "5:00 PM" through "10:00 PM" 152 | "description": "Reservation time (e.g., '6:00 PM')", 153 | }, 154 | "party_size": {"type": "integer"}, 155 | }, 156 | required=["time", "party_size"], 157 | handler=check_availability, 158 | transition_callback=handle_availability_check, 159 | ) 160 | 161 | end_conversation_schema = FlowsFunctionSchema( 162 | name="end_conversation", 163 | description="End the conversation", 164 | properties={}, 165 | required=[], 166 | transition_callback=handle_end, 167 | ) 168 | 169 | 170 | # Node configurations 171 | def create_initial_node(wait_for_user: bool) -> NodeConfig: 172 | """Create initial node for party size collection.""" 173 | return { 174 | "role_messages": [ 175 | { 176 | "role": "system", 177 | "content": "You are a restaurant reservation assistant for La Maison, an upscale French restaurant. Be casual and friendly. This is a voice conversation, so avoid special characters and emojis.", 178 | } 179 | ], 180 | "task_messages": [ 181 | { 182 | "role": "system", 183 | "content": "Warmly greet the customer and ask how many people are in their party. This is your only job for now; if the customer asks for something else, politely remind them you can't do it.", 184 | } 185 | ], 186 | "functions": [party_size_schema], 187 | "respond_immediately": not wait_for_user, 188 | } 189 | 190 | 191 | def create_time_selection_node() -> NodeConfig: 192 | """Create node for time selection and availability check.""" 193 | logger.debug("Creating time selection node") 194 | return { 195 | "task_messages": [ 196 | { 197 | "role": "system", 198 | "content": "Ask what time they'd like to dine. Restaurant is open 5 PM to 10 PM.", 199 | } 200 | ], 201 | "functions": [availability_schema], 202 | } 203 | 204 | 205 | def create_confirmation_node() -> NodeConfig: 206 | """Create confirmation node for successful reservations.""" 207 | return { 208 | "task_messages": [ 209 | { 210 | "role": "system", 211 | "content": "Confirm the reservation details and ask if they need anything else.", 212 | } 213 | ], 214 | "functions": [end_conversation_schema], 215 | } 216 | 217 | 218 | def create_no_availability_node(alternative_times: list[str]) -> NodeConfig: 219 | """Create node for handling no availability.""" 220 | times_list = ", ".join(alternative_times) 221 | return { 222 | "task_messages": [ 223 | { 224 | "role": "system", 225 | "content": ( 226 | f"Apologize that the requested time is not available. " 227 | f"Suggest these alternative times: {times_list}. " 228 | "Ask if they'd like to try one of these times." 229 | ), 230 | } 231 | ], 232 | "functions": [availability_schema, end_conversation_schema], 233 | } 234 | 235 | 236 | def create_end_node() -> NodeConfig: 237 | """Create the final node.""" 238 | return { 239 | "task_messages": [ 240 | { 241 | "role": "system", 242 | "content": "Thank them and end the conversation.", 243 | } 244 | ], 245 | "post_actions": [{"type": "end_conversation"}], 246 | } 247 | 248 | 249 | # Main setup 250 | async def main(wait_for_user: bool): 251 | async with aiohttp.ClientSession() as session: 252 | (room_url, _) = await configure(session) 253 | 254 | transport = DailyTransport( 255 | room_url, 256 | None, 257 | "Reservation bot", 258 | DailyParams( 259 | audio_in_enabled=True, 260 | audio_out_enabled=True, 261 | vad_analyzer=SileroVADAnalyzer(), 262 | ), 263 | ) 264 | 265 | stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) 266 | tts = CartesiaTTSService( 267 | api_key=os.getenv("CARTESIA_API_KEY"), 268 | voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady 269 | ) 270 | llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") 271 | 272 | context = OpenAILLMContext() 273 | context_aggregator = llm.create_context_aggregator(context) 274 | 275 | pipeline = Pipeline( 276 | [ 277 | transport.input(), 278 | stt, 279 | context_aggregator.user(), 280 | llm, 281 | tts, 282 | transport.output(), 283 | context_aggregator.assistant(), 284 | ] 285 | ) 286 | 287 | task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) 288 | 289 | # Initialize flow manager 290 | flow_manager = FlowManager( 291 | task=task, 292 | llm=llm, 293 | context_aggregator=context_aggregator, 294 | ) 295 | 296 | @transport.event_handler("on_first_participant_joined") 297 | async def on_first_participant_joined(transport, participant): 298 | await transport.capture_participant_transcription(participant["id"]) 299 | logger.debug("Initializing flow manager") 300 | await flow_manager.initialize() 301 | logger.debug("Setting initial node") 302 | await flow_manager.set_node("initial", create_initial_node(wait_for_user)) 303 | 304 | runner = PipelineRunner() 305 | await runner.run(task) 306 | 307 | 308 | if __name__ == "__main__": 309 | parser = argparse.ArgumentParser(description="Restaurant reservation bot") 310 | parser.add_argument( 311 | "--wait-for-user", 312 | action="store_true", 313 | help="If set, the bot will wait for the user to speak first", 314 | ) 315 | args = parser.parse_args() 316 | 317 | asyncio.run(main(args.wait_for_user)) 318 | -------------------------------------------------------------------------------- /examples/runner.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | import argparse 8 | import os 9 | 10 | import aiohttp 11 | from pipecat.transports.services.helpers.daily_rest import ( 12 | DailyMeetingTokenParams, 13 | DailyMeetingTokenProperties, 14 | DailyRESTHelper, 15 | ) 16 | 17 | 18 | async def configure(aiohttp_session: aiohttp.ClientSession): 19 | (url, token, _) = await configure_with_args(aiohttp_session) 20 | return (url, token) 21 | 22 | 23 | async def configure_with_args( 24 | aiohttp_session: aiohttp.ClientSession, parser: argparse.ArgumentParser | None = None 25 | ): 26 | if not parser: 27 | parser = argparse.ArgumentParser(description="Daily AI SDK Bot Sample") 28 | parser.add_argument( 29 | "-u", "--url", type=str, required=False, help="URL of the Daily room to join" 30 | ) 31 | parser.add_argument( 32 | "-k", 33 | "--apikey", 34 | type=str, 35 | required=False, 36 | help="Daily API Key (needed to create an owner token for the room)", 37 | ) 38 | 39 | args, unknown = parser.parse_known_args() 40 | 41 | url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL") 42 | key = args.apikey or os.getenv("DAILY_API_KEY") 43 | 44 | if not url: 45 | raise Exception( 46 | "No Daily room specified. use the -u/--url option from the command line, or set DAILY_SAMPLE_ROOM_URL in your environment to specify a Daily room URL." 47 | ) 48 | 49 | if not key: 50 | raise Exception( 51 | "No Daily API key specified. use the -k/--apikey option from the command line, or set DAILY_API_KEY in your environment to specify a Daily API key, available from https://dashboard.daily.co/developers." 52 | ) 53 | 54 | daily_rest_helper = DailyRESTHelper( 55 | daily_api_key=key, 56 | daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"), 57 | aiohttp_session=aiohttp_session, 58 | ) 59 | 60 | # Create a meeting token for the given room with an expiration 1 hour in 61 | # the future. 62 | expiry_time: float = 60 * 60 63 | 64 | token = await daily_rest_helper.get_token( 65 | url, 66 | expiry_time, 67 | params=DailyMeetingTokenParams(properties=DailyMeetingTokenProperties(user_id="bot")), 68 | ) 69 | 70 | return (url, token, args) 71 | -------------------------------------------------------------------------------- /examples/static/food_ordering.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | import asyncio 8 | import os 9 | import sys 10 | from pathlib import Path 11 | 12 | import aiohttp 13 | from dotenv import load_dotenv 14 | from loguru import logger 15 | from pipecat.audio.vad.silero import SileroVADAnalyzer 16 | from pipecat.pipeline.pipeline import Pipeline 17 | from pipecat.pipeline.runner import PipelineRunner 18 | from pipecat.pipeline.task import PipelineParams, PipelineTask 19 | from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext 20 | from pipecat.services.cartesia.tts import CartesiaTTSService 21 | from pipecat.services.deepgram.stt import DeepgramSTTService 22 | from pipecat.services.openai.llm import OpenAILLMService 23 | from pipecat.transports.services.daily import DailyParams, DailyTransport 24 | 25 | from pipecat_flows import FlowArgs, FlowConfig, FlowManager, FlowResult 26 | 27 | sys.path.append(str(Path(__file__).parent.parent)) 28 | from runner import configure 29 | 30 | load_dotenv(override=True) 31 | 32 | logger.remove(0) 33 | logger.add(sys.stderr, level="DEBUG") 34 | 35 | # Flow Configuration - Food ordering 36 | # 37 | # This configuration defines a food ordering system with the following states: 38 | # 39 | # 1. start 40 | # - Initial state where user chooses between pizza or sushi 41 | # - Functions: 42 | # * choose_pizza (transitions to choose_pizza) 43 | # * choose_sushi (transitions to choose_sushi) 44 | # 45 | # 2. choose_pizza 46 | # - Handles pizza order details 47 | # - Functions: 48 | # * select_pizza_order (node function with size and type) 49 | # * confirm_order (transitions to confirm) 50 | # - Pricing: 51 | # * Small: $10 52 | # * Medium: $15 53 | # * Large: $20 54 | # 55 | # 3. choose_sushi 56 | # - Handles sushi order details 57 | # - Functions: 58 | # * select_sushi_order (node function with count and type) 59 | # * confirm_order (transitions to confirm) 60 | # - Pricing: 61 | # * $8 per roll 62 | # 63 | # 4. confirm 64 | # - Reviews order details with the user 65 | # - Functions: 66 | # * complete_order (transitions to end) 67 | # 68 | # 5. end 69 | # - Final state that closes the conversation 70 | # - No functions available 71 | # - Post-action: Ends conversation 72 | 73 | 74 | # Type definitions 75 | class PizzaOrderResult(FlowResult): 76 | size: str 77 | type: str 78 | price: float 79 | 80 | 81 | class SushiOrderResult(FlowResult): 82 | count: int 83 | type: str 84 | price: float 85 | 86 | 87 | # Function handlers 88 | async def check_kitchen_status(action: dict) -> None: 89 | """Check if kitchen is open and log status.""" 90 | logger.info("Checking kitchen status") 91 | 92 | 93 | async def select_pizza_order(args: FlowArgs) -> PizzaOrderResult: 94 | """Handle pizza size and type selection.""" 95 | size = args["size"] 96 | pizza_type = args["type"] 97 | 98 | # Simple pricing 99 | base_price = {"small": 10.00, "medium": 15.00, "large": 20.00} 100 | price = base_price[size] 101 | 102 | return {"size": size, "type": pizza_type, "price": price} 103 | 104 | 105 | async def select_sushi_order(args: FlowArgs) -> SushiOrderResult: 106 | """Handle sushi roll count and type selection.""" 107 | count = args["count"] 108 | roll_type = args["type"] 109 | 110 | # Simple pricing: $8 per roll 111 | price = count * 8.00 112 | 113 | return {"count": count, "type": roll_type, "price": price} 114 | 115 | 116 | flow_config: FlowConfig = { 117 | "initial_node": "start", 118 | "nodes": { 119 | "start": { 120 | "role_messages": [ 121 | { 122 | "role": "system", 123 | "content": "You are an order-taking assistant. You must ALWAYS use the available functions to progress the conversation. This is a phone conversation and your responses will be converted to audio. Keep the conversation friendly, casual, and polite. Avoid outputting special characters and emojis.", 124 | } 125 | ], 126 | "task_messages": [ 127 | { 128 | "role": "system", 129 | "content": "For this step, ask the user if they want pizza or sushi, and wait for them to use a function to choose. Start off by greeting them. Be friendly and casual; you're taking an order for food over the phone.", 130 | } 131 | ], 132 | "pre_actions": [ 133 | { 134 | "type": "check_kitchen", 135 | "handler": check_kitchen_status, 136 | }, 137 | ], 138 | "functions": [ 139 | { 140 | "type": "function", 141 | "function": { 142 | "name": "choose_pizza", 143 | "description": "User wants to order pizza. Let's get that order started.", 144 | "parameters": {"type": "object", "properties": {}}, 145 | "transition_to": "choose_pizza", 146 | }, 147 | }, 148 | { 149 | "type": "function", 150 | "function": { 151 | "name": "choose_sushi", 152 | "description": "User wants to order sushi. Let's get that order started.", 153 | "parameters": {"type": "object", "properties": {}}, 154 | "transition_to": "choose_sushi", 155 | }, 156 | }, 157 | ], 158 | }, 159 | "choose_pizza": { 160 | "task_messages": [ 161 | { 162 | "role": "system", 163 | "content": """You are handling a pizza order. Use the available functions: 164 | - Use select_pizza_order when the user specifies both size AND type 165 | 166 | Pricing: 167 | - Small: $10 168 | - Medium: $15 169 | - Large: $20 170 | 171 | Remember to be friendly and casual.""", 172 | } 173 | ], 174 | "functions": [ 175 | { 176 | "type": "function", 177 | "function": { 178 | "name": "select_pizza_order", 179 | "handler": select_pizza_order, 180 | "description": "Record the pizza order details", 181 | "parameters": { 182 | "type": "object", 183 | "properties": { 184 | "size": { 185 | "type": "string", 186 | "enum": ["small", "medium", "large"], 187 | "description": "Size of the pizza", 188 | }, 189 | "type": { 190 | "type": "string", 191 | "enum": ["pepperoni", "cheese", "supreme", "vegetarian"], 192 | "description": "Type of pizza", 193 | }, 194 | }, 195 | "required": ["size", "type"], 196 | }, 197 | "transition_to": "confirm", 198 | }, 199 | }, 200 | ], 201 | }, 202 | "choose_sushi": { 203 | "task_messages": [ 204 | { 205 | "role": "system", 206 | "content": """You are handling a sushi order. Use the available functions: 207 | - Use select_sushi_order when the user specifies both count AND type 208 | 209 | Pricing: 210 | - $8 per roll 211 | 212 | Remember to be friendly and casual.""", 213 | } 214 | ], 215 | "functions": [ 216 | { 217 | "type": "function", 218 | "function": { 219 | "name": "select_sushi_order", 220 | "handler": select_sushi_order, 221 | "description": "Record the sushi order details", 222 | "parameters": { 223 | "type": "object", 224 | "properties": { 225 | "count": { 226 | "type": "integer", 227 | "minimum": 1, 228 | "maximum": 10, 229 | "description": "Number of rolls to order", 230 | }, 231 | "type": { 232 | "type": "string", 233 | "enum": ["california", "spicy tuna", "rainbow", "dragon"], 234 | "description": "Type of sushi roll", 235 | }, 236 | }, 237 | "required": ["count", "type"], 238 | }, 239 | "transition_to": "confirm", 240 | }, 241 | }, 242 | ], 243 | }, 244 | "confirm": { 245 | "task_messages": [ 246 | { 247 | "role": "system", 248 | "content": """Read back the complete order details to the user and if they want anything else or if they want to make changes. Use the available functions: 249 | - Use complete_order when the user confirms that the order is correct and no changes are needed 250 | - Use revise_order if they want to change something 251 | 252 | Be friendly and clear when reading back the order details.""", 253 | } 254 | ], 255 | "functions": [ 256 | { 257 | "type": "function", 258 | "function": { 259 | "name": "complete_order", 260 | "description": "User confirms the order is correct", 261 | "parameters": {"type": "object", "properties": {}}, 262 | "transition_to": "end", 263 | }, 264 | }, 265 | { 266 | "type": "function", 267 | "function": { 268 | "name": "revise_order", 269 | "description": "User wants to make changes to their order", 270 | "parameters": {"type": "object", "properties": {}}, 271 | "transition_to": "start", 272 | }, 273 | }, 274 | ], 275 | }, 276 | "end": { 277 | "task_messages": [ 278 | { 279 | "role": "system", 280 | "content": "Thank the user for their order and end the conversation politely and concisely.", 281 | } 282 | ], 283 | "post_actions": [{"type": "end_conversation"}], 284 | }, 285 | }, 286 | } 287 | 288 | 289 | async def main(): 290 | """Main function to set up and run the food ordering bot.""" 291 | async with aiohttp.ClientSession() as session: 292 | (room_url, _) = await configure(session) 293 | 294 | # Initialize services 295 | transport = DailyTransport( 296 | room_url, 297 | None, 298 | "Food Ordering Bot", 299 | DailyParams( 300 | audio_in_enabled=True, 301 | audio_out_enabled=True, 302 | vad_analyzer=SileroVADAnalyzer(), 303 | ), 304 | ) 305 | 306 | stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) 307 | tts = CartesiaTTSService( 308 | api_key=os.getenv("CARTESIA_API_KEY"), 309 | voice_id="820a3788-2b37-4d21-847a-b65d8a68c99a", # Salesman 310 | ) 311 | llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") 312 | 313 | context = OpenAILLMContext() 314 | context_aggregator = llm.create_context_aggregator(context) 315 | 316 | # Create pipeline 317 | pipeline = Pipeline( 318 | [ 319 | transport.input(), 320 | stt, 321 | context_aggregator.user(), 322 | llm, 323 | tts, 324 | transport.output(), 325 | context_aggregator.assistant(), 326 | ] 327 | ) 328 | 329 | task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) 330 | 331 | # Initialize flow manager in static mode 332 | flow_manager = FlowManager( 333 | task=task, 334 | llm=llm, 335 | context_aggregator=context_aggregator, 336 | tts=tts, 337 | flow_config=flow_config, 338 | ) 339 | 340 | @transport.event_handler("on_first_participant_joined") 341 | async def on_first_participant_joined(transport, participant): 342 | await transport.capture_participant_transcription(participant["id"]) 343 | logger.debug("Initializing flow") 344 | await flow_manager.initialize() 345 | 346 | runner = PipelineRunner() 347 | await runner.run(task) 348 | 349 | 350 | if __name__ == "__main__": 351 | asyncio.run(main()) 352 | -------------------------------------------------------------------------------- /images/food-ordering-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/pipecat-flows/12bb9f5b356e2905ae4bdaf5b26cd2505cd3e2ff/images/food-ordering-flow.png -------------------------------------------------------------------------------- /pipecat-flows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/pipecat-flows/12bb9f5b356e2905ae4bdaf5b26cd2505cd3e2ff/pipecat-flows.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pipecat-ai-flows" 7 | version = "0.0.17" 8 | description = "Conversation Flow management for Pipecat AI applications" 9 | license = { text = "BSD 2-Clause License" } 10 | readme = "README.md" 11 | requires-python = ">=3.10" 12 | keywords = ["pipecat", "conversation", "flows", "state machine", "ai", "llm"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 18 | "Topic :: Communications :: Conferencing", 19 | "Topic :: Multimedia :: Sound/Audio", 20 | ] 21 | dependencies = [ 22 | "pipecat-ai>=0.0.67", 23 | "loguru~=0.7.2", 24 | ] 25 | 26 | [project.urls] 27 | Source = "https://github.com/pipecat-ai/pipecat-flows" 28 | Website = "https://www.pipecat.ai" 29 | 30 | [tool.pytest.ini_options] 31 | pythonpath = ["src"] 32 | testpaths = ["tests"] 33 | asyncio_mode = "auto" 34 | 35 | [tool.ruff] 36 | exclude = [".git", "*_pb2.py"] 37 | line-length = 100 38 | 39 | [tool.ruff.lint] 40 | select = [ 41 | "D", # Docstring rules 42 | "I", # Import rules 43 | ] 44 | 45 | [tool.ruff.pydocstyle] 46 | convention = "google" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | loguru~=0.7.2 -------------------------------------------------------------------------------- /src/pipecat_flows/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | """Pipecat Flows. 7 | 8 | This package provides a framework for building structured conversations in Pipecat. 9 | The FlowManager can handle both static and dynamic conversation flows: 10 | 11 | 1. Static Flows: 12 | - Configuration-driven conversations with predefined paths 13 | - Entire flow structure defined upfront 14 | - Example: 15 | from pipecat_flows import FlowArgs, FlowResult 16 | 17 | async def collect_name(args: FlowArgs) -> FlowResult: 18 | name = args["name"] 19 | return {"status": "success", "name": name} 20 | 21 | flow_config = { 22 | "initial_node": "greeting", 23 | "nodes": { 24 | "greeting": { 25 | "messages": [...], 26 | "functions": [{ 27 | "type": "function", 28 | "function": { 29 | "name": "collect_name", 30 | "handler": collect_name, 31 | "description": "...", 32 | "parameters": {...}, 33 | "transition_to": "next_step" 34 | } 35 | }] 36 | } 37 | } 38 | } 39 | flow_manager = FlowManager(task, llm, flow_config=flow_config) 40 | 41 | 2. Dynamic Flows: 42 | - Runtime-determined conversations 43 | - Nodes created or modified during execution 44 | - Example: 45 | from pipecat_flows import FlowArgs, FlowResult 46 | 47 | async def collect_age(args: FlowArgs) -> FlowResult: 48 | age = args["age"] 49 | return {"status": "success", "age": age} 50 | 51 | async def handle_transitions(function_name: str, args: Dict, flow_manager): 52 | if function_name == "collect_age": 53 | await flow_manager.set_node("next_step", create_next_node()) 54 | 55 | flow_manager = FlowManager(task, llm, transition_callback=handle_transitions) 56 | """ 57 | 58 | from .exceptions import ( 59 | ActionError, 60 | FlowError, 61 | FlowInitializationError, 62 | FlowTransitionError, 63 | InvalidFunctionError, 64 | ) 65 | from .manager import FlowManager 66 | from .types import ( 67 | ContextStrategy, 68 | ContextStrategyConfig, 69 | FlowArgs, 70 | FlowConfig, 71 | FlowFunctionHandler, 72 | FlowResult, 73 | FlowsFunctionSchema, 74 | LegacyFunctionHandler, 75 | NodeConfig, 76 | ) 77 | 78 | __all__ = [ 79 | # Flow Manager 80 | "FlowManager", 81 | # Types 82 | "ContextStrategy", 83 | "ContextStrategyConfig", 84 | "FlowArgs", 85 | "FlowConfig", 86 | "FlowFunctionHandler", 87 | "FlowResult", 88 | "FlowsFunctionSchema", 89 | "LegacyFunctionHandler", 90 | "NodeConfig", 91 | # Exceptions 92 | "FlowError", 93 | "FlowInitializationError", 94 | "FlowTransitionError", 95 | "InvalidFunctionError", 96 | "ActionError", 97 | ] 98 | -------------------------------------------------------------------------------- /src/pipecat_flows/actions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | """Action management system for conversation flows. 8 | 9 | This module provides the ActionManager class which handles execution of actions 10 | during conversation state transitions. It supports: 11 | - Built-in actions (TTS, conversation ending) 12 | - Custom action registration 13 | - Synchronous and asynchronous handlers 14 | - Pre and post-transition actions 15 | - Error handling and validation 16 | 17 | Actions are used to perform side effects during conversations, such as: 18 | - Text-to-speech output 19 | - Database updates 20 | - External API calls 21 | - Custom integrations 22 | """ 23 | 24 | import asyncio 25 | import inspect 26 | from dataclasses import dataclass 27 | from typing import Callable, Dict, List, Optional 28 | 29 | from loguru import logger 30 | from pipecat.frames.frames import ( 31 | BotStoppedSpeakingFrame, 32 | ControlFrame, 33 | EndFrame, 34 | TTSSpeakFrame, 35 | ) 36 | from pipecat.pipeline.task import PipelineTask 37 | 38 | from .exceptions import ActionError 39 | from .types import ActionConfig, FlowActionHandler 40 | 41 | 42 | @dataclass 43 | class FunctionActionFrame(ControlFrame): 44 | action: dict 45 | function: FlowActionHandler 46 | 47 | 48 | class ActionManager: 49 | """Manages the registration and execution of flow actions. 50 | 51 | Actions are executed during state transitions and can include: 52 | - Text-to-speech output 53 | - Database updates 54 | - External API calls 55 | - Custom user-defined actions 56 | 57 | Built-in actions: 58 | - tts_say: Speak text using TTS 59 | - end_conversation: End the current conversation 60 | 61 | Custom actions can be registered using register_action(). 62 | """ 63 | 64 | def __init__(self, task: PipelineTask, flow_manager: "FlowManager", tts=None): 65 | """Initialize the action manager. 66 | 67 | Args: 68 | task: PipelineTask instance used to queue frames 69 | flow_manager: FlowManager instance that this ActionManager is part of 70 | tts: Optional TTS service for voice actions 71 | """ 72 | self.action_handlers: Dict[str, Callable] = {} 73 | self.task = task 74 | self._flow_manager = flow_manager 75 | self.tts = tts 76 | self.function_finished_event = asyncio.Event() 77 | self._deferred_post_actions: List[ActionConfig] = [] 78 | 79 | # Register built-in actions 80 | self._register_action("tts_say", self._handle_tts_action) 81 | self._register_action("end_conversation", self._handle_end_action) 82 | self._register_action("function", self._handle_function_action) 83 | 84 | # Wire up function actions 85 | task.set_reached_downstream_filter((FunctionActionFrame, BotStoppedSpeakingFrame)) 86 | 87 | @task.event_handler("on_frame_reached_downstream") 88 | async def on_frame_reached_downstream(task, frame): 89 | if isinstance(frame, FunctionActionFrame): 90 | await frame.function(frame.action, flow_manager) 91 | self.function_finished_event.set() 92 | elif isinstance(frame, BotStoppedSpeakingFrame): 93 | await self._execute_deferred_post_actions() 94 | 95 | def _register_action(self, action_type: str, handler: Callable) -> None: 96 | """Register a handler for a specific action type. 97 | 98 | Args: 99 | action_type: String identifier for the action (e.g., "tts_say") 100 | handler: Async or sync function that handles the action 101 | 102 | Raises: 103 | ValueError: If handler is not callable 104 | """ 105 | if not callable(handler): 106 | raise ValueError("Action handler must be callable") 107 | self.action_handlers[action_type] = handler 108 | logger.debug(f"Registered handler for action type: {action_type}") 109 | 110 | async def execute_actions(self, actions: Optional[List[ActionConfig]]) -> None: 111 | """Execute a list of actions. 112 | 113 | Args: 114 | actions: List of action configurations to execute 115 | 116 | Raises: 117 | ActionError: If action execution fails 118 | 119 | Note: 120 | Each action must have a 'type' field matching a registered handler 121 | """ 122 | if not actions: 123 | return 124 | 125 | for action in actions: 126 | action_type = action.get("type") 127 | if not action_type: 128 | raise ActionError("Action missing required 'type' field") 129 | 130 | handler = self.action_handlers.get(action_type) 131 | if not handler: 132 | raise ActionError(f"No handler registered for action type: {action_type}") 133 | 134 | try: 135 | # Determine if handler can accept flow_manager argument by inspecting its signature 136 | # Handlers can either take (action) or (action, flow_manager) 137 | try: 138 | handler_positional_arg_count = handler.__code__.co_argcount 139 | if inspect.ismethod(handler) and handler_positional_arg_count > 0: 140 | # adjust for `self` being the first arg 141 | handler_positional_arg_count -= 1 142 | can_handle_flow_manager_arg = ( 143 | handler_positional_arg_count > 1 or handler.__code__.co_flags & 0x04 144 | ) 145 | except AttributeError: 146 | logger.warning( 147 | f"Unable to determine handler signature for action type '{action_type}', " 148 | "falling back to legacy single-parameter call" 149 | ) 150 | can_handle_flow_manager_arg = False 151 | 152 | # Invoke handler appropriately, with async and flow_manager arg as needed 153 | if can_handle_flow_manager_arg: 154 | if asyncio.iscoroutinefunction(handler): 155 | await handler(action, self._flow_manager) 156 | else: 157 | handler(action, self._flow_manager) 158 | else: 159 | if asyncio.iscoroutinefunction(handler): 160 | await handler(action) 161 | else: 162 | handler(action) 163 | logger.debug(f"Successfully executed action: {action_type}") 164 | except Exception as e: 165 | raise ActionError(f"Failed to execute action {action_type}: {str(e)}") from e 166 | 167 | def schedule_deferred_post_actions(self, post_actions: List[ActionConfig]) -> None: 168 | """Schedule "deferred" post-actions to be executed after next LLM completion. 169 | 170 | Args: 171 | post_actions: List of actions to execute 172 | """ 173 | self._deferred_post_actions = post_actions 174 | 175 | def clear_deferred_post_actions(self) -> None: 176 | """Clear any scheduled deferred post-actions.""" 177 | self._deferred_post_actions = [] 178 | 179 | async def _execute_deferred_post_actions(self) -> None: 180 | """Execute deferred post-actions.""" 181 | actions = self._deferred_post_actions 182 | self._deferred_post_actions = [] 183 | if actions: 184 | await self.execute_actions(actions) 185 | 186 | async def _handle_tts_action(self, action: dict) -> None: 187 | """Built-in handler for TTS actions. 188 | 189 | Args: 190 | action: Action configuration containing 'text' to speak 191 | """ 192 | if not self.tts: 193 | logger.warning("TTS action called but no TTS service provided") 194 | return 195 | 196 | text = action.get("text") 197 | if not text: 198 | logger.error("TTS action missing 'text' field") 199 | return 200 | 201 | try: 202 | await self.tts.say(text) 203 | # TODO: Update to TTSSpeakFrame once Pipecat is fixed 204 | # await self.task.queue_frame(TTSSpeakFrame(text=action["text"])) 205 | except Exception as e: 206 | logger.error(f"TTS error: {e}") 207 | 208 | async def _handle_end_action(self, action: dict) -> None: 209 | """Built-in handler for ending the conversation. 210 | 211 | This handler queues an EndFrame to terminate the conversation. If the action 212 | includes a 'text' key, it will queue that text to be spoken before ending. 213 | 214 | Args: 215 | action: Dictionary containing the action configuration. 216 | Optional 'text' key for a goodbye message. 217 | """ 218 | if action.get("text"): # Optional goodbye message 219 | await self.task.queue_frame(TTSSpeakFrame(text=action["text"])) 220 | await self.task.queue_frame(EndFrame()) 221 | 222 | async def _handle_function_action(self, action: dict) -> None: 223 | """Built-in handler for queuing functions to run "inline" in the pipeline (i.e. when the pipeline is done with all the work queued before it). 224 | 225 | This handler queues a FunctionFrame. 226 | It expects a 'handler' key in the action, containing the function to execute. 227 | 228 | Args: 229 | action: Dictionary containing the action configuration. 230 | Required 'handler' key containing the function to execute. 231 | """ 232 | handler = action.get("handler") 233 | if not handler: 234 | logger.error("Function action missing 'handler' field") 235 | return 236 | # the reason we're queueing a frame here is to ensure it happens after bot turn is over in 237 | # post_actions 238 | await self.task.queue_frame(FunctionActionFrame(action=action, function=handler)) 239 | await self.function_finished_event.wait() 240 | self.function_finished_event.clear() 241 | -------------------------------------------------------------------------------- /src/pipecat_flows/exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | """Custom exceptions for the conversation flow system. 8 | 9 | This module defines the exception hierarchy used throughout the flow system: 10 | - FlowError: Base exception for all flow-related errors 11 | - FlowInitializationError: Initialization failures 12 | - FlowTransitionError: State transition issues 13 | - InvalidFunctionError: Function registration/calling problems 14 | - ActionError: Action execution failures 15 | 16 | These exceptions provide specific error types for better error handling 17 | and debugging. 18 | """ 19 | 20 | 21 | class FlowError(Exception): 22 | """Base exception for all flow-related errors.""" 23 | 24 | pass 25 | 26 | 27 | class FlowInitializationError(FlowError): 28 | """Raised when flow initialization fails.""" 29 | 30 | pass 31 | 32 | 33 | class FlowTransitionError(FlowError): 34 | """Raised when a state transition fails.""" 35 | 36 | pass 37 | 38 | 39 | class InvalidFunctionError(FlowError): 40 | """Raised when an invalid or unavailable function is called.""" 41 | 42 | pass 43 | 44 | 45 | class ActionError(FlowError): 46 | """Raised when an action execution fails.""" 47 | 48 | pass 49 | -------------------------------------------------------------------------------- /src/pipecat_flows/types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | """Type definitions for the conversation flow system. 8 | 9 | This module defines the core types used throughout the flow system: 10 | - FlowResult: Function return type 11 | - FlowArgs: Function argument type 12 | - NodeConfig: Node configuration type 13 | - FlowConfig: Complete flow configuration type 14 | 15 | These types provide structure and validation for flow configurations 16 | and function interactions. 17 | """ 18 | 19 | from dataclasses import dataclass 20 | from enum import Enum 21 | from typing import Any, Awaitable, Callable, Dict, List, Optional, TypedDict, TypeVar, Union 22 | 23 | from pipecat.adapters.schemas.function_schema import FunctionSchema 24 | 25 | T = TypeVar("T") 26 | TransitionHandler = Callable[[Dict[str, T], "FlowManager"], Awaitable[None]] 27 | """Type for transition handler functions. 28 | 29 | Args: 30 | args: Dictionary of arguments from the function call 31 | flow_manager: Reference to the FlowManager instance 32 | 33 | Returns: 34 | None: Handlers are expected to update state and set next node 35 | """ 36 | 37 | 38 | class FlowResult(TypedDict, total=False): 39 | """Base type for function results. 40 | 41 | Example: 42 | { 43 | "status": "success", 44 | "data": {"processed": True}, 45 | "error": None # Optional error message 46 | } 47 | """ 48 | 49 | status: str 50 | error: str 51 | 52 | 53 | FlowArgs = Dict[str, Any] 54 | """Type alias for function handler arguments. 55 | 56 | Example: 57 | { 58 | "user_name": "John", 59 | "age": 25, 60 | "preferences": {"color": "blue"} 61 | } 62 | """ 63 | 64 | LegacyFunctionHandler = Callable[[FlowArgs], Awaitable[FlowResult]] 65 | """Legacy function handler that only receives arguments. 66 | 67 | Args: 68 | args: Dictionary of arguments from the function call 69 | 70 | Returns: 71 | FlowResult: Result of the function execution 72 | """ 73 | 74 | FlowFunctionHandler = Callable[[FlowArgs, "FlowManager"], Awaitable[FlowResult]] 75 | """Modern function handler that receives both arguments and flow_manager. 76 | 77 | Args: 78 | args: Dictionary of arguments from the function call 79 | flow_manager: Reference to the FlowManager instance 80 | 81 | Returns: 82 | FlowResult: Result of the function execution 83 | """ 84 | 85 | FunctionHandler = Union[LegacyFunctionHandler, FlowFunctionHandler] 86 | """Union type for function handlers supporting both legacy and modern patterns.""" 87 | 88 | 89 | LegacyActionHandler = Callable[[Dict[str, Any]], Awaitable[None]] 90 | """Legacy action handler type that only receives the action dictionary. 91 | 92 | Args: 93 | action: Dictionary containing action configuration and parameters 94 | 95 | Example: 96 | async def simple_handler(action: dict): 97 | await notify(action["text"]) 98 | """ 99 | 100 | FlowActionHandler = Callable[[Dict[str, Any], "FlowManager"], Awaitable[None]] 101 | """Modern action handler type that receives both action and flow_manager. 102 | 103 | Args: 104 | action: Dictionary containing action configuration and parameters 105 | flow_manager: Reference to the FlowManager instance 106 | 107 | Example: 108 | async def advanced_handler(action: dict, flow_manager: FlowManager): 109 | await flow_manager.transport.notify(action["text"]) 110 | """ 111 | 112 | 113 | class ActionConfigRequired(TypedDict): 114 | """Required fields for action configuration.""" 115 | 116 | type: str 117 | 118 | 119 | class ActionConfig(ActionConfigRequired, total=False): 120 | """Configuration for an action. 121 | 122 | Required: 123 | type: Action type identifier (e.g. "tts_say", "notify_slack") 124 | 125 | Optional: 126 | handler: Callable to handle the action 127 | text: Text for tts_say action 128 | Additional fields are allowed and passed to the handler 129 | """ 130 | 131 | handler: Union[LegacyActionHandler, FlowActionHandler] 132 | text: str 133 | 134 | 135 | class ContextStrategy(Enum): 136 | """Strategy for managing context during node transitions. 137 | 138 | Attributes: 139 | APPEND: Append new messages to existing context (default) 140 | RESET: Reset context with new messages only 141 | RESET_WITH_SUMMARY: Reset context but include an LLM-generated summary 142 | """ 143 | 144 | APPEND = "append" 145 | RESET = "reset" 146 | RESET_WITH_SUMMARY = "reset_with_summary" 147 | 148 | 149 | @dataclass 150 | class ContextStrategyConfig: 151 | """Configuration for context management. 152 | 153 | Attributes: 154 | strategy: Strategy to use for context management 155 | summary_prompt: Required prompt text when using RESET_WITH_SUMMARY 156 | """ 157 | 158 | strategy: ContextStrategy 159 | summary_prompt: Optional[str] = None 160 | 161 | def __post_init__(self): 162 | """Validate configuration.""" 163 | if self.strategy == ContextStrategy.RESET_WITH_SUMMARY and not self.summary_prompt: 164 | raise ValueError("summary_prompt is required when using RESET_WITH_SUMMARY strategy") 165 | 166 | 167 | @dataclass 168 | class FlowsFunctionSchema: 169 | """Function schema with Flows-specific properties. 170 | 171 | This class provides similar functionality to FunctionSchema with additional 172 | fields for Pipecat Flows integration. 173 | 174 | Attributes: 175 | name: Name of the function 176 | description: Description of the function 177 | properties: Dictionary defining properties types and descriptions 178 | required: List of required parameters 179 | handler: Function handler to process the function call 180 | transition_to: Target node to transition to after function execution 181 | transition_callback: Callback function for dynamic transitions 182 | """ 183 | 184 | name: str 185 | description: str 186 | properties: Dict[str, Any] 187 | required: List[str] 188 | handler: Optional[FunctionHandler] = None 189 | transition_to: Optional[str] = None 190 | transition_callback: Optional[Callable] = None 191 | 192 | def __post_init__(self): 193 | """Validate the schema configuration.""" 194 | if self.transition_to and self.transition_callback: 195 | raise ValueError("Cannot specify both transition_to and transition_callback") 196 | 197 | def to_function_schema(self) -> FunctionSchema: 198 | """Convert to a standard FunctionSchema for use with LLMs. 199 | 200 | Returns: 201 | FunctionSchema without flow-specific fields 202 | """ 203 | return FunctionSchema( 204 | name=self.name, 205 | description=self.description, 206 | properties=self.properties, 207 | required=self.required, 208 | ) 209 | 210 | 211 | class NodeConfigRequired(TypedDict): 212 | """Required fields for node configuration.""" 213 | 214 | task_messages: List[dict] 215 | 216 | 217 | class NodeConfig(NodeConfigRequired, total=False): 218 | """Configuration for a single node in the flow. 219 | 220 | Required fields: 221 | task_messages: List of message dicts defining the current node's objectives 222 | 223 | Optional fields: 224 | role_messages: List of message dicts defining the bot's role/personality 225 | functions: List of function definitions in provider-specific format, FunctionSchema, 226 | or FlowsFunctionSchema 227 | pre_actions: Actions to execute before LLM inference 228 | post_actions: Actions to execute after LLM inference 229 | context_strategy: Strategy for updating context during transitions 230 | respond_immediately: Whether to run LLM inference as soon as the node is set (default: True) 231 | 232 | Example: 233 | { 234 | "role_messages": [ 235 | { 236 | "role": "system", 237 | "content": "You are a helpful assistant..." 238 | } 239 | ], 240 | "task_messages": [ 241 | { 242 | "role": "system", 243 | "content": "Ask the user for their name..." 244 | } 245 | ], 246 | "functions": [...], 247 | "pre_actions": [...], 248 | "post_actions": [...], 249 | "context_strategy": ContextStrategyConfig(strategy=ContextStrategy.APPEND) 250 | } 251 | """ 252 | 253 | role_messages: List[Dict[str, Any]] 254 | functions: List[Union[Dict[str, Any], FlowsFunctionSchema]] 255 | pre_actions: List[ActionConfig] 256 | post_actions: List[ActionConfig] 257 | context_strategy: ContextStrategyConfig 258 | respond_immediately: bool 259 | 260 | 261 | class FlowConfig(TypedDict): 262 | """Configuration for the entire conversation flow. 263 | 264 | Attributes: 265 | initial_node: Name of the starting node 266 | nodes: Dictionary mapping node names to their configurations 267 | 268 | Example: 269 | { 270 | "initial_node": "greeting", 271 | "nodes": { 272 | "greeting": { 273 | "role_messages": [...], 274 | "task_messages": [...], 275 | "functions": [...], 276 | "pre_actions": [...] 277 | }, 278 | "process_order": { 279 | "task_messages": [...], 280 | "functions": [...], 281 | "post_actions": [...] 282 | } 283 | } 284 | } 285 | """ 286 | 287 | initial_node: str 288 | nodes: Dict[str, NodeConfig] 289 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest~=8.3.2 2 | pytest-asyncio~=0.23.5 3 | pytest-cov~=4.1.0 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for pipecat-flows.""" 2 | -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | """Test suite for ActionManager functionality. 8 | 9 | This module tests the ActionManager class which handles execution of actions 10 | during conversation flows. Tests cover: 11 | - Built-in actions (TTS, end conversation) 12 | - Custom action registration and execution 13 | - Error handling and validation 14 | - Action sequencing 15 | - TTS service integration 16 | - Frame queueing 17 | 18 | The tests use unittest.IsolatedAsyncioTestCase for async support and include 19 | mocked dependencies for PipelineTask and TTS service. 20 | """ 21 | 22 | import unittest 23 | from typing import Any 24 | from unittest.mock import AsyncMock, Mock, patch 25 | 26 | from pipecat.frames.frames import EndFrame, TTSSpeakFrame 27 | 28 | from pipecat_flows.actions import ActionManager 29 | from pipecat_flows.exceptions import ActionError 30 | 31 | 32 | class TestActionManager(unittest.IsolatedAsyncioTestCase): 33 | """Test suite for ActionManager class. 34 | 35 | Tests functionality of ActionManager including: 36 | - Built-in action handlers: 37 | - TTS speech synthesis 38 | - Conversation ending 39 | - Custom action registration 40 | - Action execution sequencing 41 | - Error handling: 42 | - Missing TTS service 43 | - Invalid actions 44 | - Failed handlers 45 | - Multiple action execution 46 | - Frame queueing validation 47 | 48 | Each test uses mocked dependencies to verify: 49 | - Correct frame generation 50 | - Proper service calls 51 | - Error handling behavior 52 | - Action sequencing 53 | """ 54 | 55 | def setUp(self): 56 | """Set up test fixtures before each test. 57 | 58 | Creates: 59 | - Mock PipelineTask for frame queueing 60 | - Mock TTS service for speech synthesis 61 | - ActionManager instance with mocked dependencies 62 | """ 63 | self.mock_task = AsyncMock() 64 | self.mock_task.queue_frame = AsyncMock() 65 | self.mock_task.event_handler = Mock() 66 | self.mock_task.set_reached_downstream_filter = Mock() 67 | 68 | self.mock_tts = AsyncMock() 69 | self.mock_tts.say = AsyncMock() 70 | 71 | self.mock_flow_manager = AsyncMock() 72 | 73 | self.action_manager = ActionManager(self.mock_task, self.mock_flow_manager, self.mock_tts) 74 | 75 | async def test_initialization(self): 76 | """Test ActionManager initialization and default handlers.""" 77 | # Verify built-in action handlers are registered 78 | self.assertIn("tts_say", self.action_manager.action_handlers) 79 | self.assertIn("end_conversation", self.action_manager.action_handlers) 80 | 81 | # Test initialization without TTS service 82 | action_manager_no_tts = ActionManager(self.mock_task, self.mock_flow_manager, None) 83 | self.assertIsNone(action_manager_no_tts.tts) 84 | 85 | async def test_tts_action(self): 86 | """Test basic TTS action execution.""" 87 | action = {"type": "tts_say", "text": "Hello"} 88 | await self.action_manager.execute_actions([action]) 89 | 90 | # Verify TTS service was called with correct text 91 | self.mock_tts.say.assert_called_once_with("Hello") 92 | 93 | @patch("loguru.logger.error") 94 | async def test_tts_action_no_text(self, mock_logger): 95 | """Test TTS action with missing text field.""" 96 | action = {"type": "tts_say"} # Missing text field 97 | 98 | # The implementation logs error but doesn't raise 99 | await self.action_manager.execute_actions([action]) 100 | 101 | # Verify error was logged 102 | mock_logger.assert_called_with("TTS action missing 'text' field") 103 | 104 | # Verify TTS service was not called 105 | self.mock_tts.say.assert_not_called() 106 | 107 | @patch("loguru.logger.warning") 108 | async def test_tts_action_no_service(self, mock_logger): 109 | """Test TTS action when no TTS service is provided.""" 110 | action_manager = ActionManager(self.mock_task, None) 111 | action = {"type": "tts_say", "text": "Hello"} 112 | 113 | # Should log warning but not raise error 114 | await action_manager.execute_actions([action]) 115 | 116 | # Verify warning was logged 117 | mock_logger.assert_called_with("TTS action called but no TTS service provided") 118 | 119 | # Verify no frames were queued 120 | self.mock_task.queue_frame.assert_not_called() 121 | 122 | async def test_end_conversation_action(self): 123 | """Test basic end conversation action.""" 124 | action = {"type": "end_conversation"} 125 | await self.action_manager.execute_actions([action]) 126 | 127 | # Verify EndFrame was queued 128 | self.mock_task.queue_frame.assert_called_once() 129 | frame = self.mock_task.queue_frame.call_args[0][0] 130 | self.assertIsInstance(frame, EndFrame) 131 | 132 | async def test_end_conversation_with_goodbye(self): 133 | """Test end conversation action with goodbye message.""" 134 | action = {"type": "end_conversation", "text": "Goodbye!"} 135 | await self.action_manager.execute_actions([action]) 136 | 137 | # Verify both frames were queued in correct order 138 | self.assertEqual(self.mock_task.queue_frame.call_count, 2) 139 | 140 | # Verify TTSSpeakFrame 141 | first_frame = self.mock_task.queue_frame.call_args_list[0][0][0] 142 | self.assertIsInstance(first_frame, TTSSpeakFrame) 143 | self.assertEqual(first_frame.text, "Goodbye!") 144 | 145 | # Verify EndFrame 146 | second_frame = self.mock_task.queue_frame.call_args_list[1][0][0] 147 | self.assertIsInstance(second_frame, EndFrame) 148 | 149 | async def test_action_handler_signatures(self): 150 | """Test both legacy and modern action handler signatures.""" 151 | 152 | # Test legacy single-parameter handler 153 | async def legacy_handler(action: dict): 154 | self.assertEqual(action["data"], "legacy") 155 | 156 | self.action_manager._register_action("legacy", legacy_handler) 157 | await self.action_manager.execute_actions([{"type": "legacy", "data": "legacy"}]) 158 | 159 | # Test modern two-parameter handler 160 | async def modern_handler(action: dict, flow_manager: Any): 161 | self.assertEqual(action["data"], "modern") 162 | self.assertEqual(flow_manager, self.mock_flow_manager) 163 | 164 | self.action_manager._register_action("modern", modern_handler) 165 | await self.action_manager.execute_actions([{"type": "modern", "data": "modern"}]) 166 | 167 | async def test_invalid_action(self): 168 | """Test handling invalid actions.""" 169 | # Test missing type 170 | with self.assertRaises(ActionError) as context: 171 | await self.action_manager.execute_actions([{}]) 172 | self.assertIn("missing required 'type' field", str(context.exception)) 173 | 174 | # Test unknown action type 175 | with self.assertRaises(ActionError) as context: 176 | await self.action_manager.execute_actions([{"type": "invalid"}]) 177 | self.assertIn("No handler registered", str(context.exception)) 178 | 179 | async def test_multiple_actions(self): 180 | """Test executing multiple actions in sequence.""" 181 | actions = [ 182 | {"type": "tts_say", "text": "First"}, 183 | {"type": "tts_say", "text": "Second"}, 184 | ] 185 | await self.action_manager.execute_actions(actions) 186 | 187 | # Verify TTS was called twice in correct order 188 | self.assertEqual(self.mock_tts.say.call_count, 2) 189 | expected_calls = [unittest.mock.call("First"), unittest.mock.call("Second")] 190 | self.assertEqual(self.mock_tts.say.call_args_list, expected_calls) 191 | 192 | def test_register_invalid_handler(self): 193 | """Test registering invalid action handlers.""" 194 | # Test non-callable handler 195 | with self.assertRaises(ValueError) as context: 196 | self.action_manager._register_action("invalid", "not_callable") 197 | self.assertIn("must be callable", str(context.exception)) 198 | 199 | # Test None handler 200 | with self.assertRaises(ValueError) as context: 201 | self.action_manager._register_action("invalid", None) 202 | self.assertIn("must be callable", str(context.exception)) 203 | 204 | async def test_none_or_empty_actions(self): 205 | """Test handling None or empty action lists.""" 206 | # Test None actions 207 | await self.action_manager.execute_actions(None) 208 | self.mock_task.queue_frame.assert_not_called() 209 | self.mock_tts.say.assert_not_called() 210 | 211 | # Test empty list 212 | await self.action_manager.execute_actions([]) 213 | self.mock_task.queue_frame.assert_not_called() 214 | self.mock_tts.say.assert_not_called() 215 | 216 | @patch("loguru.logger.error") 217 | async def test_action_error_handling(self, mock_logger): 218 | """Test error handling during action execution.""" 219 | # Configure TTS mock to raise an error 220 | self.mock_tts.say.side_effect = Exception("TTS error") 221 | 222 | action = {"type": "tts_say", "text": "Hello"} 223 | await self.action_manager.execute_actions([action]) 224 | 225 | # Verify error was logged 226 | mock_logger.assert_called_with("TTS error: TTS error") 227 | 228 | # Verify action was still marked as executed (doesn't raise) 229 | self.mock_tts.say.assert_called_once() 230 | 231 | async def test_action_execution_error_handling(self): 232 | """Test error handling during action execution.""" 233 | action_manager = ActionManager(self.mock_task, self.mock_tts) 234 | 235 | # Test action with missing handler 236 | with self.assertRaises(ActionError): 237 | await action_manager.execute_actions([{"type": "nonexistent_action"}]) 238 | 239 | # Test action handler that raises an exception 240 | async def failing_handler(action): 241 | raise Exception("Handler error") 242 | 243 | action_manager._register_action("failing_action", failing_handler) 244 | 245 | with self.assertRaises(ActionError): 246 | await action_manager.execute_actions([{"type": "failing_action"}]) 247 | -------------------------------------------------------------------------------- /tests/test_context_strategies.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024, Daily 3 | # 4 | # SPDX-License-Identifier: BSD 2-Clause License 5 | # 6 | 7 | """Test suite for context management strategies. 8 | 9 | This module contains tests for the context management features of Pipecat Flows, 10 | focusing on: 11 | - Context strategy configuration 12 | - Strategy behavior (APPEND, RESET, RESET_WITH_SUMMARY) 13 | - Provider-specific message formatting 14 | - Summary generation and integration 15 | """ 16 | 17 | import unittest 18 | from unittest.mock import AsyncMock, MagicMock, Mock, patch 19 | 20 | from pipecat.frames.frames import LLMMessagesAppendFrame, LLMMessagesUpdateFrame 21 | from pipecat.services.anthropic.llm import AnthropicLLMService 22 | from pipecat.services.google.llm import GoogleLLMService 23 | from pipecat.services.openai.llm import OpenAILLMService 24 | 25 | from pipecat_flows.exceptions import FlowError 26 | from pipecat_flows.manager import FlowManager 27 | from pipecat_flows.types import ContextStrategy, ContextStrategyConfig, NodeConfig 28 | 29 | 30 | class TestContextStrategies(unittest.IsolatedAsyncioTestCase): 31 | """Test suite for context management strategies. 32 | 33 | Tests functionality including: 34 | - Strategy configuration and validation 35 | - Strategy behavior and message handling 36 | - Provider-specific adaptations 37 | - Summary generation and integration 38 | """ 39 | 40 | async def asyncSetUp(self): 41 | """Set up test fixtures before each test.""" 42 | self.mock_task = AsyncMock() 43 | self.mock_task.event_handler = Mock() 44 | self.mock_task.set_reached_downstream_filter = Mock() 45 | 46 | # Set up mock LLM with client 47 | self.mock_llm = OpenAILLMService(api_key="") 48 | self.mock_llm._client = MagicMock() 49 | self.mock_llm._client.chat = MagicMock() 50 | self.mock_llm._client.chat.completions = MagicMock() 51 | self.mock_llm._client.chat.completions.create = AsyncMock() 52 | 53 | self.mock_tts = AsyncMock() 54 | 55 | # Create mock context aggregator with messages 56 | self.mock_context = MagicMock() 57 | self.mock_context.messages = [ 58 | {"role": "user", "content": "Hello"}, 59 | {"role": "assistant", "content": "Hi there"}, 60 | ] 61 | 62 | self.mock_context_aggregator = MagicMock() 63 | self.mock_context_aggregator.user = MagicMock() 64 | self.mock_context_aggregator.user.return_value = MagicMock() 65 | self.mock_context_aggregator.user.return_value._context = self.mock_context 66 | self.mock_context_aggregator.user.return_value.get_context_frame = MagicMock( 67 | return_value=MagicMock() 68 | ) 69 | 70 | # Sample node configuration 71 | self.sample_node: NodeConfig = { 72 | "task_messages": [{"role": "system", "content": "Test task."}], 73 | "functions": [], 74 | } 75 | 76 | async def test_context_strategy_config_validation(self): 77 | """Test ContextStrategyConfig validation.""" 78 | # Valid configurations 79 | ContextStrategyConfig(strategy=ContextStrategy.APPEND) 80 | ContextStrategyConfig(strategy=ContextStrategy.RESET) 81 | ContextStrategyConfig( 82 | strategy=ContextStrategy.RESET_WITH_SUMMARY, summary_prompt="Summarize the conversation" 83 | ) 84 | 85 | # Invalid configuration - missing prompt 86 | with self.assertRaises(ValueError): 87 | ContextStrategyConfig(strategy=ContextStrategy.RESET_WITH_SUMMARY) 88 | 89 | async def test_default_strategy(self): 90 | """Test default context strategy (APPEND).""" 91 | flow_manager = FlowManager( 92 | task=self.mock_task, 93 | llm=self.mock_llm, 94 | context_aggregator=self.mock_context_aggregator, 95 | ) 96 | await flow_manager.initialize() 97 | 98 | # First node should use UpdateFrame regardless of strategy 99 | await flow_manager.set_node("first", self.sample_node) 100 | first_call = self.mock_task.queue_frames.call_args_list[0] 101 | first_frames = first_call[0][0] 102 | self.assertTrue(any(isinstance(f, LLMMessagesUpdateFrame) for f in first_frames)) 103 | 104 | # Reset mock 105 | self.mock_task.queue_frames.reset_mock() 106 | 107 | # Subsequent node should use AppendFrame with default strategy 108 | await flow_manager.set_node("second", self.sample_node) 109 | second_call = self.mock_task.queue_frames.call_args_list[0] 110 | second_frames = second_call[0][0] 111 | self.assertTrue(any(isinstance(f, LLMMessagesAppendFrame) for f in second_frames)) 112 | 113 | async def test_reset_strategy(self): 114 | """Test RESET strategy behavior.""" 115 | flow_manager = FlowManager( 116 | task=self.mock_task, 117 | llm=self.mock_llm, 118 | context_aggregator=self.mock_context_aggregator, 119 | context_strategy=ContextStrategyConfig(strategy=ContextStrategy.RESET), 120 | ) 121 | await flow_manager.initialize() 122 | 123 | # Set initial node 124 | await flow_manager.set_node("first", self.sample_node) 125 | self.mock_task.queue_frames.reset_mock() 126 | 127 | # Second node should use UpdateFrame with RESET strategy 128 | await flow_manager.set_node("second", self.sample_node) 129 | second_call = self.mock_task.queue_frames.call_args_list[0] 130 | second_frames = second_call[0][0] 131 | self.assertTrue(any(isinstance(f, LLMMessagesUpdateFrame) for f in second_frames)) 132 | 133 | async def test_reset_with_summary_success(self): 134 | """Test successful RESET_WITH_SUMMARY strategy.""" 135 | # Mock successful summary generation 136 | mock_summary = "Conversation summary" 137 | self.mock_llm._client.chat.completions.create.return_value.choices = [ 138 | MagicMock(message=MagicMock(content=mock_summary)) 139 | ] 140 | 141 | flow_manager = FlowManager( 142 | task=self.mock_task, 143 | llm=self.mock_llm, 144 | context_aggregator=self.mock_context_aggregator, 145 | context_strategy=ContextStrategyConfig( 146 | strategy=ContextStrategy.RESET_WITH_SUMMARY, 147 | summary_prompt="Summarize the conversation", 148 | ), 149 | ) 150 | await flow_manager.initialize() 151 | 152 | # Set nodes and verify summary inclusion 153 | await flow_manager.set_node("first", self.sample_node) 154 | self.mock_task.queue_frames.reset_mock() 155 | 156 | await flow_manager.set_node("second", self.sample_node) 157 | 158 | # Verify summary was included in context update 159 | second_call = self.mock_task.queue_frames.call_args_list[0] 160 | second_frames = second_call[0][0] 161 | update_frame = next(f for f in second_frames if isinstance(f, LLMMessagesUpdateFrame)) 162 | self.assertTrue(any(mock_summary in str(m) for m in update_frame.messages)) 163 | 164 | async def test_reset_with_summary_timeout(self): 165 | """Test RESET_WITH_SUMMARY fallback on timeout.""" 166 | flow_manager = FlowManager( 167 | task=self.mock_task, 168 | llm=self.mock_llm, 169 | context_aggregator=self.mock_context_aggregator, 170 | context_strategy=ContextStrategyConfig( 171 | strategy=ContextStrategy.RESET_WITH_SUMMARY, 172 | summary_prompt="Summarize the conversation", 173 | ), 174 | ) 175 | await flow_manager.initialize() 176 | 177 | # Mock timeout 178 | self.mock_llm._client.chat.completions.create.side_effect = AsyncMock( 179 | side_effect=TimeoutError 180 | ) 181 | 182 | # Set nodes and verify fallback to RESET 183 | await flow_manager.set_node("first", self.sample_node) 184 | self.mock_task.queue_frames.reset_mock() 185 | 186 | await flow_manager.set_node("second", self.sample_node) 187 | 188 | # Verify UpdateFrame was used (RESET behavior) 189 | second_call = self.mock_task.queue_frames.call_args_list[0] 190 | second_frames = second_call[0][0] 191 | self.assertTrue(any(isinstance(f, LLMMessagesUpdateFrame) for f in second_frames)) 192 | 193 | async def test_provider_specific_summary_formatting(self): 194 | """Test summary formatting for different LLM providers.""" 195 | summary = "Test summary" 196 | 197 | # Test OpenAI format 198 | flow_manager = FlowManager( 199 | task=self.mock_task, 200 | llm=OpenAILLMService(api_key=""), 201 | context_aggregator=self.mock_context_aggregator, 202 | ) 203 | openai_message = flow_manager.adapter.format_summary_message(summary) 204 | self.assertEqual(openai_message["role"], "system") 205 | 206 | # Test Anthropic format 207 | flow_manager = FlowManager( 208 | task=self.mock_task, 209 | llm=AnthropicLLMService(api_key=""), 210 | context_aggregator=self.mock_context_aggregator, 211 | ) 212 | anthropic_message = flow_manager.adapter.format_summary_message(summary) 213 | self.assertEqual(anthropic_message["role"], "user") 214 | 215 | # Test Gemini format 216 | flow_manager = FlowManager( 217 | task=self.mock_task, 218 | llm=GoogleLLMService(api_key=""), 219 | context_aggregator=self.mock_context_aggregator, 220 | ) 221 | gemini_message = flow_manager.adapter.format_summary_message(summary) 222 | self.assertEqual(gemini_message["role"], "user") 223 | 224 | async def test_node_level_strategy_override(self): 225 | """Test that node-level strategy overrides global strategy.""" 226 | flow_manager = FlowManager( 227 | task=self.mock_task, 228 | llm=self.mock_llm, 229 | context_aggregator=self.mock_context_aggregator, 230 | context_strategy=ContextStrategyConfig(strategy=ContextStrategy.APPEND), 231 | ) 232 | await flow_manager.initialize() 233 | 234 | # Create node with RESET strategy 235 | node_with_strategy = { 236 | **self.sample_node, 237 | "context_strategy": ContextStrategyConfig(strategy=ContextStrategy.RESET), 238 | } 239 | 240 | # Set nodes and verify strategy override 241 | await flow_manager.set_node("first", self.sample_node) 242 | self.mock_task.queue_frames.reset_mock() 243 | 244 | await flow_manager.set_node("second", node_with_strategy) 245 | 246 | # Verify UpdateFrame was used (RESET behavior) despite global APPEND 247 | second_call = self.mock_task.queue_frames.call_args_list[0] 248 | second_frames = second_call[0][0] 249 | self.assertTrue(any(isinstance(f, LLMMessagesUpdateFrame) for f in second_frames)) 250 | 251 | async def test_summary_generation_content(self): 252 | """Test that summary generation uses correct prompt and context.""" 253 | mock_summary = "Generated summary" 254 | self.mock_llm._client.chat.completions.create.return_value.choices = [ 255 | MagicMock(message=MagicMock(content=mock_summary)) 256 | ] 257 | 258 | summary_prompt = "Create a detailed summary" 259 | flow_manager = FlowManager( 260 | task=self.mock_task, 261 | llm=self.mock_llm, 262 | context_aggregator=self.mock_context_aggregator, 263 | context_strategy=ContextStrategyConfig( 264 | strategy=ContextStrategy.RESET_WITH_SUMMARY, summary_prompt=summary_prompt 265 | ), 266 | ) 267 | await flow_manager.initialize() 268 | 269 | # Set nodes to trigger summary generation 270 | await flow_manager.set_node("first", self.sample_node) 271 | await flow_manager.set_node("second", self.sample_node) 272 | 273 | # Verify summary generation call 274 | create_call = self.mock_llm._client.chat.completions.create.call_args 275 | create_kwargs = create_call[1] 276 | 277 | # Verify prompt and context were included 278 | messages = create_kwargs["messages"] 279 | self.assertTrue(any(summary_prompt in str(m) for m in messages)) 280 | self.assertTrue( 281 | any(str(self.mock_context.messages[0]["content"]) in str(m) for m in messages) 282 | ) 283 | 284 | async def test_context_structure_after_summary(self): 285 | """Test the structure of context after summary generation.""" 286 | mock_summary = "Generated summary" 287 | self.mock_llm._client.chat.completions.create.return_value.choices = [ 288 | MagicMock(message=MagicMock(content=mock_summary)) 289 | ] 290 | 291 | flow_manager = FlowManager( 292 | task=self.mock_task, 293 | llm=self.mock_llm, 294 | context_aggregator=self.mock_context_aggregator, 295 | context_strategy=ContextStrategyConfig( 296 | strategy=ContextStrategy.RESET_WITH_SUMMARY, summary_prompt="Summarize" 297 | ), 298 | ) 299 | await flow_manager.initialize() 300 | 301 | # Set nodes to trigger summary generation 302 | await flow_manager.set_node("first", self.sample_node) 303 | self.mock_task.queue_frames.reset_mock() 304 | 305 | # Node with new task messages 306 | new_node = { 307 | "task_messages": [{"role": "system", "content": "New task."}], 308 | "functions": [], 309 | } 310 | await flow_manager.set_node("second", new_node) 311 | 312 | # Verify context structure 313 | update_call = self.mock_task.queue_frames.call_args_list[0] 314 | update_frames = update_call[0][0] 315 | messages_frame = next(f for f in update_frames if isinstance(f, LLMMessagesUpdateFrame)) 316 | 317 | # Verify order: summary message, then new task messages 318 | self.assertTrue(mock_summary in str(messages_frame.messages[0])) 319 | self.assertEqual( 320 | messages_frame.messages[1]["content"], new_node["task_messages"][0]["content"] 321 | ) 322 | --------------------------------------------------------------------------------