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.
Files changed (115) hide show
  1. {dv_pipecat_flows-0.0.0.dev2087/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.0.dev2098}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -1
  4. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/adapters.py +4 -5
  5. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/manager.py +165 -93
  6. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/speak_interruption_guard.py +8 -8
  7. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/router_mode.py +9 -85
  8. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/types.py +4 -0
  9. dv_pipecat_flows-0.0.0.dev2087/src/pipecat_flows/data_extractor.py +0 -249
  10. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/SKILL.md +0 -0
  11. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.agents/skills/loki-logs/query-reference.md +0 -0
  12. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/SKILL.md +0 -0
  13. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.claude/skills/loki-logs/query-reference.md +0 -0
  14. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.gitattributes +0 -0
  15. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.gitignore +0 -0
  16. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.pre-commit-config.yaml +0 -0
  17. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.python-version +0 -0
  18. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/.readthedocs.yaml +0 -0
  19. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/AGENTS.md +0 -0
  20. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CHANGELOG.md +0 -0
  21. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CLAUDE.md +0 -0
  22. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/CONTRIBUTING.md +0 -0
  23. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/LICENSE +0 -0
  24. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/MANIFEST.in +0 -0
  25. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/README.md +0 -0
  26. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/dev-requirements.txt +0 -0
  27. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/docker-compose.dev.yml +0 -0
  28. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/.eslintrc.json +0 -0
  29. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/.prettierrc +0 -0
  30. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/css/tailwind.css +0 -0
  31. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/food_ordering.json +0 -0
  32. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/movie_explorer.json +0 -0
  33. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/patient_intake.json +0 -0
  34. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/restaurant_reservation.json +0 -0
  35. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/examples/travel_planner.json +0 -0
  36. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.png +0 -0
  37. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/favicon.svg +0 -0
  38. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/index.html +0 -0
  39. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/canvas.js +0 -0
  40. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/editorState.js +0 -0
  41. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/sidePanel.js +0 -0
  42. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/editor/toolbar.js +0 -0
  43. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/main.js +0 -0
  44. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/baseNode.js +0 -0
  45. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/endNode.js +0 -0
  46. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/flowNode.js +0 -0
  47. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/functionNode.js +0 -0
  48. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/index.js +0 -0
  49. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/mergeNode.js +0 -0
  50. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/nodes/startNode.js +0 -0
  51. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/types.js +0 -0
  52. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/export.js +0 -0
  53. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/helpers.js +0 -0
  54. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/import.js +0 -0
  55. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/js/utils/validation.js +0 -0
  56. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/jsdoc.json +0 -0
  57. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/package-lock.json +0 -0
  58. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/package.json +0 -0
  59. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/postcss.config.cjs +0 -0
  60. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.png +0 -0
  61. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/public/favicon.svg +0 -0
  62. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/tailwind.config.cjs +0 -0
  63. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/vercel.json +0 -0
  64. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/editor/vite.config.js +0 -0
  65. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/engine_primitives_plan.md +0 -0
  66. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/env.example +0 -0
  67. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/.gitignore +0 -0
  68. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/README.md +0 -0
  69. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/RESULTS.md +0 -0
  70. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/UNIFIED_KB_BENCHMARKS.md +0 -0
  71. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/chunkers.py +0 -0
  72. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/contracts.py +0 -0
  73. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_corpus.py +0 -0
  74. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_dataset.py +0 -0
  75. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/generate_hybrid.py +0 -0
  76. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datagen/templates/insurance.yaml +0 -0
  77. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/.gitignore +0 -0
  78. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_hybrid/manifest.json +0 -0
  79. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval/manifest.json +0 -0
  80. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_retrieval_long/manifest.json +0 -0
  81. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/datasets/v1_synthetic/manifest.json +0 -0
  82. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_pick.py +0 -0
  83. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_filter_scaling.py +0 -0
  84. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_hybrid.py +0 -0
  85. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_latency.py +0 -0
  86. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_rerank.py +0 -0
  87. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/runners/run_retrieval.py +0 -0
  88. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/API.md +0 -0
  89. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/__init__.py +0 -0
  90. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/app.py +0 -0
  91. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/evals/kb/service/jobs.py +0 -0
  92. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/images/food-ordering-flow.png +0 -0
  93. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pipecat-flows.png +0 -0
  94. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pipecat_upgrade.md +0 -0
  95. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/pyproject.toml +0 -0
  96. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/README.md +0 -0
  97. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/extensions.conf +0 -0
  98. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/remote-asterisk-code/rtp.conf +0 -0
  99. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/requirements.txt +0 -0
  100. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/check-pypi-package.py +0 -0
  101. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/fix-ruff.sh +0 -0
  102. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/scripts/pre-commit.sh +0 -0
  103. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/setup.cfg +0 -0
  104. {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
  105. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  106. {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
  107. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/__init__.py +0 -0
  108. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/actions.py +0 -0
  109. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/condition_evaluator.py +0 -0
  110. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/exceptions.py +0 -0
  111. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/flow_validator.py +0 -0
  112. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/__init__.py +0 -0
  113. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  114. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  115. {dv_pipecat_flows-0.0.0.dev2087 → dv_pipecat_flows-0.0.0.dev2098}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.0.dev2087
3
+ Version: 0.0.0.dev2098
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.0.dev2087
3
+ Version: 0.0.0.dev2098
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -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
@@ -176,9 +176,9 @@ class LLMAdapter:
176
176
 
177
177
  Args:
178
178
  llm_service: The underlying LLM service (exposes ``_client`` +
179
- ``_model``, same convention as ``DataExtractor`` uses).
180
- system_prompt: System message text (built by
181
- :func:`render_router_system_prompt`).
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
- Mirrors the direct-client pattern already used by ``DataExtractor``:
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
@@ -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("uninterruptible"):
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 is_speak_uninterruptible(self) -> bool:
1095
- """True when the current node has ``uninterruptible`` set, so
1096
- :class:`pipecat_flows.processors.SpeakInterruptionGuard` swallows the
1097
- interruption cascade and the bot's scripted line lands intact.
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("uninterruptible"))
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 ("speak", "action"):
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
- "transfer",
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
- # Temporary diagnostic answers "did router_branch_descriptions
1256
- # actually reach the engine, or is the lowerer/pydantic layer still
1257
- # silently dropping it?" Remove after verifying.
1258
- logger.debug(
1259
- f"Router '{node_id}' descriptions present={bool(branch_descriptions)} "
1260
- f"keys={list(branch_descriptions.keys())} "
1261
- f"sample={next(iter(branch_descriptions.items()), None)}"
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
- schema = build_router_response_schema(branches, extract)
1265
- system_prompt = render_router_system_prompt(
1266
- branches, extract, branch_descriptions
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
- logger.debug(
1269
- f"Router '{node_id}' rendered system_prompt ({len(system_prompt)} chars):\n{system_prompt}"
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("uninterruptible"):
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("uninterruptible") and self._current_node == node_id:
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 uninterruptible.
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
- ``is_speak_uninterruptible()``.
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 ``is_uninterruptible_fn()`` returns True.
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, is_uninterruptible_fn: Callable[[], bool], **kwargs) -> None:
59
+ def __init__(self, is_interruptible_fn: Callable[[], bool], **kwargs) -> None:
60
60
  super().__init__(**kwargs)
61
- self._is_uninterruptible_fn = is_uninterruptible_fn
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 not self._is_uninterruptible_fn():
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 uninterruptible node"
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 uninterruptible node"
80
+ f"during non-interruptible node"
81
81
  )
82
82
  return
83
83
 
@@ -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],
@@ -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]