soprano-sdk 0.2.12__tar.gz → 0.2.14__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 (106) hide show
  1. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/PKG-INFO +2 -1
  2. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/pyproject.toml +2 -1
  3. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/agents/adaptor.py +3 -0
  4. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/core/engine.py +11 -1
  5. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/collect_input.py +1 -12
  6. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/tools.py +62 -8
  7. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_collect_input_refactor.py +4 -40
  8. soprano_sdk-0.2.12/examples/ASYNC_FUNCTIONS_README.md +0 -414
  9. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/.github/workflows/test_build_and_publish.yaml +0 -0
  10. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/.gitignore +0 -0
  11. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/.python-version +0 -0
  12. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/CLAUDE.md +0 -0
  13. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/LICENSE +0 -0
  14. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/README.md +0 -0
  15. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/concert_booking/__init__.py +0 -0
  16. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/concert_booking/booking_helpers.py +0 -0
  17. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
  18. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/framework_example.yaml +0 -0
  19. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/greeting_functions.py +0 -0
  20. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/greeting_workflow.yaml +0 -0
  21. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/main.py +0 -0
  22. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/payment_async_functions.py +0 -0
  23. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/payment_async_workflow.yaml +0 -0
  24. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/persistence/README.md +0 -0
  25. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/persistence/conversation_based.py +0 -0
  26. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/persistence/entity_based.py +0 -0
  27. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/persistence/mongodb_demo.py +0 -0
  28. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/return_functions.py +0 -0
  29. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/return_workflow.yaml +0 -0
  30. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/structured_output_example.yaml +0 -0
  31. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/README.md +0 -0
  32. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/crewai_supervisor_ui.py +0 -0
  33. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
  34. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/tools/__init__.py +0 -0
  35. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/tools/crewai_tools.py +0 -0
  36. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/tools/langgraph_tools.py +0 -0
  37. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/supervisors/workflow_tools.py +0 -0
  38. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/test_payment_async.py +0 -0
  39. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/tools/__init__.py +0 -0
  40. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/tools/address.py +0 -0
  41. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/examples/validator.py +0 -0
  42. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/langgraph_demo.py +0 -0
  43. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/langgraph_selfloop_demo.py +0 -0
  44. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/langgraph_v.py +0 -0
  45. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/main.py +0 -0
  46. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/return_fsm.excalidraw +0 -0
  47. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/return_state_machine.png +0 -0
  48. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/legacy/ui.py +0 -0
  49. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/scripts/visualize_workflow.py +0 -0
  50. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/scripts/workflow_demo.py +0 -0
  51. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/scripts/workflow_demo_ui.py +0 -0
  52. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/__init__.py +0 -0
  53. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/agents/__init__.py +0 -0
  54. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/agents/factory.py +0 -0
  55. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/agents/structured_output.py +0 -0
  56. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/authenticators/__init__.py +0 -0
  57. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/authenticators/mfa.py +0 -0
  58. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/core/__init__.py +0 -0
  59. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/core/constants.py +0 -0
  60. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/core/rollback_strategies.py +0 -0
  61. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/core/state.py +0 -0
  62. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/engine.py +0 -0
  63. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/__init__.py +0 -0
  64. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/async_function.py +0 -0
  65. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/base.py +0 -0
  66. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/call_function.py +0 -0
  67. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/nodes/factory.py +0 -0
  68. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/routing/__init__.py +0 -0
  69. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/routing/router.py +0 -0
  70. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/__init__.py +0 -0
  71. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/function.py +0 -0
  72. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/logger.py +0 -0
  73. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/template.py +0 -0
  74. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/tool.py +0 -0
  75. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/utils/tracing.py +0 -0
  76. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/validation/__init__.py +0 -0
  77. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/validation/schema.py +0 -0
  78. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/soprano_sdk/validation/validator.py +0 -0
  79. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/debug_jinja2.py +0 -0
  80. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_adaptor_logging.py +0 -0
  81. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_agent_factory.py +0 -0
  82. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_async_function.py +0 -0
  83. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_external_values.py +0 -0
  84. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_inputs_validation.py +0 -0
  85. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_jinja2_path.py +0 -0
  86. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_jinja2_standalone.py +0 -0
  87. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_mfa_scenarios.py +0 -0
  88. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_persistence.py +0 -0
  89. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_structured_output.py +0 -0
  90. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_transition_routing.py +0 -0
  91. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/tests/test_workflow_tool_context_update.py +0 -0
  92. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/todo.md +0 -0
  93. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/.eslintrc.cjs +0 -0
  94. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/.gitignore +0 -0
  95. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/README.md +0 -0
  96. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/index.html +0 -0
  97. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/package-lock.json +0 -0
  98. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/package.json +0 -0
  99. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/App.jsx +0 -0
  100. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/CustomNode.jsx +0 -0
  101. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
  102. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
  103. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
  104. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/assets/react.svg +0 -0
  105. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/src/main.jsx +0 -0
  106. {soprano_sdk-0.2.12 → soprano_sdk-0.2.14}/workflow-visualizer/vite.config.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soprano-sdk
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -21,6 +21,7 @@ Requires-Dist: langchain-openai>=1.0.3
21
21
  Requires-Dist: langchain>=1.0.7
