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.
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/PKG-INFO +1 -1
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/pyproject.toml +1 -1
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/constants.py +7 -0
- soprano_sdk-0.2.5/soprano_sdk/nodes/async_function.py +237 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/factory.py +2 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/routing/router.py +6 -1
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/tools.py +35 -13
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/schema.py +1 -1
- soprano_sdk-0.2.5/tests/test_async_function.py +379 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_collect_input_refactor.py +4 -2
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.github/workflows/test_build_and_publish.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.gitignore +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/.python-version +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/CLAUDE.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/LICENSE +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/README.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/booking_helpers.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/framework_example.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/greeting_functions.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/greeting_workflow.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/main.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/README.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/conversation_based.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/entity_based.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/persistence/mongodb_demo.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/return_functions.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/return_workflow.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/structured_output_example.yaml +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/README.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/crewai_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/crewai_tools.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/tools/langgraph_tools.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/supervisors/workflow_tools.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/tools/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/tools/address.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/examples/validator.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_demo.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_selfloop_demo.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/langgraph_v.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/main.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/return_fsm.excalidraw +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/return_state_machine.png +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/legacy/ui.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/visualize_workflow.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/workflow_demo.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/scripts/workflow_demo_ui.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/adaptor.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/factory.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/agents/structured_output.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/authenticators/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/authenticators/mfa.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/engine.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/rollback_strategies.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/core/state.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/engine.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/base.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/call_function.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/nodes/collect_input.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/routing/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/function.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/logger.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/template.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/tool.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/utils/tracing.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/__init__.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/soprano_sdk/validation/validator.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/debug_jinja2.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_agent_factory.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_external_values.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_inputs_validation.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_jinja2_path.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_jinja2_standalone.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_mfa_scenarios.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_persistence.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_structured_output.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/tests/test_transition_routing.py +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/todo.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/uv.lock +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/.eslintrc.cjs +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/.gitignore +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/README.md +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/index.html +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/package-lock.json +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/package.json +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/App.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/CustomNode.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/assets/react.svg +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/workflow-visualizer/src/main.jsx +0 -0
- {soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/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.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
|
-
|
|
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 =
|
|
110
|
-
return f"
|
|
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(
|
|
117
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
134
|
-
|
|
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)
|
|
@@ -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] == "
|
|
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] == "
|
|
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
|
{soprano_sdk-0.2.4 → soprano_sdk-0.2.5}/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
|