dv-pipecat-flows 0.0.0.dev2087__tar.gz → 0.0.0.dev2098__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dv_pipecat_flows-0.0.0.dev2087/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.0.dev2098}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -1
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/adapters.py +4 -5
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/manager.py +165 -93
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/speak_interruption_guard.py +8 -8
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/router_mode.py +9 -85
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/types.py +4 -0
- dv_pipecat_flows-0.0.0.dev2087/src/pipecat_flows/data_extractor.py +0 -249
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.gitattributes +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.gitignore +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.pre-commit-config.yaml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.python-version +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.readthedocs.yaml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/AGENTS.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CHANGELOG.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CLAUDE.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CONTRIBUTING.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/LICENSE +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/MANIFEST.in +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/README.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/dev-requirements.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/docker-compose.dev.yml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/.eslintrc.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/.prettierrc +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/css/tailwind.css +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/food_ordering.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/movie_explorer.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/patient_intake.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/restaurant_reservation.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/travel_planner.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/index.html +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/canvas.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/editorState.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/sidePanel.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/toolbar.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/main.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/baseNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/endNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/flowNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/functionNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/index.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/mergeNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/startNode.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/types.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/export.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/helpers.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/import.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/validation.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/jsdoc.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/package-lock.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/package.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/postcss.config.cjs +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/tailwind.config.cjs +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/vercel.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/vite.config.js +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/engine_primitives_plan.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/env.example +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/.gitignore +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/README.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/RESULTS.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/chunkers.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/contracts.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_corpus.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_dataset.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_hybrid.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/templates/insurance.yaml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/.gitignore +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_hybrid/manifest.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval/manifest.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_synthetic/manifest.json +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_pick.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_scaling.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_hybrid.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_latency.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_rerank.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_retrieval.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/API.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/__init__.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/app.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/jobs.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/images/food-ordering-flow.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pipecat-flows.png +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pipecat_upgrade.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pyproject.toml +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/README.md +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/extensions.conf +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/rtp.conf +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/requirements.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/check-pypi-package.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/fix-ruff.sh +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/pre-commit.sh +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/setup.cfg +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/__init__.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/actions.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/condition_evaluator.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/exceptions.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/flow_validator.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/__init__.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
- {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/uv.lock +0 -0
|
@@ -101,7 +101,6 @@ src/pipecat_flows/__init__.py
|
|
|
101
101
|
src/pipecat_flows/actions.py
|
|
102
102
|
src/pipecat_flows/adapters.py
|
|
103
103
|
src/pipecat_flows/condition_evaluator.py
|
|
104
|
-
src/pipecat_flows/data_extractor.py
|
|
105
104
|
src/pipecat_flows/exceptions.py
|
|
106
105
|
src/pipecat_flows/flow_validator.py
|
|
107
106
|
src/pipecat_flows/manager.py
|
{dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/adapters.py
RENAMED
|
@@ -176,9 +176,9 @@ class LLMAdapter:
|
|
|
176
176
|
|
|
177
177
|
Args:
|
|
178
178
|
llm_service: The underlying LLM service (exposes ``_client`` +
|
|
179
|
-
``_model
|
|
180
|
-
system_prompt: System message text (
|
|
181
|
-
|
|
179
|
+
``_model``).
|
|
180
|
+
system_prompt: System message text (authored by the backend
|
|
181
|
+
lowerer and shipped via ``task_messages[0].content``).
|
|
182
182
|
response_schema: JSON Schema constraining the output (built by
|
|
183
183
|
:func:`build_router_response_schema`).
|
|
184
184
|
context_messages: Recent conversation turns to include as user
|
|
@@ -374,8 +374,7 @@ class OpenAIAdapter(LLMAdapter):
|
|
|
374
374
|
"""OpenAI / Azure constrained completion via ``response_format``
|
|
375
375
|
``json_schema``. Returns the raw JSON string emitted by the model.
|
|
376
376
|
|
|
377
|
-
|
|
378
|
-
accesses ``llm_service._client`` and ``llm_service._model``, makes a
|
|
377
|
+
Accesses ``llm_service._client`` and ``llm_service._model``, makes a
|
|
379
378
|
one-shot ``chat.completions.create`` outside the streaming pipeline.
|
|
380
379
|
|
|
381
380
|
Uses ``strict: True`` so OpenAI enforces the schema server-side
|
{dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/manager.py
RENAMED
|
@@ -68,7 +68,6 @@ from pipecat.transports.base_transport import BaseTransport
|
|
|
68
68
|
from pipecat_flows.actions import ActionError, ActionManager
|
|
69
69
|
from pipecat_flows.adapters import create_adapter
|
|
70
70
|
from pipecat_flows.condition_evaluator import evaluate_conditions
|
|
71
|
-
from pipecat_flows.data_extractor import DataExtractor
|
|
72
71
|
from pipecat_flows.exceptions import (
|
|
73
72
|
AutoTransitionDepthError,
|
|
74
73
|
FlowError,
|
|
@@ -79,12 +78,12 @@ from pipecat_flows.exceptions import (
|
|
|
79
78
|
RouterProviderError,
|
|
80
79
|
)
|
|
81
80
|
from pipecat_flows.router_mode import (
|
|
81
|
+
BACK_BRANCH_ID,
|
|
82
82
|
REPROMPT_IN_PLACE,
|
|
83
83
|
UNCLEAR_BRANCH_ID,
|
|
84
84
|
build_router_response_schema,
|
|
85
85
|
parse_router_response,
|
|
86
86
|
pick_branch_target,
|
|
87
|
-
render_router_system_prompt,
|
|
88
87
|
)
|
|
89
88
|
from pipecat_flows.types import (
|
|
90
89
|
ActionConfig,
|
|
@@ -175,8 +174,12 @@ class FlowManager:
|
|
|
175
174
|
self._llm = llm
|
|
176
175
|
self._action_manager = ActionManager(task, flow_manager=self)
|
|
177
176
|
self._adapter = create_adapter(llm, context_aggregator)
|
|
178
|
-
self._data_extractor = DataExtractor()
|
|
179
177
|
self._initialized = False
|
|
178
|
+
# Stashed by _run_router_node right after the LLM picks a branch and
|
|
179
|
+
# read by _set_node when it tool-logs the transition. Lets the router
|
|
180
|
+
# rationale + prompt_sha1 land in the transition tool-log args. Cleared
|
|
181
|
+
# by _set_node after read so it can't leak into an unrelated next hop.
|
|
182
|
+
self._last_router_pick: Optional[Dict[str, Any]] = None
|
|
180
183
|
self._context_aggregator = context_aggregator
|
|
181
184
|
self._pending_transition: Optional[Dict[str, Any]] = None
|
|
182
185
|
self._pending_function_results: Dict[str, Dict[str, Any]] = {} # Store function results for extraction
|
|
@@ -216,6 +219,12 @@ class FlowManager:
|
|
|
216
219
|
# backwards-compatible if a downstream installs an older engine.
|
|
217
220
|
self._user_turn_observer: Optional[Any] = None
|
|
218
221
|
|
|
222
|
+
self._current_node_entered_ts: float = 0.0
|
|
223
|
+
self._previous_node_entered_ts: float = 0.0
|
|
224
|
+
|
|
225
|
+
self._current_llm_router_name: Optional[str] = None
|
|
226
|
+
self._previous_llm_router_name: Optional[str] = None
|
|
227
|
+
|
|
219
228
|
# Set up static or dynamic mode
|
|
220
229
|
if flow_config:
|
|
221
230
|
warnings.warn(
|
|
@@ -912,69 +921,6 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
912
921
|
)
|
|
913
922
|
await self._set_node(node_id, node_config)
|
|
914
923
|
|
|
915
|
-
async def _process_data_extraction(self, node_id: str, node_config: NodeConfig) -> None:
|
|
916
|
-
"""Process data extraction for the current node.
|
|
917
|
-
|
|
918
|
-
Args:
|
|
919
|
-
node_id: The current node ID
|
|
920
|
-
node_config: The node configuration containing extraction settings
|
|
921
|
-
"""
|
|
922
|
-
# Get extraction config from runtime config or node config
|
|
923
|
-
runtime_config = self.state.get("nodes_runtime_config", {})
|
|
924
|
-
node_runtime = runtime_config.get(node_id, {})
|
|
925
|
-
extraction_config = node_runtime.get("data_extraction", [])
|
|
926
|
-
|
|
927
|
-
# Also check node_config directly if not in runtime
|
|
928
|
-
if not extraction_config and "data_extraction" in node_config:
|
|
929
|
-
extraction_config = node_config["data_extraction"]
|
|
930
|
-
# Convert to dict format if needed
|
|
931
|
-
if extraction_config and hasattr(extraction_config[0], "model_dump"):
|
|
932
|
-
extraction_config = [field.model_dump() for field in extraction_config]
|
|
933
|
-
|
|
934
|
-
if not extraction_config:
|
|
935
|
-
return
|
|
936
|
-
|
|
937
|
-
logger.debug(f"Processing data extraction for node {node_id}: {len(extraction_config)} fields")
|
|
938
|
-
|
|
939
|
-
try:
|
|
940
|
-
# Get conversation context for conversation-type extractions
|
|
941
|
-
conversation_context = None
|
|
942
|
-
has_conversation_fields = any(
|
|
943
|
-
f.get("extraction_type") == "conversation" for f in extraction_config
|
|
944
|
-
)
|
|
945
|
-
if has_conversation_fields:
|
|
946
|
-
try:
|
|
947
|
-
conversation_context = self.get_current_context()
|
|
948
|
-
except Exception as e:
|
|
949
|
-
logger.warning(f"Could not get conversation context for extraction: {e}")
|
|
950
|
-
|
|
951
|
-
# Process all extractions
|
|
952
|
-
extracted_data = await self._data_extractor.process_node_extractions(
|
|
953
|
-
node_name=node_id,
|
|
954
|
-
extraction_config=extraction_config,
|
|
955
|
-
function_results=self._pending_function_results.copy() if self._pending_function_results else None,
|
|
956
|
-
conversation_context=conversation_context,
|
|
957
|
-
llm_service=self._llm if has_conversation_fields else None,
|
|
958
|
-
)
|
|
959
|
-
|
|
960
|
-
# Store extracted data in state
|
|
961
|
-
if extracted_data:
|
|
962
|
-
if "extracted_data" not in self.state:
|
|
963
|
-
self.state["extracted_data"] = {}
|
|
964
|
-
if node_id not in self.state["extracted_data"]:
|
|
965
|
-
self.state["extracted_data"][node_id] = {}
|
|
966
|
-
|
|
967
|
-
self.state["extracted_data"][node_id].update(extracted_data)
|
|
968
|
-
logger.info(f"Stored {len(extracted_data)} extracted fields for node {node_id}")
|
|
969
|
-
|
|
970
|
-
# Clear pending function results after extraction
|
|
971
|
-
self._pending_function_results.clear()
|
|
972
|
-
|
|
973
|
-
except Exception as e:
|
|
974
|
-
logger.error(f"Error during data extraction for node {node_id}: {e}")
|
|
975
|
-
# Clear pending results even on error
|
|
976
|
-
self._pending_function_results.clear()
|
|
977
|
-
|
|
978
924
|
# ------------------------------------------------------------------
|
|
979
925
|
# Pure-code dispatch primitives (auto_transition + condition_evaluator).
|
|
980
926
|
# Used by Speak / Action / Condition-Router to skip the LLM round-trip.
|
|
@@ -1028,6 +974,24 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1028
974
|
|
|
1029
975
|
# Case 1: bare string (Speak / single-target Action).
|
|
1030
976
|
if isinstance(auto_transition, str):
|
|
977
|
+
if node_config.get("interruptible", False):
|
|
978
|
+
observer = self._user_turn_observer
|
|
979
|
+
user_barged_in = (
|
|
980
|
+
observer is not None
|
|
981
|
+
and observer.last_user_stopped_ts
|
|
982
|
+
>= self._current_node_entered_ts
|
|
983
|
+
)
|
|
984
|
+
if user_barged_in and self._current_llm_router_name:
|
|
985
|
+
next_cfg = self._nodes.get(auto_transition) or {}
|
|
986
|
+
if next_cfg.get("llm_mode") != "router":
|
|
987
|
+
logger.info(
|
|
988
|
+
f"Interruptible Speak '{node_config.get('name')}': "
|
|
989
|
+
f"caller barged in; natural-next "
|
|
990
|
+
f"'{auto_transition}' is not a Router; routing "
|
|
991
|
+
f"to most-recent Router "
|
|
992
|
+
f"'{self._current_llm_router_name}' instead"
|
|
993
|
+
)
|
|
994
|
+
return self._current_llm_router_name
|
|
1031
995
|
return auto_transition
|
|
1032
996
|
|
|
1033
997
|
return None
|
|
@@ -1062,7 +1026,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1062
1026
|
# and drops the OpenAILLMContextFrame upstream of the main LLM.
|
|
1063
1027
|
current_cfg = self._nodes.get(self._current_node) or {}
|
|
1064
1028
|
needs_deferred_flush = False
|
|
1065
|
-
if current_cfg.get("
|
|
1029
|
+
if not current_cfg.get("interruptible", False):
|
|
1066
1030
|
needs_deferred_flush = await self._exit_speak_guard(
|
|
1067
1031
|
current_cfg, target
|
|
1068
1032
|
)
|
|
@@ -1091,15 +1055,15 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1091
1055
|
node_config = self._nodes.get(self._current_node) or {}
|
|
1092
1056
|
return bool(node_config.get("suppress_llm_text"))
|
|
1093
1057
|
|
|
1094
|
-
def
|
|
1095
|
-
"""True when the current node has ``
|
|
1096
|
-
:class:`pipecat_flows.processors.SpeakInterruptionGuard`
|
|
1097
|
-
interruption cascade and the
|
|
1058
|
+
def is_speak_interruptible(self) -> bool:
|
|
1059
|
+
"""True when the current node has ``interruptible`` set, so
|
|
1060
|
+
:class:`pipecat_flows.processors.SpeakInterruptionGuard` lets the
|
|
1061
|
+
interruption cascade through and the caller can barge in.
|
|
1098
1062
|
"""
|
|
1099
1063
|
if not getattr(self, "_current_node", None):
|
|
1100
1064
|
return False
|
|
1101
1065
|
node_config = self._nodes.get(self._current_node) or {}
|
|
1102
|
-
return bool(node_config.get("
|
|
1066
|
+
return bool(node_config.get("interruptible", False))
|
|
1103
1067
|
|
|
1104
1068
|
async def _enter_speak_guard(self, node_config: Dict[str, Any]) -> None:
|
|
1105
1069
|
"""Reset the user aggregator's recency timers so the
|
|
@@ -1144,11 +1108,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1144
1108
|
if not self._context_aggregator or target_node_config is None:
|
|
1145
1109
|
return False
|
|
1146
1110
|
target_kind = target_node_config.get("type")
|
|
1147
|
-
if target_kind in ("
|
|
1111
|
+
if target_kind in ("speak_node", "action_node"):
|
|
1148
1112
|
return False
|
|
1149
1113
|
is_terminal = bool(target_node_config.get("terminal")) or target_kind in (
|
|
1150
1114
|
"end_node",
|
|
1151
|
-
"
|
|
1115
|
+
"transfer_node",
|
|
1152
1116
|
)
|
|
1153
1117
|
is_cond_router = target_node_config.get("auto_transition") == "__condition__"
|
|
1154
1118
|
user_agg = self._context_aggregator.user()
|
|
@@ -1202,8 +1166,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1202
1166
|
# `_run_router_node` captures a fresh entry_ts — the next iteration
|
|
1203
1167
|
# correctly waits for a NEW user reply instead of reclassifying the
|
|
1204
1168
|
# stale one that triggered the previous UNCLEAR.
|
|
1205
|
-
timeout_s = float(node_config.get("router_wait_for_user_timeout", 15.0))
|
|
1206
1169
|
observer = self._user_turn_observer
|
|
1170
|
+
|
|
1171
|
+
timeout_s = float(node_config.get("router_wait_for_user_timeout", 15.0))
|
|
1207
1172
|
if observer is not None and timeout_s > 0:
|
|
1208
1173
|
entry_ts = time.monotonic()
|
|
1209
1174
|
if observer.last_user_stopped_ts >= entry_ts:
|
|
@@ -1250,24 +1215,58 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1250
1215
|
|
|
1251
1216
|
branches = node_config.get("router_branches") or []
|
|
1252
1217
|
extract = node_config.get("router_extract") or {}
|
|
1253
|
-
branch_descriptions = node_config.get("router_branch_descriptions") or {}
|
|
1254
1218
|
|
|
1255
|
-
#
|
|
1256
|
-
#
|
|
1257
|
-
#
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1219
|
+
# Backend lowerer is the single source of truth for the router prompt.
|
|
1220
|
+
# It writes the rendered prompt into task_messages[0].content at publish
|
|
1221
|
+
# time; we read it verbatim here. No runtime re-rendering — keeps prompt
|
|
1222
|
+
# authoring in the backend repo and avoids drift between lowering-time
|
|
1223
|
+
# and runtime prompts.
|
|
1224
|
+
tm = node_config.get("task_messages") or []
|
|
1225
|
+
system_prompt = (
|
|
1226
|
+
tm[0].get("content", "") if tm and isinstance(tm[0], dict) else ""
|
|
1262
1227
|
)
|
|
1228
|
+
if not system_prompt:
|
|
1229
|
+
raise RouterParseError(
|
|
1230
|
+
f"router node '{node_id}' has no rendered system prompt in "
|
|
1231
|
+
f"task_messages[0].content — re-publish the agent so the "
|
|
1232
|
+
f"lowerer fills it"
|
|
1233
|
+
)
|
|
1263
1234
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1235
|
+
back_routing_enabled = (
|
|
1236
|
+
self._previous_llm_router_name is not None
|
|
1237
|
+
and self._previous_llm_router_name != node_id
|
|
1267
1238
|
)
|
|
1268
|
-
|
|
1269
|
-
|
|
1239
|
+
if back_routing_enabled:
|
|
1240
|
+
prev_cfg = self._nodes.get(self._previous_llm_router_name) or {}
|
|
1241
|
+
prev_descriptions = prev_cfg.get("router_branch_descriptions") or {}
|
|
1242
|
+
prev_desc_block = "\n".join(
|
|
1243
|
+
f" - {bid}: {desc}" for bid, desc in prev_descriptions.items()
|
|
1244
|
+
) or " (previous router branches unavailable)"
|
|
1245
|
+
branches = list(branches) + [
|
|
1246
|
+
{"id": BACK_BRANCH_ID, "next": self._previous_llm_router_name}
|
|
1247
|
+
]
|
|
1248
|
+
system_prompt = (
|
|
1249
|
+
system_prompt
|
|
1250
|
+
+ "\n\n## Re-routing to previous Router\n"
|
|
1251
|
+
+ f"In addition to your branches above, you may pick "
|
|
1252
|
+
+ f"`{BACK_BRANCH_ID}` if the caller's reply is clearly NOT "
|
|
1253
|
+
+ f"answering this Router's question and instead contradicts "
|
|
1254
|
+
+ f"or changes the prior intent. The previous Router "
|
|
1255
|
+
+ f"(`{self._previous_llm_router_name}`) can re-classify into:\n"
|
|
1256
|
+
+ prev_desc_block
|
|
1257
|
+
+ "\n\nOnly pick this when the caller's reply belongs at the "
|
|
1258
|
+
+ "previous decision level — otherwise prefer your own branches."
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
# Log the FULL router system prompt actually being used at runtime, so we
|
|
1262
|
+
# can confirm what the classifier sees (branches/extract/reasoning) rather
|
|
1263
|
+
# than just the sha1 hash logged after the pick below.
|
|
1264
|
+
logger.info(
|
|
1265
|
+
f"Router '{node_id}' system prompt ({len(system_prompt)} chars):\n"
|
|
1266
|
+
f"{system_prompt}"
|
|
1270
1267
|
)
|
|
1268
|
+
|
|
1269
|
+
schema = build_router_response_schema(branches, extract)
|
|
1271
1270
|
valid_branch_ids = [b["id"] for b in branches if isinstance(b, dict) and b.get("id")]
|
|
1272
1271
|
valid_branch_ids.append(UNCLEAR_BRANCH_ID)
|
|
1273
1272
|
|
|
@@ -1340,6 +1339,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1340
1339
|
f"Router '{node_id}' picked branch_id='{branch_id}' "
|
|
1341
1340
|
f"rationale={rationale!r} prompt_sha1={prompt_hash}"
|
|
1342
1341
|
)
|
|
1342
|
+
# Hand off to the next _set_node so it can surface this in the
|
|
1343
|
+
# transition tool-log args. Cleared by _set_node after read.
|
|
1344
|
+
self._last_router_pick = {
|
|
1345
|
+
"branch_id": branch_id,
|
|
1346
|
+
"rationale": rationale,
|
|
1347
|
+
"prompt_sha1": prompt_hash,
|
|
1348
|
+
}
|
|
1343
1349
|
|
|
1344
1350
|
# Write extracted variables into shared state so downstream nodes
|
|
1345
1351
|
# (Condition routers, Speak template substitutions) can read them.
|
|
@@ -1404,6 +1410,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1404
1410
|
f"router_unclear observer failed for {node_id}: {observer_exc!r}"
|
|
1405
1411
|
)
|
|
1406
1412
|
|
|
1413
|
+
if back_routing_enabled and branch_id == BACK_BRANCH_ID:
|
|
1414
|
+
back_msg = node_config.get("router_back_transition_message") or ""
|
|
1415
|
+
if back_msg:
|
|
1416
|
+
await self._task.queue_frame(TTSSpeakFrame(text=back_msg))
|
|
1417
|
+
logger.info(
|
|
1418
|
+
f"Router '{node_id}' picked back-branch -> "
|
|
1419
|
+
f"{self._previous_llm_router_name} (msg={back_msg!r})"
|
|
1420
|
+
)
|
|
1421
|
+
return self._previous_llm_router_name
|
|
1422
|
+
|
|
1407
1423
|
target = pick_branch_target(
|
|
1408
1424
|
branch_id=branch_id,
|
|
1409
1425
|
node_config=node_config,
|
|
@@ -1595,6 +1611,39 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1595
1611
|
if not self._initialized:
|
|
1596
1612
|
raise FlowTransitionError(f"{self.__class__.__name__} must be initialized first")
|
|
1597
1613
|
|
|
1614
|
+
self._previous_node_entered_ts = self._current_node_entered_ts
|
|
1615
|
+
self._current_node_entered_ts = time.monotonic()
|
|
1616
|
+
|
|
1617
|
+
if node_config.get("llm_mode") == "router":
|
|
1618
|
+
self._previous_llm_router_name = self._current_llm_router_name
|
|
1619
|
+
self._current_llm_router_name = node_id
|
|
1620
|
+
|
|
1621
|
+
# Tool-log every transition so operators see the graph traversal on
|
|
1622
|
+
# the same timeline as on-call API tool calls. Capture state BEFORE
|
|
1623
|
+
# any of the node-setup logic runs so previous_node_id reflects the
|
|
1624
|
+
# actual outgoing node (legacy path resets _current_node only at L1796
|
|
1625
|
+
# of the success arm; v2 paths early-latch at L1546).
|
|
1626
|
+
transition_start_time = time.time()
|
|
1627
|
+
previous_node_id = self._current_node
|
|
1628
|
+
router_pick = self._last_router_pick
|
|
1629
|
+
self._last_router_pick = None
|
|
1630
|
+
transition_tool_name = f"transition_to_{node_id}"
|
|
1631
|
+
transition_args: Dict[str, Any] = {
|
|
1632
|
+
"from_node_id": previous_node_id,
|
|
1633
|
+
"to_node_id": node_id,
|
|
1634
|
+
}
|
|
1635
|
+
if router_pick:
|
|
1636
|
+
transition_args.update(router_pick)
|
|
1637
|
+
transition_call_id = await start_tracking(
|
|
1638
|
+
transcript_handler=getattr(self, "_transcript_handler", None),
|
|
1639
|
+
tool_name=transition_tool_name,
|
|
1640
|
+
args=transition_args,
|
|
1641
|
+
tool_phase="on_call",
|
|
1642
|
+
tool_type="transition",
|
|
1643
|
+
current_node=previous_node_id,
|
|
1644
|
+
logger=logger,
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1598
1647
|
try:
|
|
1599
1648
|
# Clear any pending transition state when starting a new node
|
|
1600
1649
|
# This ensures clean state regardless of how we arrived here:
|
|
@@ -1612,7 +1661,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1612
1661
|
# Latch the guard's reactive flag (read off ``_current_node``)
|
|
1613
1662
|
# BEFORE pre_actions execute, so the Speak's tts_say plays under
|
|
1614
1663
|
# protection. Reset the aggregator's recency timers in lockstep.
|
|
1615
|
-
if node_config.get("
|
|
1664
|
+
if not node_config.get("interruptible", False):
|
|
1616
1665
|
self._current_node = node_id
|
|
1617
1666
|
await self._enter_speak_guard(node_config)
|
|
1618
1667
|
|
|
@@ -1715,9 +1764,6 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1715
1764
|
# Apply node-level overrides if any
|
|
1716
1765
|
await self._apply_node_overrides(node_id)
|
|
1717
1766
|
|
|
1718
|
-
# Process data extraction for the node (function results from previous node)
|
|
1719
|
-
await self._process_data_extraction(node_id, node_config)
|
|
1720
|
-
|
|
1721
1767
|
# Pure-code short-circuit: if this node is a Speak / Action /
|
|
1722
1768
|
# Condition-Router (has `auto_transition`), skip tools+context+
|
|
1723
1769
|
# LLMRunFrame entirely and recurse directly into the target node.
|
|
@@ -1886,16 +1932,42 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
1886
1932
|
# node_entered observer is now emitted at the *top* of _set_node
|
|
1887
1933
|
# so short-circuit paths (auto_transition / router-mode) get it too.
|
|
1888
1934
|
|
|
1935
|
+
await complete_tracking(
|
|
1936
|
+
transcript_handler=getattr(self, "_transcript_handler", None),
|
|
1937
|
+
function_call_id=transition_call_id,
|
|
1938
|
+
tool_name=transition_tool_name,
|
|
1939
|
+
function_start_time=transition_start_time,
|
|
1940
|
+
response_data={
|
|
1941
|
+
"to_node_id": node_id,
|
|
1942
|
+
"to_node_label": node_config.get("label"),
|
|
1943
|
+
},
|
|
1944
|
+
status_code=200,
|
|
1945
|
+
logger=logger,
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1889
1948
|
except Exception as e:
|
|
1890
1949
|
# If setup crashed inside an uninterruptible node, the guard is
|
|
1891
1950
|
# still latched on ``_current_node`` — clear it so the pipeline
|
|
1892
1951
|
# doesn't strand with every cancel signal being eaten.
|
|
1893
1952
|
try:
|
|
1894
|
-
if node_config.get("
|
|
1953
|
+
if not node_config.get("interruptible", False) and self._current_node == node_id:
|
|
1895
1954
|
self._current_node = None
|
|
1896
1955
|
except Exception:
|
|
1897
1956
|
pass
|
|
1898
1957
|
logger.error(f"Error setting node {node_id}: {str(e)}")
|
|
1958
|
+
try:
|
|
1959
|
+
await complete_tracking(
|
|
1960
|
+
transcript_handler=getattr(self, "_transcript_handler", None),
|
|
1961
|
+
function_call_id=transition_call_id,
|
|
1962
|
+
tool_name=transition_tool_name,
|
|
1963
|
+
function_start_time=transition_start_time,
|
|
1964
|
+
response_data=None,
|
|
1965
|
+
status_code=500,
|
|
1966
|
+
error_details={"error": str(e), "type": type(e).__name__},
|
|
1967
|
+
logger=logger,
|
|
1968
|
+
)
|
|
1969
|
+
except Exception as track_exc:
|
|
1970
|
+
logger.warning(f"complete_tracking failed for {transition_tool_name}: {track_exc!r}")
|
|
1899
1971
|
raise FlowError(f"Failed to set node {node_id}: {str(e)}") from e
|
|
1900
1972
|
|
|
1901
1973
|
def _schedule_deferred_post_actions(self, post_actions: List[ActionConfig]) -> None:
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
5
|
#
|
|
6
6
|
|
|
7
|
-
"""Swallows interruption-cascade frames while the current node is
|
|
7
|
+
"""Swallows interruption-cascade frames while the current node is non-interruptible.
|
|
8
8
|
|
|
9
9
|
Speak / Action / Transfer / End nodes play scripted TTS that must land in full.
|
|
10
10
|
The pipeline-wide ``InterruptionFrame`` cascade (cancel-task and flush the
|
|
11
11
|
output audio queue in ``MediaSender``) would truncate them — so this processor
|
|
12
12
|
drops the cancel signal in both directions when the FlowManager reports
|
|
13
|
-
``
|
|
13
|
+
``is_speak_interruptible()`` is False.
|
|
14
14
|
|
|
15
15
|
User transcripts still pass through, so they accumulate in
|
|
16
16
|
``LLMUserContextAggregator._aggregation`` and the FlowManager flushes (or
|
|
@@ -49,35 +49,35 @@ _UPSTREAM_DROP_TYPES: Tuple[Type[Frame], ...] = (InterruptionTaskFrame,)
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
class SpeakInterruptionGuard(FrameProcessor):
|
|
52
|
-
"""Drops interruption-cascade frames while ``
|
|
52
|
+
"""Drops interruption-cascade frames while ``is_interruptible_fn()`` returns False.
|
|
53
53
|
|
|
54
54
|
Same holder-callback pattern as :class:`RouterModeGuard` so the manager can
|
|
55
55
|
flip state via ``_current_node`` without the processor importing
|
|
56
56
|
FlowManager directly.
|
|
57
57
|
"""
|
|
58
58
|
|
|
59
|
-
def __init__(self,
|
|
59
|
+
def __init__(self, is_interruptible_fn: Callable[[], bool], **kwargs) -> None:
|
|
60
60
|
super().__init__(**kwargs)
|
|
61
|
-
self.
|
|
61
|
+
self._is_interruptible_fn = is_interruptible_fn
|
|
62
62
|
|
|
63
63
|
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
|
64
64
|
await super().process_frame(frame, direction)
|
|
65
65
|
|
|
66
|
-
if
|
|
66
|
+
if self._is_interruptible_fn():
|
|
67
67
|
await self.push_frame(frame, direction)
|
|
68
68
|
return
|
|
69
69
|
|
|
70
70
|
if direction == FrameDirection.DOWNSTREAM and isinstance(frame, _DOWNSTREAM_DROP_TYPES):
|
|
71
71
|
logger.debug(
|
|
72
72
|
f"SpeakInterruptionGuard dropping {type(frame).__name__} downstream "
|
|
73
|
-
f"during
|
|
73
|
+
f"during non-interruptible node"
|
|
74
74
|
)
|
|
75
75
|
return
|
|
76
76
|
|
|
77
77
|
if direction == FrameDirection.UPSTREAM and isinstance(frame, _UPSTREAM_DROP_TYPES):
|
|
78
78
|
logger.debug(
|
|
79
79
|
f"SpeakInterruptionGuard dropping {type(frame).__name__} upstream "
|
|
80
|
-
f"during
|
|
80
|
+
f"during non-interruptible node"
|
|
81
81
|
)
|
|
82
82
|
return
|
|
83
83
|
|
{dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/router_mode.py
RENAMED
|
@@ -16,12 +16,15 @@ writes extracted variables into ``flow_manager.state``, and recurses into
|
|
|
16
16
|
|
|
17
17
|
This module is the pure data-shaping layer:
|
|
18
18
|
* :func:`build_router_response_schema` — JSON Schema for the constrained call
|
|
19
|
-
* :func:`render_router_system_prompt` — system message text for the call
|
|
20
19
|
* :func:`parse_router_response` — extracts ``{branch, extracted}`` from a
|
|
21
20
|
provider response (string/dict/tool-call), with defensive fallbacks
|
|
22
21
|
* :func:`pick_branch_target` — resolves the parsed branch_id to a target
|
|
23
22
|
node name (and handles the unclear sentinel + attempt counter)
|
|
24
23
|
|
|
24
|
+
The router system prompt itself is authored by the backend lowerer
|
|
25
|
+
(``new_calling_agent_backend/agent/commons/flow_lowering.py``) and shipped
|
|
26
|
+
verbatim in ``task_messages[0].content``; the engine reads it directly.
|
|
27
|
+
|
|
25
28
|
The actual LLM call orchestration lives in ``manager.py:_run_router_node`` —
|
|
26
29
|
this module provides the helpers it composes.
|
|
27
30
|
"""
|
|
@@ -41,6 +44,11 @@ logger = logging.getLogger(__name__)
|
|
|
41
44
|
# Sentinel returned by the LLM when none of the explicit branches match.
|
|
42
45
|
UNCLEAR_BRANCH_ID = "__unclear__"
|
|
43
46
|
|
|
47
|
+
# Synthetic branch ID injected at runtime when a Router has an upstream LLM
|
|
48
|
+
# Router available, letting the classifier pick "re-route to previous Router"
|
|
49
|
+
# without the author wiring an explicit edge.
|
|
50
|
+
BACK_BRANCH_ID = "__back_to_previous_router__"
|
|
51
|
+
|
|
44
52
|
# JSON Schema scalar type translation for ExtractSpec.type values.
|
|
45
53
|
_EXTRACT_TYPE_TO_JSONSCHEMA = {
|
|
46
54
|
"string": "string",
|
|
@@ -135,90 +143,6 @@ def build_router_response_schema(
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
|
|
138
|
-
def render_router_system_prompt(
|
|
139
|
-
branches: List[RouterBranch],
|
|
140
|
-
extract: Optional[Dict[str, ExtractSpec]] = None,
|
|
141
|
-
branch_descriptions: Optional[Dict[str, str]] = None,
|
|
142
|
-
) -> str:
|
|
143
|
-
"""Build the router's system message.
|
|
144
|
-
|
|
145
|
-
Kept short on purpose — every token here is paid on every router call (or
|
|
146
|
-
cached, if prompt-caching is wired). Lists each branch with its description
|
|
147
|
-
so the LLM knows when to pick which; appends the extract-vars block when
|
|
148
|
-
the node asks for any.
|
|
149
|
-
|
|
150
|
-
Args:
|
|
151
|
-
branches: NodeConfig ``router_branches`` list (carries id + next).
|
|
152
|
-
extract: NodeConfig ``router_extract`` map.
|
|
153
|
-
branch_descriptions: Optional override map ``{branch_id: description}``.
|
|
154
|
-
The backend lowerer pulls descriptions from the matching edges'
|
|
155
|
-
``condition`` field, not from the branches list itself, so this
|
|
156
|
-
argument is how the orchestrator passes them in.
|
|
157
|
-
|
|
158
|
-
Returns:
|
|
159
|
-
System-prompt string. Engine appends the recent conversation turns
|
|
160
|
-
as a follow-on user message (see ``router_context_window``).
|
|
161
|
-
"""
|
|
162
|
-
descriptions = branch_descriptions or {}
|
|
163
|
-
branch_lines: List[str] = []
|
|
164
|
-
for br in branches or []:
|
|
165
|
-
if not isinstance(br, dict):
|
|
166
|
-
continue
|
|
167
|
-
bid = br.get("id")
|
|
168
|
-
if not bid:
|
|
169
|
-
continue
|
|
170
|
-
desc = descriptions.get(bid) or "(no description)"
|
|
171
|
-
branch_lines.append(f"- {bid}: {desc}")
|
|
172
|
-
branch_lines.append(
|
|
173
|
-
f"- {UNCLEAR_BRANCH_ID}: pick this when no branch above clearly fits "
|
|
174
|
-
f"what the user said"
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
extract_block = ""
|
|
178
|
-
if extract:
|
|
179
|
-
extract_rows: List[str] = []
|
|
180
|
-
for name, spec in extract.items():
|
|
181
|
-
if not isinstance(spec, dict):
|
|
182
|
-
extract_rows.append(f"- {name} (string)")
|
|
183
|
-
continue
|
|
184
|
-
t = spec.get("type", "string")
|
|
185
|
-
desc = spec.get("description", "")
|
|
186
|
-
tail = f" ({t}) — {desc}" if desc else f" ({t})"
|
|
187
|
-
extract_rows.append(f"- {name}{tail}")
|
|
188
|
-
extract_block = (
|
|
189
|
-
"\n\n## Also extract these variables (use null if the user didn't say):\n"
|
|
190
|
-
+ "\n".join(extract_rows)
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
reasoning_block = (
|
|
194
|
-
"\n\n## How to reason\n"
|
|
195
|
-
"Read the user's MOST RECENT message in full before picking. When it "
|
|
196
|
-
"contains BOTH a negation (\"no\", \"not\", \"don't\", \"won't\", "
|
|
197
|
-
"\"can't\", \"nahi\", \"nahin\") AND a positive-sounding action "
|
|
198
|
-
"statement — in either order — the negation scopes over the whole "
|
|
199
|
-
"message. Do NOT pick a positive branch just because one sub-clause "
|
|
200
|
-
"matches its description. Pick a negative-sense branch if one fits; "
|
|
201
|
-
"otherwise __unclear__.\n\n"
|
|
202
|
-
"Example: \"no I don't think so I'll be able to pay\" — negation at "
|
|
203
|
-
"the start scopes over \"I'll be able to pay\". Pick a "
|
|
204
|
-
"\"cannot pay\"-style branch if present, else __unclear__.\n\n"
|
|
205
|
-
"## Strong default\n"
|
|
206
|
-
"If the message is ambiguous or self-contradictory and you can't "
|
|
207
|
-
"defend your pick by quoting the user, prefer __unclear__. A reprompt "
|
|
208
|
-
"costs one turn; a confident misroute costs the call."
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
return (
|
|
212
|
-
"You are a branch-routing classifier for a conversational agent. Read "
|
|
213
|
-
"the conversation so far and pick the one branch_id that best matches "
|
|
214
|
-
"what the user said. Emit JSON with `rationale` first (1-2 sentences "
|
|
215
|
-
"citing the user's words), then `branch`, then `extracted`.\n\n"
|
|
216
|
-
"## Branches\n" + "\n".join(branch_lines)
|
|
217
|
-
+ extract_block
|
|
218
|
-
+ reasoning_block
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
|
|
222
146
|
def parse_router_response(
|
|
223
147
|
raw: Any,
|
|
224
148
|
valid_branch_ids: List[str],
|
{dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/types.py
RENAMED
|
@@ -573,6 +573,10 @@ class NodeConfig(NodeConfigRequired, total=False):
|
|
|
573
573
|
router_exhausted_node: str
|
|
574
574
|
"""Target when ``router_max_unclear`` is exceeded. Typically Transfer or End."""
|
|
575
575
|
|
|
576
|
+
skip_wait_if_silent: bool
|
|
577
|
+
"""If true and no user transcript has landed since this node was entered,
|
|
578
|
+
skip the wait + classifier call and route via ``router_unclear_node``."""
|
|
579
|
+
|
|
576
580
|
# Router-Condition (consumed when auto_transition is the sentinel
|
|
577
581
|
# ``"__condition__"`` AND ``conditions`` is set)
|
|
578
582
|
conditions: List[Condition]
|