dv-pipecat-flows 0.0.23.dev4__tar.gz → 0.0.23.dev6__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 (91) hide show
  1. {dv_pipecat_flows-0.0.23.dev4/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.23.dev6}/PKG-INFO +2 -1
  2. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/README.md +1 -0
  3. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6/src/dv_pipecat_flows.egg-info}/PKG-INFO +2 -1
  4. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/actions.py +2 -2
  5. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/manager.py +185 -20
  6. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.agents/skills/loki-logs/SKILL.md +0 -0
  7. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.agents/skills/loki-logs/query-reference.md +0 -0
  8. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.claude/skills/loki-logs/SKILL.md +0 -0
  9. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.claude/skills/loki-logs/query-reference.md +0 -0
  10. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.gitattributes +0 -0
  11. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.gitignore +0 -0
  12. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.pre-commit-config.yaml +0 -0
  13. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.python-version +0 -0
  14. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/.readthedocs.yaml +0 -0
  15. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/AGENTS.md +0 -0
  16. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/CHANGELOG.md +0 -0
  17. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/CLAUDE.md +0 -0
  18. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/CONTRIBUTING.md +0 -0
  19. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/LICENSE +0 -0
  20. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/MANIFEST.in +0 -0
  21. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/dev-requirements.txt +0 -0
  22. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/docker-compose.dev.yml +0 -0
  23. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/.eslintrc.json +0 -0
  24. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/.prettierrc +0 -0
  25. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/css/tailwind.css +0 -0
  26. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/examples/food_ordering.json +0 -0
  27. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/examples/movie_explorer.json +0 -0
  28. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/examples/patient_intake.json +0 -0
  29. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/examples/restaurant_reservation.json +0 -0
  30. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/examples/travel_planner.json +0 -0
  31. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/favicon.png +0 -0
  32. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/favicon.svg +0 -0
  33. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/index.html +0 -0
  34. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/editor/canvas.js +0 -0
  35. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/editor/editorState.js +0 -0
  36. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/editor/sidePanel.js +0 -0
  37. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/editor/toolbar.js +0 -0
  38. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/main.js +0 -0
  39. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/baseNode.js +0 -0
  40. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/endNode.js +0 -0
  41. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/flowNode.js +0 -0
  42. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/functionNode.js +0 -0
  43. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/index.js +0 -0
  44. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/mergeNode.js +0 -0
  45. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/nodes/startNode.js +0 -0
  46. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/types.js +0 -0
  47. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/utils/export.js +0 -0
  48. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/utils/helpers.js +0 -0
  49. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/utils/import.js +0 -0
  50. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/js/utils/validation.js +0 -0
  51. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/jsdoc.json +0 -0
  52. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/package-lock.json +0 -0
  53. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/package.json +0 -0
  54. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/postcss.config.cjs +0 -0
  55. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/public/favicon.png +0 -0
  56. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/public/favicon.svg +0 -0
  57. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/tailwind.config.cjs +0 -0
  58. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/vercel.json +0 -0
  59. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/editor/vite.config.js +0 -0
  60. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/engine_primitives_plan.md +0 -0
  61. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/env.example +0 -0
  62. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/images/food-ordering-flow.png +0 -0
  63. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/pipecat-flows.png +0 -0
  64. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/pipecat_upgrade.md +0 -0
  65. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/pyproject.toml +0 -0
  66. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/remote-asterisk-code/README.md +0 -0
  67. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/remote-asterisk-code/extensions.conf +0 -0
  68. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/remote-asterisk-code/rtp.conf +0 -0
  69. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/requirements.txt +0 -0
  70. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/scripts/check-pypi-package.py +0 -0
  71. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/scripts/fix-ruff.sh +0 -0
  72. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/scripts/pre-commit.sh +0 -0
  73. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/setup.cfg +0 -0
  74. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  75. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  76. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  77. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/scm_file_list.json +0 -0
  78. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/scm_version.json +0 -0
  79. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  80. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/__init__.py +0 -0
  81. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/condition_evaluator.py +0 -0
  83. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/exceptions.py +0 -0
  84. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/flow_validator.py +0 -0
  85. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/processors/__init__.py +0 -0
  86. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  88. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  89. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/router_mode.py +0 -0
  90. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/src/pipecat_flows/types.py +0 -0
  91. {dv_pipecat_flows-0.0.23.dev4 → dv_pipecat_flows-0.0.23.dev6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.23.dev4
3
+ Version: 0.0.23.dev6
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -309,3 +309,4 @@ We aim to review all contributions promptly and provide constructive feedback to
309
309
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
310
310
 
311
311
 
312
+
@@ -284,3 +284,4 @@ We aim to review all contributions promptly and provide constructive feedback to
284
284
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
285
285
 
286
286
 
287
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.23.dev4
3
+ Version: 0.0.23.dev6
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -309,3 +309,4 @@ We aim to review all contributions promptly and provide constructive feedback to
309
309
  ➡️ [Reach us on X](https://x.com/pipecat_ai)
310
310
 
311
311
 
312
+
@@ -73,7 +73,7 @@ _EXTRACT_VAR_RE = re.compile(
73
73
  )
74
74
 
75
75
 
76
- def _substitute_extract_vars(text: str, state: dict) -> str:
76
+ def substitute_extract_vars(text: str, state: dict) -> str:
77
77
  """Replace ``[{var}]`` / ``@[{var}]`` occurrences with ``state[var]``.
78
78
 
79
79
  Supports dotted paths (``[{user.name}]``). Unknown variables (key not
@@ -483,7 +483,7 @@ class ActionManager:
483
483
  # text_raw stays unrendered so the split-cache's prefix derivation
484
484
  # remains stable across calls.
485
485
  state = self._flow_manager.state if isinstance(self._flow_manager.state, dict) else {}
486
- text = _substitute_extract_vars(text, state)
486
+ text = substitute_extract_vars(text, state)
487
487
 
488
488
  await self._maybe_switch_tts_service_for_action(action)
489
489
 
@@ -71,7 +71,7 @@ from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
71
71
  from pipecat.services.llm_service import FunctionCallParams
72
72
  from pipecat.transports.base_transport import BaseTransport
73
73
 
74
- from pipecat_flows.actions import ActionError, ActionManager
74
+ from pipecat_flows.actions import ActionError, ActionManager, substitute_extract_vars
75
75
  from pipecat_flows.adapters import create_adapter
76
76
  from pipecat_flows.condition_evaluator import evaluate_conditions
77
77
  from pipecat_flows.exceptions import (
@@ -120,34 +120,139 @@ else:
120
120
  LLMService = Any
121
121
 
122
122
 
123
- def extract_conversation_messages(messages: List[dict]) -> List[dict]:
124
- """Keep only the conversational turns from a context's messages.
123
+ def _paired_transition_call_ids(
124
+ messages: List[dict], transition_names: Set[str]
125
+ ) -> Set[str]:
126
+ """IDs of transition tool calls that have BOTH a call and a matching result.
125
127
 
126
- Returns user/assistant messages carrying real spoken text and drops
127
- everything else: system prompts, tool results (``role`` = ``tool``/
128
- ``function``), and assistant messages that are only tool calls. Handles both
129
- string content and provider content-part lists (e.g. Anthropic), keeping
130
- only ``text`` parts and dropping ``tool_use``/``tool_result`` blocks. Used by
131
- the RESET_WITH_CONVERSATION strategy to carry a clean transcript across a
132
- node transition without the previous node's system prompt or tool logs.
128
+ Pairing is the whole point. Every provider rejects a dangling half: an
129
+ assistant ``tool_use``/``tool_calls`` entry with no result, or a result whose
130
+ call was filtered away, is a hard 400. Rather than emit and then repair, we
131
+ resolve the pairs up front and only keep ids that are complete on both sides
132
+ so a transition still in flight when the context is rebuilt is simply left
133
+ out instead of corrupting the whole request.
133
134
  """
135
+ call_ids: Set[str] = set()
136
+ for message in messages:
137
+ if not isinstance(message, dict) or message.get("role") != "assistant":
138
+ continue
139
+ # OpenAI shape: {"tool_calls": [{"id", "function": {"name"}}]}
140
+ for tool_call in message.get("tool_calls") or []:
141
+ if not isinstance(tool_call, dict):
142
+ continue
143
+ name = (tool_call.get("function") or {}).get("name")
144
+ if name in transition_names and tool_call.get("id"):
145
+ call_ids.add(tool_call["id"])
146
+ # Anthropic shape: content parts of type "tool_use"
147
+ content = message.get("content")
148
+ if isinstance(content, list):
149
+ for part in content:
150
+ if (
151
+ isinstance(part, dict)
152
+ and part.get("type") == "tool_use"
153
+ and part.get("name") in transition_names
154
+ and part.get("id")
155
+ ):
156
+ call_ids.add(part["id"])
157
+
158
+ result_ids: Set[str] = set()
159
+ for message in messages:
160
+ if not isinstance(message, dict):
161
+ continue
162
+ # OpenAI shape: a dedicated {"role": "tool", "tool_call_id": ...} message
163
+ if message.get("role") == "tool" and message.get("tool_call_id") in call_ids:
164
+ result_ids.add(message["tool_call_id"])
165
+ # Anthropic shape: "tool_result" parts ride inside a *user* message
166
+ content = message.get("content")
167
+ if isinstance(content, list):
168
+ for part in content:
169
+ if (
170
+ isinstance(part, dict)
171
+ and part.get("type") == "tool_result"
172
+ and part.get("tool_use_id") in call_ids
173
+ ):
174
+ result_ids.add(part["tool_use_id"])
175
+
176
+ return call_ids & result_ids
177
+
178
+
179
+ def extract_conversation_messages(
180
+ messages: List[dict], transition_names: Optional[Set[str]] = None
181
+ ) -> List[dict]:
182
+ """Keep the conversational turns, plus the transition tool calls.
183
+
184
+ Returns user/assistant messages carrying real spoken text, and — when
185
+ ``transition_names`` is supplied — the ``tool_use``/``tool_result`` pairs for
186
+ the flow's transition functions. Everything else is dropped: system prompts,
187
+ and the tool traffic of KB lookups / API tools / widgets (a single ``query_kb``
188
+ result runs ~8 KB, and the node's own system prompt is the real cost anyway).
189
+
190
+ Keeping the transition call is not cosmetic. Without it the carried transcript
191
+ ends on the user turn that triggered the hop, which is indistinguishable from a
192
+ fresh message nobody has handled — so the target node re-routes on it and the
193
+ source node sends it straight back. That is the A->B->A ping-pong. The
194
+ preserved call tells the target it was routed here, by whom, and (via the
195
+ required ``transition_reason`` arg) why.
196
+
197
+ Handles both string content and provider content-part lists (Anthropic).
198
+ Used by the RESET_WITH_CONVERSATION strategy.
199
+ """
200
+ transition_names = transition_names or set()
201
+ keep_ids = _paired_transition_call_ids(messages, transition_names)
202
+
134
203
  kept: List[dict] = []
135
204
  for message in messages:
136
- if not isinstance(message, dict) or message.get("role") not in ("user", "assistant"):
205
+ if not isinstance(message, dict):
206
+ continue
207
+ role = message.get("role")
208
+ if role not in ("user", "assistant", "tool"):
137
209
  continue
138
210
  content = message.get("content")
211
+
212
+ # OpenAI tool result — keep only if its call survived.
213
+ if role == "tool":
214
+ if message.get("tool_call_id") in keep_ids:
215
+ kept.append(message)
216
+ continue
217
+
218
+ # Provider content-part list (Anthropic): text + surviving tool blocks.
139
219
  if isinstance(content, list):
140
- text_parts = [
220
+ parts = [
141
221
  part
142
222
  for part in content
143
- if isinstance(part, dict) and part.get("type") == "text" and part.get("text")
223
+ if isinstance(part, dict)
224
+ and (
225
+ (part.get("type") == "text" and part.get("text"))
226
+ or (part.get("type") == "tool_use" and part.get("id") in keep_ids)
227
+ or (
228
+ part.get("type") == "tool_result"
229
+ and part.get("tool_use_id") in keep_ids
230
+ )
231
+ )
144
232
  ]
145
- if not text_parts:
146
- continue # e.g. a tool_use / tool_result-only turn
147
- kept.append({"role": message["role"], "content": text_parts})
233
+ if parts:
234
+ kept.append({"role": role, "content": parts})
235
+ continue
236
+
237
+ # OpenAI assistant turn carrying tool_calls.
238
+ tool_calls = [
239
+ tool_call
240
+ for tool_call in (message.get("tool_calls") or [])
241
+ if isinstance(tool_call, dict) and tool_call.get("id") in keep_ids
242
+ ]
243
+ if tool_calls:
244
+ # An assistant turn may mix a KB call with a transition call; only the
245
+ # surviving calls come across, and their results were matched above.
246
+ kept.append(
247
+ {
248
+ "role": role,
249
+ "content": content if isinstance(content, str) and content.strip() else None,
250
+ "tool_calls": tool_calls,
251
+ }
252
+ )
148
253
  elif isinstance(content, str) and content.strip():
149
- kept.append({"role": message["role"], "content": content})
150
- # else: empty content (e.g. an assistant turn that was only tool_calls) -> drop
254
+ kept.append({"role": role, "content": content})
255
+ # else: empty content and no surviving tool calls -> drop
151
256
  return kept
152
257
 
153
258
 
@@ -219,6 +324,16 @@ class FlowManager:
219
324
  # rationale + prompt_sha1 land in the transition tool-log args. Cleared
220
325
  # by _set_node after read so it can't leak into an unrelated next hop.
221
326
  self._last_router_pick: Optional[Dict[str, Any]] = None
327
+ # The LLM's own one-line rationale for the hop it just requested, taken
328
+ # from the `transition_reason` arg every transition function declares.
329
+ # Same lifecycle as _last_router_pick: stashed by the transition func,
330
+ # consumed + cleared by _set_node so it can't leak into the next hop.
331
+ self._last_transition_reason: Optional[str] = None
332
+ # Every transition function registered so far this call (populated by
333
+ # _register_function). RESET_WITH_CONVERSATION keeps these tool calls in
334
+ # the carried context and drops all other tool traffic — see
335
+ # extract_conversation_messages.
336
+ self._transition_function_names: Set[str] = set()
222
337
  self._context_aggregator = context_aggregator
223
338
  self._pending_transition: Optional[Dict[str, Any]] = None
224
339
  # Result ("success"/"failure") of the most recent function action, set
@@ -698,6 +813,14 @@ class FlowManager:
698
813
  function_start_time = time.time()
699
814
  try:
700
815
  logger.debug(f"Function called: {name}")
816
+ # Every transition function declares a required `transition_reason`
817
+ # (see call_helper._with_transition_reason). Stash it for _set_node,
818
+ # which tool-logs it and turns it into the handoff breadcrumb.
819
+ # Not written into state — it is per-hop rationale, not a variable.
820
+ reason = (params.arguments or {}).get("transition_reason")
821
+ if isinstance(reason, str) and reason.strip():
822
+ self._last_transition_reason = reason.strip()
823
+
701
824
  # Flattened-router extract: copy LLM-supplied args into
702
825
  # ``flow_manager.state`` BEFORE the transition resolves, so
703
826
  # downstream nodes (e.g. Speak picking variant by
@@ -1041,6 +1164,13 @@ class FlowManager:
1041
1164
  Raises:
1042
1165
  FlowError: If function registration fails
1043
1166
  """
1167
+ # A function that moves the flow is a transition function; everything else
1168
+ # (KB lookups, API tools, widgets) is not. Recorded cumulatively for the
1169
+ # whole call so RESET_WITH_CONVERSATION can tell the two apart in a context
1170
+ # it is filtering long after the source node's tools were swapped out.
1171
+ if transition_to or transition_callback:
1172
+ self._transition_function_names.add(name)
1173
+
1044
1174
  if name not in self._current_functions:
1045
1175
  try:
1046
1176
  # Handle special token format (e.g. "__function__:function_name")
@@ -3414,6 +3544,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
3414
3544
  previous_node_id = self._current_node
3415
3545
  router_pick = self._last_router_pick
3416
3546
  self._last_router_pick = None
3547
+ transition_reason = self._last_transition_reason
3548
+ self._last_transition_reason = None
3417
3549
  # In v2 deterministic lowering, ``FlowConfig.nodes`` is keyed by
3418
3550
  # ``node.name`` (see calling/flow_builder_v2.py:214), and
3419
3551
  # ``self._current_node`` stores that same dict key — so
@@ -3459,9 +3591,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
3459
3591
  }
3460
3592
  if router_pick:
3461
3593
  transition_args.update(router_pick)
3594
+ if transition_reason:
3595
+ transition_args["transition_reason"] = transition_reason
3462
3596
  logger.info(
3463
3597
  f"[node_transition] from={previous_node_id} to={node_id} "
3464
- f"router_pick={router_pick}"
3598
+ f"router_pick={router_pick} reason={transition_reason!r}"
3465
3599
  )
3466
3600
  transition_call_id = await start_tracking(
3467
3601
  transcript_handler=transition_transcript_handler,
@@ -4077,6 +4211,31 @@ In all of these cases, you can provide a `name` in your new node's config for de
4077
4211
  """Generate a conversation summary from a given context."""
4078
4212
  return await self._adapter.generate_summary(self._llm, summary_prompt, context)
4079
4213
 
4214
+ def _resolve_extract_tokens(
4215
+ self, messages: Optional[List[dict]]
4216
+ ) -> Optional[List[dict]]:
4217
+ """Substitute ``[{name}]`` extract tokens in message content from
4218
+ ``self.state``.
4219
+
4220
+ Router/agent nodes write their extracted values into ``self.state`` at
4221
+ runtime; the backend lowerer only resolves compile-time vars
4222
+ (custom_args, api_responses), so a downstream node's role/task prompt
4223
+ that references an extracted value would otherwise reach the LLM as a
4224
+ literal ``[{name}]`` marker. Mirrors the Speak/tts_say pass in
4225
+ ``actions.substitute_extract_vars``. Returns shallow copies so the
4226
+ source NodeConfig messages stay in token form for the next entry;
4227
+ unknown keys are left literal.
4228
+ """
4229
+ if not messages:
4230
+ return messages
4231
+ resolved: List[dict] = []
4232
+ for message in messages:
4233
+ content = message.get("content") if isinstance(message, dict) else None
4234
+ if isinstance(content, str) and "[{" in content:
4235
+ message = {**message, "content": substitute_extract_vars(content, self.state)}
4236
+ resolved.append(message)
4237
+ return resolved
4238
+
4080
4239
  async def _update_llm_context(
4081
4240
  self,
4082
4241
  role_messages: Optional[List[dict]],
@@ -4102,6 +4261,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
4102
4261
  try:
4103
4262
  messages = []
4104
4263
 
4264
+ # Resolve runtime ``[{name}]`` extract tokens against self.state
4265
+ # before the prompts enter context (see _resolve_extract_tokens).
4266
+ role_messages = self._resolve_extract_tokens(role_messages)
4267
+ task_messages = self._resolve_extract_tokens(task_messages)
4268
+
4105
4269
  # Add role messages if provided.
4106
4270
  # Note that these come before any possible summary message; some
4107
4271
  # LLMs only support a single system instruction, and the first role
@@ -4151,7 +4315,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
4151
4315
  # role_messages (the new system instruction) and before task
4152
4316
  # messages, mirroring the RESET_WITH_SUMMARY layout.
4153
4317
  carried = extract_conversation_messages(
4154
- self._context_aggregator.user()._context.get_messages()
4318
+ self._context_aggregator.user()._context.get_messages(),
4319
+ transition_names=self._transition_function_names,
4155
4320
  )
4156
4321
  messages.extend(carried)
4157
4322
  logger.debug(f"Carried {len(carried)} conversation message(s) across transition")