soprano-sdk 0.2.4__tar.gz → 0.2.5__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 (101) hide show
  1. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/PKG-INFO +1 -1
  2. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/pyproject.toml +1 -1
  3. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/constants.py +7 -0
  4. soprano_sdk-0.2.5/soprano_sdk/nodes/async_function.py +237 -0
  5. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/factory.py +2 -0
  6. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/routing/router.py +6 -1
  7. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/tools.py +35 -13
  8. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/schema.py +1 -1
  9. soprano_sdk-0.2.5/tests/test_async_function.py +379 -0
  10. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_collect_input_refactor.py +4 -2
  11. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.github/workflows/test_build_and_publish.yaml +0 -0
  12. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.gitignore +0 -0
  13. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.python-version +0 -0
  14. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/CLAUDE.md +0 -0
  15. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/LICENSE +0 -0
  16. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/README.md +0 -0
  17. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/__init__.py +0 -0
  18. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/booking_helpers.py +0 -0
  19. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
  20. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/framework_example.yaml +0 -0
  21. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/greeting_functions.py +0 -0
  22. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/greeting_workflow.yaml +0 -0
  23. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/main.py +0 -0
  24. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/README.md +0 -0
  25. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/conversation_based.py +0 -0
  26. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/entity_based.py +0 -0
  27. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/mongodb_demo.py +0 -0
  28. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/return_functions.py +0 -0
  29. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/return_workflow.yaml +0 -0
  30. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/structured_output_example.yaml +0 -0
  31. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/README.md +0 -0
  32. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/crewai_supervisor_ui.py +0 -0
  33. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
  34. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/__init__.py +0 -0
  35. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/crewai_tools.py +0 -0
  36. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/langgraph_tools.py +0 -0
  37. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/workflow_tools.py +0 -0
  38. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/tools/__init__.py +0 -0
  39. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/tools/address.py +0 -0
  40. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/validator.py +0 -0
  41. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_demo.py +0 -0
  42. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_selfloop_demo.py +0 -0
  43. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_v.py +0 -0
  44. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/main.py +0 -0
  45. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/return_fsm.excalidraw +0 -0
  46. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/return_state_machine.png +0 -0
  47. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/ui.py +0 -0
  48. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/visualize_workflow.py +0 -0
  49. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/workflow_demo.py +0 -0
  50. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/workflow_demo_ui.py +0 -0
  51. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/__init__.py +0 -0
  52. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/__init__.py +0 -0
  53. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/adaptor.py +0 -0
  54. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/factory.py +0 -0
  55. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/structured_output.py +0 -0
  56. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/authenticators/__init__.py +0 -0
  57. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/authenticators/mfa.py +0 -0
  58. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/__init__.py +0 -0
  59. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/engine.py +0 -0
  60. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/rollback_strategies.py +0 -0
  61. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/state.py +0 -0
  62. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/engine.py +0 -0
  63. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/__init__.py +0 -0
  64. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/base.py +0 -0
  65. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/call_function.py +0 -0
  66. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/collect_input.py +0 -0
  67. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/routing/__init__.py +0 -0
  68. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/__init__.py +0 -0
  69. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/function.py +0 -0
  70. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/logger.py +0 -0
  71. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/template.py +0 -0
  72. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/tool.py +0 -0
  73. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/tracing.py +0 -0
  74. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/__init__.py +0 -0
  75. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/validator.py +0 -0
  76. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/debug_jinja2.py +0 -0
  77. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_agent_factory.py +0 -0
  78. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_external_values.py +0 -0
  79. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_inputs_validation.py +0 -0
  80. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_jinja2_path.py +0 -0
  81. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_jinja2_standalone.py +0 -0
  82. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_mfa_scenarios.py +0 -0
  83. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_persistence.py +0 -0
  84. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_structured_output.py +0 -0
  85. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_transition_routing.py +0 -0
  86. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/todo.md +0 -0
  87. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/uv.lock +0 -0
  88. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/.eslintrc.cjs +0 -0
  89. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/.gitignore +0 -0
  90. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/README.md +0 -0
  91. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/index.html +0 -0
  92. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/package-lock.json +0 -0
  93. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/package.json +0 -0
  94. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/App.jsx +0 -0
  95. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/CustomNode.jsx +0 -0
  96. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
  97. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
  98. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
  99. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/assets/react.svg +0 -0
  100. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/main.jsx +0 -0
  101. {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/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.4
3
+ Version: 0.2.5
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "soprano-sdk"
7
- version = "0.2.4"
7
+ version = "0.2.5"
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"
@@ -22,6 +22,13 @@ class WorkflowKeys:
22
22
  class ActionType(Enum):
23
23
  COLLECT_INPUT_WITH_AGENT = 'collect_input_with_agent'
24
24
  CALL_FUNCTION = 'call_function'
25
+ CALL_ASYNC_FUNCTION = 'call_async_function'
26
+
27
+
28
+ class InterruptType:
29
+ """Interrupt type markers for workflow pauses"""
30
+ USER_INPUT = '__WORKFLOW_INTERRUPT__'
31
+ ASYNC = '__ASYNC_INTERRUPT__'
25
32
 
26
33
 
27
34
  class DataType(Enum):
@@ -0,0 +1,237 @@
1
+ """
2
+ Async Function Strategy - Handles asynchronous function calls with interrupt/resume pattern.
3
+
4
+ This strategy allows workflows to pause while waiting for an external async operation
5
+ to complete. The async function initiates an operation and returns a "pending" status.
6
+ The workflow then interrupts, and resumes when the external system calls back with the result.
7
+
8
+ Example YAML:
9
+ - id: verify_identity
10
+ action: call_async_function
11
+ function: "services.identity.start_verification"
12
+ output: verification_result
13
+ transitions:
14
+ - condition: "verified"
15
+ next: approved
16
+ - condition: "failed"
17
+ next: rejected
18
+
19
+ The async function should return:
20
+ - {"status": "pending", ...metadata} to trigger interrupt and wait for callback
21
+ - Any other dict for synchronous completion (no interrupt)
22
+
23
+ On resume, the async result is passed via Command(resume=async_result) and stored
24
+ in the output field, then transitions are evaluated.
25
+ """
26
+ from typing import Dict, Any
27
+
28
+ from langgraph.types import interrupt
29
+
30
+ from .base import ActionStrategy
31
+ from ..core.state import set_state_value, get_state_value
32
+ from ..core.constants import WorkflowKeys
33
+ from ..utils.logger import logger
34
+ from ..utils.template import get_nested_value
35
+
36
+
37
+ class AsyncFunctionStrategy(ActionStrategy):
38
+ """Strategy for executing async functions with interrupt/resume pattern."""
39
+
40
+ # Key for storing pending metadata in state
41
+ PENDING_KEY_PREFIX = '_async_pending_'
42
+
43
+ def __init__(self, step_config: Dict[str, Any], engine_context: Any):
44
+ super().__init__(step_config, engine_context)
45
+ self.function_path = step_config.get('function')
46
+ self.output_field = step_config.get('output')
47
+ self.transitions = self._get_transitions()
48
+ self.next_step = self._get_next_step()
49
+
50
+ if not self.function_path:
51
+ raise RuntimeError(f"Step '{self.step_id}' missing required 'function' property")
52
+
53
+ if not self.output_field:
54
+ raise RuntimeError(f"Step '{self.step_id}' missing required 'output' property")
55
+
56
+ @property
57
+ def _pending_key(self) -> str:
58
+ """State key for storing pending operation metadata."""
59
+ return f"{self.PENDING_KEY_PREFIX}{self.step_id}"
60
+
61
+ def _is_async_pending(self, state: Dict[str, Any]) -> bool:
62
+ """Check if this node is waiting for async operation to complete."""
63
+ return state.get(WorkflowKeys.STATUS) == f'{self.step_id}_pending'
64
+
65
+ def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
66
+ """Pre-execution hook."""
67
+ pass
68
+
69
+ def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
70
+ from ..utils.tracing import trace_node_execution
71
+
72
+ with trace_node_execution(
73
+ node_id=self.step_id,
74
+ node_type="call_async_function",
75
+ function=self.function_path,
76
+ output_field=self.output_field
77
+ ) as span:
78
+ # Check if we're resuming from a pending async operation
79
+ if self._is_async_pending(state):
80
+ span.add_event("async.resuming")
81
+ pending_metadata = state.get(self._pending_key, {})
82
+ else:
83
+ # First invocation - call the async function
84
+ result = self._call_function(state, span)
85
+
86
+ if self._is_pending_result(result):
87
+ # Async operation started - store metadata and prepare to interrupt
88
+ span.add_event("async.pending", {"metadata": str(result)})
89
+ self._set_status(state, "pending")
90
+ state[self._pending_key] = result
91
+ pending_metadata = result
92
+ else:
93
+ # Synchronous completion - no interrupt needed
94
+ span.add_event("async.sync_complete", {"result": str(result)})
95
+ return self._handle_sync_completion(state, result, span)
96
+
97
+ # Interrupt with pending metadata
98
+ # On resume, interrupt() returns the async result from Command(resume=...)
99
+ async_result = interrupt({
100
+ "type": "async",
101
+ "step_id": self.step_id,
102
+ "pending": pending_metadata
103
+ })
104
+
105
+ # Clean up pending state
106
+ if self._pending_key in state:
107
+ del state[self._pending_key]
108
+
109
+ span.add_event("async.resumed", {"result": str(async_result)})
110
+
111
+ # Store result and handle routing
112
+ return self._handle_async_completion(state, async_result, span)
113
+
114
+ def _call_function(self, state: Dict[str, Any], span) -> Any:
115
+ """Load and execute the async function."""
116
+ try:
117
+ logger.info(f"Loading async function: {self.function_path}")
118
+ func = self.engine_context.function_repository.load(self.function_path)
119
+ except Exception as e:
120
+ span.set_attribute("error", True)
121
+ span.set_attribute("error.type", "LoadError")
122
+ span.set_attribute("error.message", str(e))
123
+ raise RuntimeError(
124
+ f"Failed to load function '{self.function_path}' in step '{self.step_id}': {e}"
125
+ )
126
+
127
+ try:
128
+ logger.info(f"Calling async function: {self.function_path}")
129
+ result = func(state)
130
+ logger.info(f"Async function {self.function_path} returned: {result}")
131
+ return result
132
+ except Exception as e:
133
+ span.set_attribute("error", True)
134
+ span.set_attribute("error.type", type(e).__name__)
135
+ span.set_attribute("error.message", str(e))
136
+ raise RuntimeError(
137
+ f"Function '{self.function_path}' failed in step '{self.step_id}': {e}"
138
+ )
139
+
140
+ def _is_pending_result(self, result: Any) -> bool:
141
+ """Check if the function result indicates a pending async operation."""
142
+ if not isinstance(result, dict):
143
+ return False
144
+ return result.get("status") == "pending"
145
+
146
+ def _handle_sync_completion(
147
+ self,
148
+ state: Dict[str, Any],
149
+ result: Any,
150
+ span
151
+ ) -> Dict[str, Any]:
152
+ """Handle synchronous function completion (no async wait needed)."""
153
+ set_state_value(state, self.output_field, result)
154
+ self._track_computed_field(state)
155
+ return self._handle_routing(state, result, span)
156
+
157
+ def _handle_async_completion(
158
+ self,
159
+ state: Dict[str, Any],
160
+ async_result: Any,
161
+ span
162
+ ) -> Dict[str, Any]:
163
+ """Handle async operation completion after resume."""
164
+ set_state_value(state, self.output_field, async_result)
165
+ self._track_computed_field(state)
166
+ return self._handle_routing(state, async_result, span)
167
+
168
+ def _track_computed_field(self, state: Dict[str, Any]):
169
+ """Track this field as computed for rollback purposes."""
170
+ computed_fields = get_state_value(state, WorkflowKeys.COMPUTED_FIELDS, [])
171
+ if self.output_field not in computed_fields:
172
+ computed_fields.append(self.output_field)
173
+ set_state_value(state, WorkflowKeys.COMPUTED_FIELDS, computed_fields)
174
+
175
+ def _handle_routing(
176
+ self,
177
+ state: Dict[str, Any],
178
+ result: Any,
179
+ span
180
+ ) -> Dict[str, Any]:
181
+ """Determine next step based on transitions or default routing."""
182
+ if self.transitions:
183
+ return self._handle_transition_routing(state, result, span)
184
+ return self._handle_simple_routing(state, span)
185
+
186
+ def _handle_transition_routing(
187
+ self,
188
+ state: Dict[str, Any],
189
+ result: Any,
190
+ span
191
+ ) -> Dict[str, Any]:
192
+ """Route based on transition conditions matching the result."""
193
+ for transition in self.transitions:
194
+ check_value = result
195
+
196
+ # Support nested field references
197
+ if 'ref' in transition:
198
+ check_value = get_nested_value(result, transition['ref'])
199
+
200
+ condition = transition['condition']
201
+
202
+ # Support list of conditions
203
+ if isinstance(condition, list):
204
+ if check_value not in condition:
205
+ continue
206
+ elif check_value != condition:
207
+ continue
208
+
209
+ next_dest = transition['next']
210
+ logger.info(f"Async function matched transition, routing to {next_dest}")
211
+ span.add_event("transition.matched", {"next": next_dest})
212
+ self._set_status(state, next_dest)
213
+
214
+ if next_dest in self.engine_context.outcome_map:
215
+ self._set_outcome(state, next_dest)
216
+
217
+ return state
218
+
219
+ logger.warning(
220
+ f"No matching transition for async result '{result}' in step '{self.step_id}'"
221
+ )
222
+ span.add_event("transition.no_match", {"result": str(result)})
223
+ self._set_status(state, 'failed')
224
+ return state
225
+
226
+ def _handle_simple_routing(self, state: Dict[str, Any], span) -> Dict[str, Any]:
227
+ """Route to next step when no transitions are defined."""
228
+ self._set_status(state, 'success')
229
+
230
+ if self.next_step:
231
+ self._set_status(state, self.next_step)
232
+ span.add_event("routing.next_step", {"next": self.next_step})
233
+
234
+ if self.next_step in self.engine_context.outcome_map:
235
+ self._set_outcome(state, self.next_step)
236
+
237
+ return state
@@ -3,6 +3,7 @@ from typing import Dict, Any, Type, Callable
3
3
  from .base import ActionStrategy
4
4
  from .call_function import CallFunctionStrategy
5
5
  from .collect_input import CollectInputStrategy
6
+ from .async_function import AsyncFunctionStrategy
6
7
  from ..core.constants import ActionType
7
8
  from ..utils.logger import logger
8
9
 
@@ -44,3 +45,4 @@ class NodeFactory:
44
45
 
45
46
  NodeFactory.register(ActionType.COLLECT_INPUT_WITH_AGENT.value, CollectInputStrategy)
46
47
  NodeFactory.register(ActionType.CALL_FUNCTION.value, CallFunctionStrategy)
48
+ NodeFactory.register(ActionType.CALL_ASYNC_FUNCTION.value, AsyncFunctionStrategy)
@@ -32,6 +32,10 @@ class WorkflowRouter:
32
32
  logger.info(f"Self-loop: {self.step_id} (collecting)")
33
33
  return self.step_id
34
34
 
35
+ if status == f'{self.step_id}_pending':
36
+ logger.info(f"Self-loop: {self.step_id} (async pending)")
37
+ return self.step_id
38
+
35
39
  if status == f'{self.step_id}_error' :
36
40
  logger.info(f"Error encountered in {self.step_id}, ending workflow")
37
41
  return END
@@ -73,7 +77,8 @@ class WorkflowRouter:
73
77
  def get_routing_map(self, collector_nodes: List[str]) -> Dict[str, str]:
74
78
  routing_map = {}
75
79
 
76
- if self.action == 'collect_input_with_agent':
80
+ # Self-loop for nodes that can interrupt (agent input or async)
81
+ if self.action in ('collect_input_with_agent', 'call_async_function'):
77
82
  routing_map[self.step_id] = self.step_id
78
83
 
79
84
  for transition in self.transitions:
@@ -2,14 +2,15 @@
2
2
  Workflow Tools - Wraps workflows as callable tools for agent frameworks
3
3
  """
4
4
  from __future__ import annotations
5
+ import json
5
6
  import uuid
6
- from typing import Optional, Dict, Any
7
+ from typing import Optional, Dict, Any, Union
7
8
  from .utils.logger import logger
8
9
 
9
10
  from langfuse.langchain import CallbackHandler
10
11
 
11
12
  from .core.engine import load_workflow
12
- from .core.constants import MFAConfig
13
+ from .core.constants import MFAConfig, InterruptType
13
14
 
14
15
 
15
16
  class WorkflowTool:
@@ -103,35 +104,56 @@ class WorkflowTool:
103
104
  if not final_state.next and self.checkpointer:
104
105
  self.checkpointer.delete_thread(thread_id)
105
106
 
106
- # If workflow needs user input, return structured interrupt data
107
+ # If workflow needs user input or async operation, return structured interrupt data
107
108
  if "__interrupt__" in result and result["__interrupt__"]:
109
+ interrupt_value = result["__interrupt__"][0].value
110
+
111
+ # Check if this is an async interrupt
112
+ if isinstance(interrupt_value, dict) and interrupt_value.get("type") == "async":
113
+ span.set_attribute("workflow.status", "async_interrupted")
114
+ span.set_attribute("async.step_id", interrupt_value.get("step_id", ""))
115
+ pending_metadata = json.dumps(interrupt_value.get("pending", {}))
116
+ return f"{InterruptType.ASYNC}|{thread_id}|{self.name}|{pending_metadata}"
117
+
118
+ # User input interrupt (existing behavior)
108
119
  span.set_attribute("workflow.status", "interrupted")
109
- prompt = result["__interrupt__"][0].value
110
- return f"__WORKFLOW_INTERRUPT__|{thread_id}|{self.name}|{prompt}"
120
+ prompt = interrupt_value
121
+ return f"{InterruptType.USER_INPUT}|{thread_id}|{self.name}|{prompt}"
111
122
 
112
123
  # Workflow completed without interrupting
113
124
  span.set_attribute("workflow.status", "completed")
114
125
  return self.engine.get_outcome_message(result)
115
126
 
116
- def resume(self, thread_id: str, user_message: str) -> str:
117
- """Resume an interrupted workflow with user input
127
+ def resume(
128
+ self,
129
+ thread_id: str,
130
+ resume_value: Union[str, Dict[str, Any]]
131
+ ) -> str:
132
+ """Resume an interrupted workflow with user input or async result
118
133
 
119
134
  Args:
120
135
  thread_id: Thread ID of the interrupted workflow
121
- user_message: User's response to the interrupt prompt
136
+ resume_value: User's response (str) or async operation result (dict)
122
137
 
123
138
  Returns:
124
- Either another interrupt prompt or final outcome message
139
+ Either another interrupt prompt/async metadata or final outcome message
125
140
  """
126
141
  from langgraph.types import Command
127
142
 
128
143
  config = {"configurable": {"thread_id": thread_id}}
129
- result = self.graph.invoke(Command(resume=user_message), config=config)
144
+ result = self.graph.invoke(Command(resume=resume_value), config=config)
130
145
 
131
- # Check if workflow needs more input
146
+ # Check if workflow needs more input or has another async operation
132
147
  if "__interrupt__" in result and result["__interrupt__"]:
133
- prompt = result["__interrupt__"][0].value
134
- return f"__WORKFLOW_INTERRUPT__|{thread_id}|{self.name}|{prompt}"
148
+ interrupt_value = result["__interrupt__"][0].value
149
+
150
+ # Check if this is an async interrupt
151
+ if isinstance(interrupt_value, dict) and interrupt_value.get("type") == "async":
152
+ pending_metadata = json.dumps(interrupt_value.get("pending", {}))
153
+ return f"{InterruptType.ASYNC}|{thread_id}|{self.name}|{pending_metadata}"
154
+
155
+ # User input interrupt
156
+ return f"{InterruptType.USER_INPUT}|{thread_id}|{self.name}|{interrupt_value}"
135
157
 
136
158
  # Workflow completed
137
159
  return self.engine.get_outcome_message(result)
@@ -73,7 +73,7 @@ WORKFLOW_SCHEMA = {
73
73
  },
74
74
  "action": {
75
75
  "type": "string",
76
- "enum": ["collect_input_with_agent", "call_function"],
76
+ "enum": ["collect_input_with_agent", "call_function", "call_async_function"],
77
77
  "description": "Action type"
78
78
  },
79
79
  "field": {
@@ -0,0 +1,379 @@
1
+ """
2
+ Tests for AsyncFunctionStrategy - the call_async_function node type.
3
+ """
4
+
5
+ import pytest
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ from soprano_sdk.nodes.async_function import AsyncFunctionStrategy
9
+ from soprano_sdk.core.constants import WorkflowKeys
10
+
11
+
12
+ class TestAsyncFunctionStrategy:
13
+ """Tests for AsyncFunctionStrategy."""
14
+
15
+ @pytest.fixture
16
+ def engine_context(self):
17
+ """Create a mock engine context."""
18
+ context = MagicMock()
19
+ context.function_repository = MagicMock()
20
+ context.outcome_map = {"success": {}, "failed": {}}
21
+ return context
22
+
23
+ @pytest.fixture
24
+ def step_config(self):
25
+ """Basic step config for async function."""
26
+ return {
27
+ "id": "async_validate",
28
+ "action": "call_async_function",
29
+ "function": "test_module.async_function",
30
+ "output": "validation_result",
31
+ }
32
+
33
+ def test_init_requires_function(self, engine_context):
34
+ """Test that function property is required."""
35
+ step_config = {
36
+ "id": "test_step",
37
+ "output": "result",
38
+ }
39
+ with pytest.raises(RuntimeError, match="missing required 'function' property"):
40
+ AsyncFunctionStrategy(step_config, engine_context)
41
+
42
+ def test_init_requires_output(self, engine_context):
43
+ """Test that output property is required."""
44
+ step_config = {
45
+ "id": "test_step",
46
+ "function": "test.func",
47
+ }
48
+ with pytest.raises(RuntimeError, match="missing required 'output' property"):
49
+ AsyncFunctionStrategy(step_config, engine_context)
50
+
51
+ def test_init_success(self, step_config, engine_context):
52
+ """Test successful initialization."""
53
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
54
+ assert strategy.step_id == "async_validate"
55
+ assert strategy.function_path == "test_module.async_function"
56
+ assert strategy.output_field == "validation_result"
57
+
58
+ def test_is_pending_result_true(self, step_config, engine_context):
59
+ """Test detection of pending result."""
60
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
61
+
62
+ pending_result = {"status": "pending", "job_id": "123"}
63
+ assert strategy._is_pending_result(pending_result) is True
64
+
65
+ def test_is_pending_result_false_different_status(self, step_config, engine_context):
66
+ """Test non-pending status is not detected as pending."""
67
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
68
+
69
+ completed_result = {"status": "completed", "value": True}
70
+ assert strategy._is_pending_result(completed_result) is False
71
+
72
+ def test_is_pending_result_false_not_dict(self, step_config, engine_context):
73
+ """Test non-dict result is not detected as pending."""
74
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
75
+
76
+ assert strategy._is_pending_result(True) is False
77
+ assert strategy._is_pending_result("result") is False
78
+ assert strategy._is_pending_result(123) is False
79
+
80
+ def test_is_async_pending_true(self, step_config, engine_context):
81
+ """Test detection of async pending state."""
82
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
83
+ state = {WorkflowKeys.STATUS: "async_validate_pending"}
84
+
85
+ assert strategy._is_async_pending(state) is True
86
+
87
+ def test_is_async_pending_false(self, step_config, engine_context):
88
+ """Test non-pending state is not detected as pending."""
89
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
90
+ state = {WorkflowKeys.STATUS: "async_validate_success"}
91
+
92
+ assert strategy._is_async_pending(state) is False
93
+
94
+ def test_pending_key(self, step_config, engine_context):
95
+ """Test pending key generation."""
96
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
97
+ assert strategy._pending_key == "_async_pending_async_validate"
98
+
99
+ def test_sync_completion_stores_result(self, step_config, engine_context):
100
+ """Test that synchronous completion stores the result."""
101
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
102
+
103
+ # Mock function that returns sync result (not pending)
104
+ mock_func = MagicMock(return_value=True)
105
+ engine_context.function_repository.load.return_value = mock_func
106
+
107
+ state = {}
108
+
109
+ with patch.object(strategy, '_handle_routing', return_value=state):
110
+ strategy.execute(state)
111
+
112
+ assert state["validation_result"] is True
113
+
114
+ def test_sync_completion_tracks_computed_field(self, step_config, engine_context):
115
+ """Test that synchronous completion tracks computed fields."""
116
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
117
+
118
+ mock_func = MagicMock(return_value={"result": "success"})
119
+ engine_context.function_repository.load.return_value = mock_func
120
+
121
+ state = {}
122
+
123
+ with patch.object(strategy, '_handle_routing', return_value=state):
124
+ strategy.execute(state)
125
+
126
+ assert "validation_result" in state.get(WorkflowKeys.COMPUTED_FIELDS, [])
127
+
128
+ def test_transition_routing_matches_condition(self, engine_context):
129
+ """Test transition routing with matching condition."""
130
+ step_config = {
131
+ "id": "async_validate",
132
+ "function": "test.func",
133
+ "output": "result",
134
+ "transitions": [
135
+ {"condition": True, "next": "next_step"},
136
+ {"condition": False, "next": "failed"},
137
+ ]
138
+ }
139
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
140
+
141
+ mock_func = MagicMock(return_value=True)
142
+ engine_context.function_repository.load.return_value = mock_func
143
+
144
+ state = {}
145
+ result = strategy.execute(state)
146
+
147
+ assert result[WorkflowKeys.STATUS] == "async_validate_next_step"
148
+
149
+ def test_transition_routing_with_ref(self, engine_context):
150
+ """Test transition routing with nested ref field."""
151
+ step_config = {
152
+ "id": "async_validate",
153
+ "function": "test.func",
154
+ "output": "result",
155
+ "transitions": [
156
+ {"condition": "approved", "next": "success", "ref": "status"},
157
+ {"condition": "rejected", "next": "failed", "ref": "status"},
158
+ ]
159
+ }
160
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
161
+
162
+ mock_func = MagicMock(return_value={"status": "approved", "score": 95})
163
+ engine_context.function_repository.load.return_value = mock_func
164
+
165
+ state = {}
166
+ result = strategy.execute(state)
167
+
168
+ assert result[WorkflowKeys.STATUS] == "async_validate_success"
169
+
170
+ def test_transition_routing_list_condition(self, engine_context):
171
+ """Test transition routing with list of conditions."""
172
+ step_config = {
173
+ "id": "async_validate",
174
+ "function": "test.func",
175
+ "output": "result",
176
+ "transitions": [
177
+ {"condition": ["approved", "verified"], "next": "success"},
178
+ {"condition": ["rejected", "failed"], "next": "failed"},
179
+ ]
180
+ }
181
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
182
+
183
+ mock_func = MagicMock(return_value="verified")
184
+ engine_context.function_repository.load.return_value = mock_func
185
+
186
+ state = {}
187
+ result = strategy.execute(state)
188
+
189
+ assert result[WorkflowKeys.STATUS] == "async_validate_success"
190
+
191
+ def test_simple_routing_with_next_step(self, engine_context):
192
+ """Test simple routing when no transitions defined."""
193
+ step_config = {
194
+ "id": "async_validate",
195
+ "function": "test.func",
196
+ "output": "result",
197
+ "next": "process_result",
198
+ }
199
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
200
+
201
+ mock_func = MagicMock(return_value=True)
202
+ engine_context.function_repository.load.return_value = mock_func
203
+
204
+ state = {}
205
+ result = strategy.execute(state)
206
+
207
+ assert result[WorkflowKeys.STATUS] == "async_validate_process_result"
208
+
209
+ def test_simple_routing_sets_outcome(self, engine_context):
210
+ """Test that routing to outcome sets outcome_id."""
211
+ step_config = {
212
+ "id": "async_validate",
213
+ "function": "test.func",
214
+ "output": "result",
215
+ "next": "success",
216
+ }
217
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
218
+
219
+ mock_func = MagicMock(return_value=True)
220
+ engine_context.function_repository.load.return_value = mock_func
221
+
222
+ state = {}
223
+ result = strategy.execute(state)
224
+
225
+ assert result[WorkflowKeys.OUTCOME_ID] == "success"
226
+
227
+ def test_function_load_error(self, step_config, engine_context):
228
+ """Test handling of function load error."""
229
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
230
+
231
+ engine_context.function_repository.load.side_effect = ImportError("Module not found")
232
+
233
+ state = {}
234
+ with pytest.raises(RuntimeError, match="Failed to load function"):
235
+ strategy.execute(state)
236
+
237
+ def test_function_execution_error(self, step_config, engine_context):
238
+ """Test handling of function execution error."""
239
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
240
+
241
+ mock_func = MagicMock(side_effect=ValueError("Invalid input"))
242
+ engine_context.function_repository.load.return_value = mock_func
243
+
244
+ state = {}
245
+ with pytest.raises(RuntimeError, match="Function.*failed"):
246
+ strategy.execute(state)
247
+
248
+
249
+ class TestAsyncFunctionPendingBehavior:
250
+ """Tests for async pending interrupt behavior."""
251
+
252
+ @pytest.fixture
253
+ def engine_context(self):
254
+ context = MagicMock()
255
+ context.function_repository = MagicMock()
256
+ context.outcome_map = {}
257
+ return context
258
+
259
+ @pytest.fixture
260
+ def step_config(self):
261
+ return {
262
+ "id": "async_validate",
263
+ "function": "test.func",
264
+ "output": "result",
265
+ "transitions": [
266
+ {"condition": True, "next": "success"},
267
+ {"condition": False, "next": "failed"},
268
+ ]
269
+ }
270
+
271
+ def test_pending_result_sets_status(self, step_config, engine_context):
272
+ """Test that pending result sets status to pending."""
273
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
274
+
275
+ mock_func = MagicMock(return_value={"status": "pending", "job_id": "abc123"})
276
+ engine_context.function_repository.load.return_value = mock_func
277
+
278
+ state = {}
279
+
280
+ # Mock interrupt to simulate the async pause
281
+ # interrupt() returns the resume value when called during resume
282
+ with patch('soprano_sdk.nodes.async_function.interrupt', return_value=True) as mock_interrupt:
283
+ strategy.execute(state)
284
+
285
+ # Verify interrupt was called with correct async metadata
286
+ mock_interrupt.assert_called_once()
287
+ call_args = mock_interrupt.call_args[0][0]
288
+ assert call_args["type"] == "async"
289
+ assert call_args["step_id"] == "async_validate"
290
+ assert call_args["pending"]["status"] == "pending"
291
+ assert call_args["pending"]["job_id"] == "abc123"
292
+
293
+ def test_pending_result_stores_metadata_before_interrupt(self, step_config, engine_context):
294
+ """Test that pending result stores metadata in state before interrupt."""
295
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
296
+
297
+ pending_metadata = {"status": "pending", "job_id": "abc123", "callback_url": "http://..."}
298
+ mock_func = MagicMock(return_value=pending_metadata)
299
+ engine_context.function_repository.load.return_value = mock_func
300
+
301
+ state = {}
302
+ stored_state = {}
303
+
304
+ # Custom interrupt mock that captures state at the moment of interrupt
305
+ def capture_state_interrupt(value):
306
+ stored_state.update(dict(state)) # Copy state at interrupt time
307
+ return True # Return resume value
308
+
309
+ with patch('soprano_sdk.nodes.async_function.interrupt', side_effect=capture_state_interrupt):
310
+ strategy.execute(state)
311
+
312
+ # Check that metadata was stored before interrupt
313
+ assert stored_state["_async_pending_async_validate"] == pending_metadata
314
+ assert stored_state[WorkflowKeys.STATUS] == "async_validate_pending"
315
+
316
+ def test_resume_cleans_up_pending_metadata(self, step_config, engine_context):
317
+ """Test that resuming cleans up pending metadata from state."""
318
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
319
+
320
+ pending_metadata = {"status": "pending", "job_id": "abc123"}
321
+ mock_func = MagicMock(return_value=pending_metadata)
322
+ engine_context.function_repository.load.return_value = mock_func
323
+
324
+ state = {}
325
+
326
+ # Simulate the full flow: first call sets pending, second call (resume) cleans up
327
+ with patch('soprano_sdk.nodes.async_function.interrupt', return_value=True):
328
+ strategy.execute(state)
329
+
330
+ # After resume (interrupt returns async result), pending key should be cleaned up
331
+ assert "_async_pending_async_validate" not in state
332
+
333
+ def test_resume_uses_async_result_for_routing(self, step_config, engine_context):
334
+ """Test that async result from resume is used for routing."""
335
+ strategy = AsyncFunctionStrategy(step_config, engine_context)
336
+
337
+ pending_metadata = {"status": "pending", "job_id": "abc123"}
338
+ mock_func = MagicMock(return_value=pending_metadata)
339
+ engine_context.function_repository.load.return_value = mock_func
340
+
341
+ state = {}
342
+
343
+ # Mock interrupt to return True (simulating resume with async_result=True)
344
+ with patch('soprano_sdk.nodes.async_function.interrupt', return_value=True):
345
+ result = strategy.execute(state)
346
+
347
+ # Result should be True (from the mocked interrupt return value)
348
+ assert state["result"] is True
349
+ assert result[WorkflowKeys.STATUS] == "async_validate_success"
350
+
351
+
352
+ class TestAsyncFunctionNodeFactory:
353
+ """Test that AsyncFunctionStrategy is properly registered."""
354
+
355
+ def test_factory_registration(self):
356
+ """Test that call_async_function is registered in NodeFactory."""
357
+ from soprano_sdk.nodes.factory import NodeFactory
358
+ from soprano_sdk.core.constants import ActionType
359
+
360
+ assert NodeFactory.is_registered(ActionType.CALL_ASYNC_FUNCTION.value)
361
+
362
+ def test_factory_creates_async_strategy(self):
363
+ """Test that factory creates AsyncFunctionStrategy for call_async_function."""
364
+ from soprano_sdk.nodes.factory import NodeFactory
365
+
366
+ step_config = {
367
+ "id": "test_async",
368
+ "action": "call_async_function",
369
+ "function": "test.func",
370
+ "output": "result",
371
+ }
372
+ engine_context = MagicMock()
373
+ engine_context.function_repository = MagicMock()
374
+ engine_context.outcome_map = {}
375
+
376
+ node_fn = NodeFactory.create(step_config, engine_context)
377
+
378
+ # The returned function should be callable
379
+ assert callable(node_fn)
@@ -30,6 +30,7 @@ class TestCollectInputStrategyRefactor:
30
30
  def test_handle_max_attempts_default_message(self):
31
31
  """Test that default error message is used when on_max_attempts_reached is not provided"""
32
32
  step_config = {
33
+ "id": "collect_customer_name",
33
34
  "field": "customer_name",
34
35
  "agent": {"name": "test_agent"}
35
36
  }
@@ -41,7 +42,7 @@ class TestCollectInputStrategyRefactor:
41
42
 
42
43
  result = strategy._handle_max_attempts(state)
43
44
 
44
- assert result[WorkflowKeys.STATUS] == "max_attempts"
45
+ assert result[WorkflowKeys.STATUS] == "collect_customer_name_max_attempts"
45
46
  assert "customer_name" in result[WorkflowKeys.MESSAGES][0]
46
47
  assert "customer service" in result[WorkflowKeys.MESSAGES][0].lower()
47
48
 
@@ -49,6 +50,7 @@ class TestCollectInputStrategyRefactor:
49
50
  """Test that custom error message is used when on_max_attempts_reached is provided"""
50
51
  custom_message = "Custom error: Too many attempts. Please call 1-800-SUPPORT."
51
52
  step_config = {
53
+ "id": "collect_customer_name",
52
54
  "field": "customer_name",
53
55
  "agent": {"name": "test_agent"},
54
56
  "on_max_attempts_reached": custom_message
@@ -61,6 +63,6 @@ class TestCollectInputStrategyRefactor:
61
63
 
62
64
  result = strategy._handle_max_attempts(state)
63
65
 
64
- assert result[WorkflowKeys.STATUS] == "max_attempts"
66
+ assert result[WorkflowKeys.STATUS] == "collect_customer_name_max_attempts"
65
67
  assert result[WorkflowKeys.MESSAGES][0] == custom_message
66
68
  assert "1-800-SUPPORT" in result[WorkflowKeys.MESSAGES][0]
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