soprano-sdk 0.2.12__tar.gz → 0.2.13__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.
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/PKG-INFO +1 -1
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/pyproject.toml +1 -1
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/core/engine.py +11 -1
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/collect_input.py +0 -11
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/tools.py +62 -8
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_collect_input_refactor.py +4 -40
- soprano_sdk-0.2.12/examples/ASYNC_FUNCTIONS_README.md +0 -414
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/.github/workflows/test_build_and_publish.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/.gitignore +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/.python-version +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/CLAUDE.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/LICENSE +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/README.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/concert_booking/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/concert_booking/booking_helpers.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/framework_example.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/greeting_functions.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/greeting_workflow.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/main.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/payment_async_functions.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/payment_async_workflow.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/persistence/README.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/persistence/conversation_based.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/persistence/entity_based.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/persistence/mongodb_demo.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/return_functions.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/return_workflow.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/structured_output_example.yaml +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/README.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/crewai_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/tools/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/tools/crewai_tools.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/tools/langgraph_tools.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/supervisors/workflow_tools.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/test_payment_async.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/tools/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/tools/address.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/validator.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/langgraph_demo.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/langgraph_selfloop_demo.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/langgraph_v.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/main.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/return_fsm.excalidraw +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/return_state_machine.png +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/legacy/ui.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/scripts/visualize_workflow.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/scripts/workflow_demo.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/scripts/workflow_demo_ui.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/agents/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/agents/adaptor.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/agents/factory.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/agents/structured_output.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/authenticators/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/authenticators/mfa.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/core/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/core/constants.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/core/rollback_strategies.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/core/state.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/engine.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/async_function.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/base.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/call_function.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/nodes/factory.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/routing/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/routing/router.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/function.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/logger.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/template.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/tool.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/utils/tracing.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/validation/__init__.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/validation/schema.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/soprano_sdk/validation/validator.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/debug_jinja2.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_adaptor_logging.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_agent_factory.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_async_function.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_external_values.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_inputs_validation.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_jinja2_path.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_jinja2_standalone.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_mfa_scenarios.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_persistence.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_structured_output.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_transition_routing.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/tests/test_workflow_tool_context_update.py +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/todo.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/.eslintrc.cjs +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/.gitignore +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/README.md +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/index.html +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/package-lock.json +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/package.json +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/App.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/CustomNode.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/assets/react.svg +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/src/main.jsx +0 -0
- {soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/workflow-visualizer/vite.config.js +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soprano-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.13"
|
|
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"
|
|
@@ -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)})
|
|
@@ -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
|
-
#
|
|
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=
|
|
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
|
|
188
|
-
"""Test that context values
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
{soprano_sdk-0.2.12 → soprano_sdk-0.2.13}/examples/concert_booking/concert_ticket_booking.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|