dv-pipecat-flows 0.0.23.dev5__tar.gz → 0.0.23.dev7__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.dev5/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.23.dev7}/PKG-INFO +2 -1
  2. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/README.md +1 -0
  3. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7/src/dv_pipecat_flows.egg-info}/PKG-INFO +2 -1
  4. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/manager.py +171 -20
  5. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.agents/skills/loki-logs/SKILL.md +0 -0
  6. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.agents/skills/loki-logs/query-reference.md +0 -0
  7. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.claude/skills/loki-logs/SKILL.md +0 -0
  8. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.claude/skills/loki-logs/query-reference.md +0 -0
  9. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.gitattributes +0 -0
  10. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.gitignore +0 -0
  11. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.pre-commit-config.yaml +0 -0
  12. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.python-version +0 -0
  13. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/.readthedocs.yaml +0 -0
  14. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/AGENTS.md +0 -0
  15. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/CHANGELOG.md +0 -0
  16. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/CLAUDE.md +0 -0
  17. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/CONTRIBUTING.md +0 -0
  18. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/LICENSE +0 -0
  19. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/MANIFEST.in +0 -0
  20. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/dev-requirements.txt +0 -0
  21. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/docker-compose.dev.yml +0 -0
  22. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/.eslintrc.json +0 -0
  23. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/.prettierrc +0 -0
  24. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/css/tailwind.css +0 -0
  25. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/examples/food_ordering.json +0 -0
  26. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/examples/movie_explorer.json +0 -0
  27. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/examples/patient_intake.json +0 -0
  28. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/examples/restaurant_reservation.json +0 -0
  29. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/examples/travel_planner.json +0 -0
  30. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/favicon.png +0 -0
  31. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/favicon.svg +0 -0
  32. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/index.html +0 -0
  33. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/editor/canvas.js +0 -0
  34. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/editor/editorState.js +0 -0
  35. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/editor/sidePanel.js +0 -0
  36. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/editor/toolbar.js +0 -0
  37. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/main.js +0 -0
  38. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/baseNode.js +0 -0
  39. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/endNode.js +0 -0
  40. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/flowNode.js +0 -0
  41. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/functionNode.js +0 -0
  42. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/index.js +0 -0
  43. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/mergeNode.js +0 -0
  44. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/nodes/startNode.js +0 -0
  45. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/types.js +0 -0
  46. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/utils/export.js +0 -0
  47. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/utils/helpers.js +0 -0
  48. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/utils/import.js +0 -0
  49. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/js/utils/validation.js +0 -0
  50. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/jsdoc.json +0 -0
  51. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/package-lock.json +0 -0
  52. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/package.json +0 -0
  53. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/postcss.config.cjs +0 -0
  54. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/public/favicon.png +0 -0
  55. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/public/favicon.svg +0 -0
  56. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/tailwind.config.cjs +0 -0
  57. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/vercel.json +0 -0
  58. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/editor/vite.config.js +0 -0
  59. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/engine_primitives_plan.md +0 -0
  60. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/env.example +0 -0
  61. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/images/food-ordering-flow.png +0 -0
  62. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/pipecat-flows.png +0 -0
  63. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/pipecat_upgrade.md +0 -0
  64. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/pyproject.toml +0 -0
  65. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/remote-asterisk-code/README.md +0 -0
  66. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/remote-asterisk-code/extensions.conf +0 -0
  67. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/remote-asterisk-code/rtp.conf +0 -0
  68. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/requirements.txt +0 -0
  69. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/scripts/check-pypi-package.py +0 -0
  70. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/scripts/fix-ruff.sh +0 -0
  71. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/scripts/pre-commit.sh +0 -0
  72. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/setup.cfg +0 -0
  73. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  74. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  75. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  76. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/scm_file_list.json +0 -0
  77. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/scm_version.json +0 -0
  78. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  79. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/__init__.py +0 -0
  80. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/actions.py +0 -0
  81. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/condition_evaluator.py +0 -0
  83. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/exceptions.py +0 -0
  84. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/flow_validator.py +0 -0
  85. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/processors/__init__.py +0 -0
  86. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  88. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  89. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/router_mode.py +0 -0
  90. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/src/pipecat_flows/types.py +0 -0
  91. {dv_pipecat_flows-0.0.23.dev5 → dv_pipecat_flows-0.0.23.dev7}/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.dev5
3
+ Version: 0.0.23.dev7
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.dev5
3
+ Version: 0.0.23.dev7
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
+
@@ -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.
125
-
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.
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.
127
+
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
@@ -805,6 +928,22 @@ class FlowManager:
805
928
  next_node = None
806
929
  is_transition_only_function = True
807
930
 
931
+ # Record that THIS function moved the flow, so
932
+ # RESET_WITH_CONVERSATION keeps its tool call in the carried
933
+ # context (see extract_conversation_messages).
934
+ #
935
+ # Detected at CALL time, not registration time. ringg-bot bakes the
936
+ # target into the handler (``create_transition_handler`` returns
937
+ # ``({"status": "ok"}, target_node)``) and leaves
938
+ # ``FlowsFunctionSchema.transition_to`` unset — so a
939
+ # registration-time check reads None, never fires, and silently
940
+ # reduces the whole context filter to a no-op. A truthy
941
+ # ``next_node`` is the only reliable signal. Call-time is
942
+ # sufficient by construction: a transition's tool call can only be
943
+ # in the context if the transition actually ran.
944
+ if next_node:
945
+ self._transition_function_names.add(name)
946
+
808
947
  logger.debug(
809
948
  f"{'Transition-only function called for' if is_transition_only_function else 'Function handler completed for'} {name}"
810
949
  )
@@ -1041,6 +1180,13 @@ class FlowManager:
1041
1180
  Raises:
1042
1181
  FlowError: If function registration fails
1043
1182
  """
1183
+ # A function that moves the flow is a transition function; everything else
1184
+ # (KB lookups, API tools, widgets) is not. Recorded cumulatively for the
1185
+ # whole call so RESET_WITH_CONVERSATION can tell the two apart in a context
1186
+ # it is filtering long after the source node's tools were swapped out.
1187
+ if transition_to or transition_callback:
1188
+ self._transition_function_names.add(name)
1189
+
1044
1190
  if name not in self._current_functions:
1045
1191
  try:
1046
1192
  # Handle special token format (e.g. "__function__:function_name")
@@ -3414,6 +3560,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
3414
3560
  previous_node_id = self._current_node
3415
3561
  router_pick = self._last_router_pick
3416
3562
  self._last_router_pick = None
3563
+ transition_reason = self._last_transition_reason
3564
+ self._last_transition_reason = None
3417
3565
  # In v2 deterministic lowering, ``FlowConfig.nodes`` is keyed by
3418
3566
  # ``node.name`` (see calling/flow_builder_v2.py:214), and
3419
3567
  # ``self._current_node`` stores that same dict key — so
@@ -3459,9 +3607,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
3459
3607
  }
3460
3608
  if router_pick:
3461
3609
  transition_args.update(router_pick)
3610
+ if transition_reason:
3611
+ transition_args["transition_reason"] = transition_reason
3462
3612
  logger.info(
3463
3613
  f"[node_transition] from={previous_node_id} to={node_id} "
3464
- f"router_pick={router_pick}"
3614
+ f"router_pick={router_pick} reason={transition_reason!r}"
3465
3615
  )
3466
3616
  transition_call_id = await start_tracking(
3467
3617
  transcript_handler=transition_transcript_handler,
@@ -4181,7 +4331,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
4181
4331
  # role_messages (the new system instruction) and before task
4182
4332
  # messages, mirroring the RESET_WITH_SUMMARY layout.
4183
4333
  carried = extract_conversation_messages(
4184
- self._context_aggregator.user()._context.get_messages()
4334
+ self._context_aggregator.user()._context.get_messages(),
4335
+ transition_names=self._transition_function_names,
4185
4336
  )
4186
4337
  messages.extend(carried)
4187
4338
  logger.debug(f"Carried {len(carried)} conversation message(s) across transition")