22
22
  Requires-Dist: langfuse>=3.10.1
23
23
  Requires-Dist: langgraph==1.0.2
24
+ Requires-Dist: litellm>=1.81.1
24
25
  Requires-Dist: openai>=1.92.1
25
26
  Requires-Dist: pydantic-ai>=1.22.0
26
27
  Requires-Dist: pydantic>=2.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "soprano-sdk"
7
- version = "0.2.12"
7
+ version = "0.2.14"
8
8
  description = "YAML-driven workflow engine with AI agent integration for building conversational SOPs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "langchain-openai>=1.0.3",
31
31
  "langfuse>=3.10.1",
32
32
  "langgraph==1.0.2",
33
+ "litellm>=1.81.1",
33
34
  "openai>=1.92.1",
34
35
  "pydantic>=2.0.0",
35
36
  "pydantic-ai>=1.22.0",
@@ -53,6 +53,9 @@ class CrewAIAgentAdapter(AgentAdapter):
53
53
  if agent_response := getattr(result, 'raw', None) :
54
54
  return agent_response
55
55
 
56
+ if isinstance(agent_response, dict):
57
+ return agent_response
58
+
56
59
  return str(result)
57
60
  except Exception as e:
58
61
  raise RuntimeError(f"CrewAI agent invocation failed: {e}")
@@ -56,7 +56,8 @@ class WorkflowEngine:
56
56
 
57
57
  logger.info(
58
58
  f"Workflow loaded: {self.workflow_name} v{self.workflow_version} "
59
- f"({len(self.steps)} steps, {len(self.outcomes)} outcomes)"
59
+ f"({len(self.steps)} steps, {len(self.outcomes)} outcomes, "
60
+ f"{len(self.collector_node_field_map)} collector nodes)"
60
61
  )
61
62
 
62
63
  except Exception as e:
@@ -202,6 +203,7 @@ class WorkflowEngine:
202
203
  def load_steps(self):
203
204
  prepared_steps: list = []
204
205
  mfa_redirects: Dict[str, str] = {}
206
+ self.collector_node_field_map: Dict[str, str] = {} # Map of node_id -> field
205
207
 
206
208
  for step in self.config['steps']:
207
209
  step_id = step['id']
@@ -228,6 +230,14 @@ class WorkflowEngine:
228
230
 
229
231
  prepared_steps.append(step)
230
232
 
233
+ # Build collector node -> field map
234
+ for step in prepared_steps:
235
+ if step.get('action') == 'collect_input':
236
+ node_id = step.get('id')
237
+ field = step.get('field')
238
+ if node_id and field:
239
+ self.collector_node_field_map[node_id] = field
240
+
231
241
  for step in prepared_steps:
232
242
  if step['id'] in self.mfa_validator_steps: # MFA Validator
233
243
  continue
@@ -196,17 +196,6 @@ class CollectInputStrategy(ActionStrategy):
196
196
  if context_value is None:
197
197
  return
198
198
 
199
- # Check if this node has already executed - if so, don't overwrite the collected value
200
- execution_order = state.get(WorkflowKeys.NODE_EXECUTION_ORDER, [])
201
- if self.step_id in execution_order:
202
- logger.info(f"Skipping context value for '{self.field}' - node '{self.step_id}' already executed")
203
- span.add_event("context.value_skipped", {
204
- "field": self.field,
205
- "reason": "node_already_executed",
206
- "existing_value": str(state.get(self.field))
207
- })
208
- return
209
-
210
199
  logger.info(f"Using context value for '{self.field}': {context_value}")
211
200
  state[self.field] = context_value
212
201
  span.add_event("context.value_used", {"field": self.field, "value": str(context_value)})
@@ -505,7 +494,7 @@ class CollectInputStrategy(ActionStrategy):
505
494
  def _handle_structured_output_transition(self, state: Dict[str, Any], conversation: List, agent_response: Any) -> Dict[str, Any]:
506
495
 
507
496
  try:
508
- agent_response = json.loads(agent_response)
497
+ agent_response = json.loads(agent_response) if isinstance(agent_response, str) else agent_response
509
498
  except (json.JSONDecodeError, TypeError, ValueError):
510
499
  pass
511
500
 
@@ -83,26 +83,35 @@ class WorkflowTool:
83
83
  callback_handler = CallbackHandler()
84
84
  config = {"configurable": {"thread_id": thread_id}, "callbacks": [callback_handler]}
85
85
 
86
- span.add_event("context.updated", {"fields": list(initial_context.keys())})
87
-
88
86
  state = self.graph.get_state(config)
89
87
 
90
- # Update engine context on both resume and fresh start
91
- # Note: collect_input nodes will check NODE_EXECUTION_ORDER to avoid
92
- # overwriting already-collected values
93
- self.engine.update_context(initial_context)
94
-
88
+ # Intelligently update context based on workflow state
95
89
  if state.next:
90
+ # Workflow is resuming - only update fields that haven't been collected yet
96
91
  span.set_attribute("workflow.resumed", True)
97
92
  logger.info(f"[WorkflowTool] Resuming interrupted workflow {self.name} (thread: {thread_id})")
93
+
94
+ filtered_context = self._filter_already_collected_fields(state.values, initial_context)
95
+ self.engine.update_context(filtered_context)
96
+
97
+ span.add_event("context.updated", {
98
+ "fields": list(filtered_context.keys()),
99
+ "filtered_out": list(set(initial_context.keys()) - set(filtered_context.keys()))
100
+ })
101
+
98
102
  result = self.graph.invoke(
99
- Command(resume=user_message or "", update=initial_context),
103
+ Command(resume=user_message or "", update=filtered_context),
100
104
  config=config
101
105
  )
102
106
 
103
107
  else:
108
+ # Fresh start - update all fields from initial_context
104
109
  span.set_attribute("workflow.resumed", False)
105
110
  logger.info(f"[WorkflowTool] Starting fresh workflow {self.name} (thread: {thread_id})")
111
+
112
+ self.engine.update_context(initial_context)
113
+ span.add_event("context.updated", {"fields": list(initial_context.keys())})
114
+
106
115
  result = self.graph.invoke(initial_context, config=config)
107
116
 
108
117
  final_state = self.graph.get_state(config)
@@ -129,6 +138,51 @@ class WorkflowTool:
129
138
  span.set_attribute("workflow.status", "completed")
130
139
  return self.engine.get_outcome_message(result)
131
140
 
141
+ def _filter_already_collected_fields(
142
+ self,
143
+ current_state: Dict[str, Any],
144
+ initial_context: Optional[Dict[str, Any]]
145
+ ) -> Dict[str, Any]:
146
+ """
147
+ Filter initial_context to exclude fields that have already been collected.
148
+
149
+ Args:
150
+ current_state: Current workflow state
151
+ initial_context: Context to filter
152
+
153
+ Returns:
154
+ Filtered context with only uncollected fields
155
+ """
156
+ if not initial_context:
157
+ return {}
158
+
159
+ from .core.constants import WorkflowKeys
160
+
161
+ execution_order = current_state.get(WorkflowKeys.NODE_EXECUTION_ORDER, [])
162
+
163
+ node_to_field_map = self.engine.collector_node_field_map
164
+
165
+ # Determine which fields have already been collected
166
+ collected_fields = set()
167
+ for executed_node_id in execution_order:
168
+ if executed_node_id in node_to_field_map:
169
+ collected_fields.add(node_to_field_map[executed_node_id])
170
+
171
+ # Filter initial_context to exclude already-collected fields
172
+ filtered_context = {
173
+ field: value
174
+ for field, value in initial_context.items()
175
+ if field not in collected_fields
176
+ }
177
+
178
+ if collected_fields:
179
+ logger.info(
180
+ f"[WorkflowTool] Filtered out already-collected fields: {collected_fields}. "
181
+ f"Updating context with: {list(filtered_context.keys())}"
182
+ )
183
+
184
+ return filtered_context
185
+
132
186
  def resume(
133
187
  self,
134
188
  thread_id: str,
@@ -184,42 +184,8 @@ class TestCollectInputStrategyRefactor:
184
184
  assert conversation[0]["role"] == "assistant"
185
185
  assert conversation[0]["content"] == "Hello! What's your name?"
186
186
 
187
- def test_context_value_not_overwritten_when_node_already_executed(self):
188
- """Test that context values don't overwrite already-collected field values on resume"""
189
- step_config = {
190
- "id": "collect_customer_id",
191
- "field": "customer_id",
192
- "agent": {"name": "test_agent"}
193
- }
194
- engine_context = MagicMock()
195
- engine_context.get_config_value.return_value = "history_based"
196
- # Mock context value (from initial_context on resume)
197
- engine_context.get_context_value.return_value = "CTX_12345"
198
-
199
- strategy = CollectInputStrategy(step_config, engine_context)
200
-
201
- # Simulate state where this node has already executed and collected a value
202
- state = {
203
- "customer_id": "COLLECTED_67890", # Already collected value
204
- WorkflowKeys.NODE_EXECUTION_ORDER: ["collect_customer_id"] # Node already executed
205
- }
206
-
207
- span = MagicMock()
208
-
209
- # Call _apply_context_value
210
- strategy._apply_context_value(state, span)
211
-
212
- # Verify the collected value was NOT overwritten
213
- assert state["customer_id"] == "COLLECTED_67890"
214
- # Verify the context.value_skipped event was logged
215
- span.add_event.assert_called_once()
216
- event_call = span.add_event.call_args
217
- assert event_call[0][0] == "context.value_skipped"
218
- assert event_call[0][1]["field"] == "customer_id"
219
- assert event_call[0][1]["reason"] == "node_already_executed"
220
-
221
- def test_context_value_applied_when_node_not_yet_executed(self):
222
- """Test that context values ARE applied when node hasn't executed yet"""
187
+ def test_context_value_applied_from_engine_context(self):
188
+ """Test that context values are applied from engine context"""
223
189
  step_config = {
224
190
  "id": "collect_customer_id",
225
191
  "field": "customer_id",
@@ -232,10 +198,8 @@ class TestCollectInputStrategyRefactor:
232
198
 
233
199
  strategy = CollectInputStrategy(step_config, engine_context)
234
200
 
235
- # Simulate state where this node has NOT executed yet
236
- state = {
237
- WorkflowKeys.NODE_EXECUTION_ORDER: [] # Empty - node not executed
238
- }
201
+ # Simulate state
202
+ state = {}
239
203
 
240
204
  span = MagicMock()
241
205
 
@@ -1,414 +0,0 @@
1
- # Async Function Examples - Complete Guide
2
-
3
- This directory contains complete examples of how to use `call_async_function` with the interrupt/resume pattern in Soprano SDK workflows.
4
-
5
- ## Files
6
-
7
- - **`payment_async_workflow.yaml`** - Complete workflow YAML demonstrating async functions
8
- - **`payment_async_functions.py`** - Python implementation of async functions
9
- - **`test_payment_async.py`** - Comprehensive test examples
10
- - **`ASYNC_FUNCTIONS_README.md`** - This file
11
-
12
- ## What is an Async Function?
13
-
14
- An async function allows your workflow to pause while waiting for an external system to complete processing. This is useful for:
15
-
16
- - **Payment verification** - Wait for payment gateway to verify
17
- - **Identity verification** - Wait for KYC/identity checks
18
- - **Background jobs** - Wait for long-running computations
19
- - **External APIs** - Wait for third-party service responses
20
- - **Manual approvals** - Wait for human review
21
-
22
- ## How It Works
23
-
24
- ### Two-Phase Execution Pattern
25
-
26
- #### Phase 1: Initial Call (Interrupt)
27
- ```python
28
- def verify_payment(state: Dict[str, Any]) -> Dict[str, Any]:
29
- # Initiate async operation
30
- job_id = start_external_verification(state["payment_amount"])
31
-
32
- # Return "pending" to pause workflow
33
- return {
34
- "status": "pending",
35
- "job_id": job_id,
36
- "webhook_url": f"https://api.example.com/webhook/{job_id}",
37
- "estimated_time": "5-10 seconds"
38
- }
39
- ```
40
-
41
- When you return `{"status": "pending", ...}`:
42
- 1. Workflow calls `interrupt()` with the pending metadata
43
- 2. Workflow pauses execution
44
- 3. External system processes asynchronously
45
- 4. Workflow state is persisted
46
-
47
- #### Phase 2: Resume (Complete)
48
- ```python
49
- # External system completes and calls your webhook
50
- @app.post("/webhook/payment/{job_id}")
51
- async def payment_webhook(job_id: str, result: Dict):
52
- # Get workflow config for this job
53
- config = get_workflow_config(job_id)
54
-
55
- # Resume workflow with result
56
- graph.update_state(config, Command(resume=result))
57
- ```
58
-
59
- When you resume with `Command(resume=result)`:
60
- 1. The `interrupt()` call returns with `result`
61
- 2. Result is stored in the `output` field
62
- 3. Workflow evaluates `transitions` based on result
63
- 4. Workflow continues to next step
64
-
65
- ## Quick Start
66
-
67
- ### 1. Define in Workflow YAML
68
-
69
- ```yaml
70
- steps:
71
- - id: verify_payment
72
- action: call_async_function
73
- function: "payment_async_functions.verify_payment"
74
- output: verification_result
75
- transitions:
76
- - condition: "verified"
77
- ref: "status"
78
- next: approved
79
- - condition: "rejected"
80
- ref: "status"
81
- next: rejected
82
- ```
83
-
84
- ### 2. Implement Async Function
85
-
86
- ```python
87
- def verify_payment(state: Dict[str, Any]) -> Dict[str, Any]:
88
- """
89
- Returns pending status to pause workflow.
90
- External system will later resume with final result.
91
- """
92
- payment_amount = state.get("payment_amount")
93
- job_id = f"pay_{uuid.uuid4().hex[:8]}"
94
-
95
- # Call external API to start verification
96
- payment_gateway.verify_async(
97
- amount=payment_amount,
98
- callback_url=f"https://your-api.com/webhook/{job_id}"
99
- )
100
-
101
- # Return pending to interrupt workflow
102
- return {
103
- "status": "pending",
104
- "job_id": job_id,
105
- "webhook_url": f"https://your-api.com/webhook/{job_id}"
106
- }
107
- ```
108
-
109
- ### 3. Create Webhook Handler
110
-
111
- ```python
112
- from langgraph.types import Command
113
-
114
- @app.post("/webhook/payment/{job_id}")
115
- async def payment_webhook(job_id: str, result: Dict):
116
- """
117
- Receives callback from external payment gateway.
118
- Resumes the workflow with the result.
119
- """
120
- # Retrieve workflow config from database
121
- config = db.get_workflow_config(job_id)
122
-
123
- # Resume workflow with result
124
- # The result will be:
125
- # 1. Returned by interrupt()
126
- # 2. Stored in the output field
127
- # 3. Used for transition routing
128
- graph.update_state(config, Command(resume=result))
129
-
130
- return {"status": "resumed"}
131
- ```
132
-
133
- ## Checking Pending Status
134
-
135
- ### Method 1: Check Function Return Value
136
- ```python
137
- result = verify_payment(state)
138
-
139
- # Check if function wants to pause
140
- if result.get("status") == "pending":
141
- print("Function is pending")
142
- print(f"Job ID: {result['job_id']}")
143
- print(f"Webhook: {result['webhook_url']}")
144
- ```
145
-
146
- ### Method 2: Check Workflow State
147
- ```python
148
- # After workflow execution
149
- if state[WorkflowKeys.STATUS] == f"{step_id}_pending":
150
- print("Workflow is pending")
151
-
152
- # Or use the strategy method
153
- if strategy._is_async_pending(state):
154
- print("Async operation is pending")
155
- ```
156
-
157
- ### Method 3: Check Stored Pending Metadata
158
- ```python
159
- # Pending metadata is stored in state
160
- pending_key = f"_async_pending_{step_id}"
161
- if pending_key in state:
162
- metadata = state[pending_key]
163
- print(f"Pending: {metadata}")
164
- ```
165
-
166
- ## Accessing Interrupt and Resume Data
167
-
168
- ### Accessing the Three Interrupt Values
169
-
170
- When `interrupt()` is called, it receives:
171
- ```python
172
- {
173
- "type": "async", # Always "async" for async functions
174
- "step_id": "verify_payment", # The step ID
175
- "pending": { # Metadata from your function
176
- "status": "pending",
177
- "job_id": "pay_123",
178
- "webhook_url": "...",
179
- # ... any other data you returned
180
- }
181
- }
182
- ```
183
-
184
- Access these values:
185
- ```python
186
- # During workflow execution
187
- with patch('soprano_sdk.nodes.async_function.interrupt') as mock_interrupt:
188
- strategy.execute(state)
189
-
190
- interrupt_args = mock_interrupt.call_args[0][0]
191
-
192
- print(interrupt_args["type"]) # "async"
193
- print(interrupt_args["step_id"]) # "verify_payment"
194
- print(interrupt_args["pending"]) # Your pending metadata
195
- ```
196
-
197
- ### Accessing Resume Data
198
-
199
- When you resume with `Command(resume=result)`, the result is:
200
- 1. Returned by the `interrupt()` call
201
- 2. Stored in the `output` field
202
- 3. Used for transition routing
203
-
204
- ```python
205
- # Resume with this data
206
- async_result = {
207
- "job_id": "pay_123",
208
- "status": "verified",
209
- "verification_id": "VRF_789",
210
- "amount_verified": 100.00,
211
- "verified_at": "2026-01-12T10:30:00Z"
212
- }
213
-
214
- graph.update_state(config, Command(resume=async_result))
215
-
216
- # After resume, access via output field
217
- verification_result = state["verification_result"]
218
- print(verification_result["status"]) # "verified"
219
- print(verification_result["verification_id"]) # "VRF_789"
220
- print(verification_result["amount_verified"]) # 100.00
221
- ```
222
-
223
- ### Accessing Nested Resume Data with 'ref'
224
-
225
- Use `ref` in transitions to check nested fields:
226
- ```yaml
227
- transitions:
228
- - condition: "approved"
229
- ref: "result.status" # Checks nested field
230
- next: success
231
- ```
232
-
233
- Access nested data:
234
- ```python
235
- async_result = {
236
- "result": {
237
- "status": "approved",
238
- "verification": {
239
- "score": 95,
240
- "details": "All checks passed"
241
- }
242
- }
243
- }
244
-
245
- # After resume
246
- result = state["verification_data"]
247
- print(result["result"]["status"]) # "approved"
248
- print(result["result"]["verification"]["score"]) # 95
249
- print(result["result"]["verification"]["details"]) # "All checks passed"
250
- ```
251
-
252
- ## Complete Example: Two-Phase Execution
253
-
254
- ### Phase 1: Initial Call
255
- ```python
256
- # Execute workflow
257
- state = {"payment_amount": 100.00}
258
- result = workflow.execute(state)
259
-
260
- # Workflow interrupts with pending
261
- print(state[WorkflowKeys.STATUS]) # "verify_payment_pending"
262
- print(state["_async_pending_verify_payment"]) # Pending metadata
263
- ```
264
-
265
- ### External Processing
266
- ```python
267
- # External payment gateway processes
268
- # (This happens outside your workflow)
269
- time.sleep(5) # Simulate processing
270
- payment_gateway.verify(job_id="pay_123")
271
- ```
272
-
273
- ### Phase 2: Resume
274
- ```python
275
- # Payment gateway calls your webhook
276
- result = {
277
- "status": "verified",
278
- "verification_id": "VRF_789",
279
- "amount_verified": 100.00
280
- }
281
-
282
- # Resume workflow
283
- graph.update_state(config, Command(resume=result))
284
-
285
- # Workflow completes
286
- print(state[WorkflowKeys.STATUS]) # "verify_payment_approved"
287
- print(state["verification_result"]) # Full result data
288
- ```
289
-
290
- ## Synchronous Completion (No Pending)
291
-
292
- If your function can complete immediately, just return the result directly:
293
-
294
- ```python
295
- def verify_payment_sync(state: Dict[str, Any]) -> Dict[str, Any]:
296
- """Completes immediately without pending."""
297
- return {
298
- "status": "verified",
299
- "verification_id": "VRF_INSTANT",
300
- "amount_verified": state["payment_amount"]
301
- }
302
- ```
303
-
304
- When you DON'T return `{"status": "pending"}`:
305
- - No workflow interrupt
306
- - Result immediately available
307
- - Transitions evaluated right away
308
- - Workflow continues to next step
309
-
310
- ## Running the Examples
311
-
312
- ### 1. View Function Implementation
313
- ```bash
314
- cat examples/payment_async_functions.py
315
- ```
316
-
317
- ### 2. Run Demo Script
318
- ```bash
319
- python3 examples/payment_async_functions.py
320
- ```
321
-
322
- ### 3. Run Tests (requires dependencies)
323
- ```bash
324
- cd ..
325
- python -m pytest tests/test_async_function.py -v
326
- ```
327
-
328
- ### 4. Run Test Examples
329
- ```bash
330
- python3 examples/test_payment_async.py
331
- ```
332
-
333
- ## Key Concepts Summary
334
-
335
- | Concept | Description |
336
- |---------|-------------|
337
- | **Pending Status** | Return `{"status": "pending", ...}` to pause workflow |
338
- | **Interrupt** | Workflow calls `interrupt()` with pending metadata |
339
- | **Resume** | Call `Command(resume=result)` to continue workflow |
340
- | **Output Field** | Resume result is stored in the configured output field |
341
- | **Transitions** | Use result fields to route to next step |
342
- | **State Keys** | Pending data stored at `_async_pending_{step_id}` |
343
- | **Status Key** | Workflow status becomes `{step_id}_pending` |
344
-
345
- ## Common Patterns
346
-
347
- ### Pattern 1: External API Call
348
- ```python
349
- def call_external_api(state):
350
- job_id = external_api.start_job(state["data"])
351
- return {"status": "pending", "job_id": job_id}
352
- ```
353
-
354
- ### Pattern 2: Manual Review
355
- ```python
356
- def request_manual_review(state):
357
- review_id = create_review_task(state)
358
- return {
359
- "status": "pending",
360
- "review_id": review_id,
361
- "webhook_url": f"/webhook/review/{review_id}"
362
- }
363
- ```
364
-
365
- ### Pattern 3: Polling (Not Recommended)
366
- ```python
367
- # DON'T DO THIS - Use webhooks instead
368
- def check_status(state):
369
- if job_complete(state["job_id"]):
370
- return {"status": "complete"}
371
- return {"status": "pending"} # Will keep checking
372
- ```
373
-
374
- ## Best Practices
375
-
376
- 1. **Always use webhooks** - Don't poll for completion
377
- 2. **Include job_id** - Track operations across systems
378
- 3. **Store webhook URL** - Document where to send results
379
- 4. **Add timeout handling** - What if webhook never arrives?
380
- 5. **Include error cases** - Handle failures in transitions
381
- 6. **Log everything** - Track async operation lifecycle
382
- 7. **Persist state** - Use workflow persistence for reliability
383
-
384
- ## Troubleshooting
385
-
386
- ### Workflow Not Resuming
387
- - Verify webhook URL is correct
388
- - Check external system is calling webhook
389
- - Confirm `Command(resume=result)` is being called
390
- - Verify config matches original workflow
391
-
392
- ### Wrong Transition
393
- - Check `ref` field matches result structure
394
- - Verify `condition` matches exact value
395
- - Use nested refs for complex data: `ref: "result.status"`
396
-
397
- ### Missing Data
398
- - Confirm resume result includes all needed fields
399
- - Check output field name matches workflow YAML
400
- - Verify data structure matches expectations
401
-
402
- ## Additional Resources
403
-
404
- - Main async function implementation: `soprano_sdk/nodes/async_function.py`
405
- - Workflow engine: `soprano_sdk/core/engine.py`
406
- - Test suite: `tests/test_async_function.py`
407
- - LangGraph Command docs: https://langchain-ai.github.io/langgraph/
408
-
409
- ## Need Help?
410
-
411
- Check the test files for comprehensive examples:
412
- - `tests/test_async_function.py` - Unit tests
413
- - `examples/test_payment_async.py` - Practical examples
414
- - `examples/payment_async_functions.py` - Implementation patterns
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes