soprano-sdk 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/PKG-INFO +1 -1
  2. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/pyproject.toml +1 -1
  3. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/__init__.py +2 -0
  4. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/authenticators/mfa.py +25 -13
  5. soprano_sdk-0.2.3/soprano_sdk/core/constants.py +114 -0
  6. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/engine.py +18 -5
  7. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/state.py +1 -0
  8. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/call_function.py +1 -0
  9. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/collect_input.py +3 -0
  10. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/tools.py +9 -3
  11. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/validator.py +34 -17
  12. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_mfa_scenarios.py +16 -25
  13. soprano_sdk-0.2.3/uv.lock +5163 -0
  14. soprano_sdk-0.2.2/examples/concert_booking/README.md +0 -108
  15. soprano_sdk-0.2.2/examples/concert_booking/TEST_RESULTS.md +0 -179
  16. soprano_sdk-0.2.2/examples/debit_card_block.yaml +0 -608
  17. soprano_sdk-0.2.2/soprano_sdk/core/constants.py +0 -76
  18. soprano_sdk-0.2.2/tests/test_mfa_error_handling.py +0 -778
  19. soprano_sdk-0.2.2/tests/test_mfa_validation.py +0 -545
  20. soprano_sdk-0.2.2/uv.lock +0 -5165
  21. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.github/workflows/test_build_and_publish.yaml +0 -0
  22. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.gitignore +0 -0
  23. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.python-version +0 -0
  24. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/CLAUDE.md +0 -0
  25. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/LICENSE +0 -0
  26. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/README.md +0 -0
  27. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/__init__.py +0 -0
  28. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/booking_helpers.py +0 -0
  29. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
  30. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/framework_example.yaml +0 -0
  31. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/greeting_functions.py +0 -0
  32. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/greeting_workflow.yaml +0 -0
  33. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/main.py +0 -0
  34. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/README.md +0 -0
  35. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/conversation_based.py +0 -0
  36. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/entity_based.py +0 -0
  37. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/mongodb_demo.py +0 -0
  38. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/return_functions.py +0 -0
  39. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/return_workflow.yaml +0 -0
  40. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/structured_output_example.yaml +0 -0
  41. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/README.md +0 -0
  42. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/crewai_supervisor_ui.py +0 -0
  43. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
  44. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/__init__.py +0 -0
  45. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/crewai_tools.py +0 -0
  46. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/langgraph_tools.py +0 -0
  47. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/workflow_tools.py +0 -0
  48. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/tools/__init__.py +0 -0
  49. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/tools/address.py +0 -0
  50. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/validator.py +0 -0
  51. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_demo.py +0 -0
  52. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_selfloop_demo.py +0 -0
  53. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_v.py +0 -0
  54. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/main.py +0 -0
  55. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/return_fsm.excalidraw +0 -0
  56. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/return_state_machine.png +0 -0
  57. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/ui.py +0 -0
  58. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/visualize_workflow.py +0 -0
  59. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/workflow_demo.py +0 -0
  60. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/workflow_demo_ui.py +0 -0
  61. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/__init__.py +0 -0
  62. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/adaptor.py +0 -0
  63. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/factory.py +0 -0
  64. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/structured_output.py +0 -0
  65. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/authenticators/__init__.py +0 -0
  66. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/__init__.py +0 -0
  67. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/rollback_strategies.py +0 -0
  68. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/engine.py +0 -0
  69. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/__init__.py +0 -0
  70. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/base.py +0 -0
  71. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/factory.py +0 -0
  72. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/routing/__init__.py +0 -0
  73. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/routing/router.py +0 -0
  74. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/__init__.py +0 -0
  75. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/function.py +0 -0
  76. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/logger.py +0 -0
  77. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/template.py +0 -0
  78. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/tool.py +0 -0
  79. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/tracing.py +0 -0
  80. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/__init__.py +0 -0
  81. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/schema.py +0 -0
  82. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/debug_jinja2.py +0 -0
  83. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_agent_factory.py +0 -0
  84. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_collect_input_refactor.py +0 -0
  85. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_external_values.py +0 -0
  86. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_inputs_validation.py +0 -0
  87. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_jinja2_path.py +0 -0
  88. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_jinja2_standalone.py +0 -0
  89. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_persistence.py +0 -0
  90. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_structured_output.py +0 -0
  91. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_transition_routing.py +0 -0
  92. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/todo.md +0 -0
  93. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/.eslintrc.cjs +0 -0
  94. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/.gitignore +0 -0
  95. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/README.md +0 -0
  96. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/index.html +0 -0
  97. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/package-lock.json +0 -0
  98. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/package.json +0 -0
  99. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/App.jsx +0 -0
  100. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/CustomNode.jsx +0 -0
  101. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
  102. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
  103. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
  104. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/assets/react.svg +0 -0
  105. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/main.jsx +0 -0
  106. {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/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.2
3
+ Version: 0.2.3
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.2"
7
+ version = "0.2.3"
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"
@@ -1,4 +1,5 @@
1
1
  from .core.engine import WorkflowEngine, load_workflow
2
+ from .core.constants import MFAConfig
2
3
  from .tools import WorkflowTool
3
4
 
4
5
  __version__ = "0.1.0"
@@ -6,5 +7,6 @@ __version__ = "0.1.0"
6
7
  __all__ = [
7
8
  "WorkflowEngine",
8
9
  "load_workflow",
10
+ "MFAConfig",
9
11
  "WorkflowTool",
10
12
  ]
@@ -1,6 +1,6 @@
1
1
  import requests
2
- from typing import TypedDict, Literal, NotRequired
3
- from soprano_sdk.core.constants import MFARestAuthorizerEnv
2
+ from typing import TypedDict, Literal, NotRequired, Optional
3
+ from soprano_sdk.core.constants import MFAConfig
4
4
 
5
5
 
6
6
  class MFAChallenge(TypedDict):
@@ -28,15 +28,21 @@ def build_path(base_url: str, path: str):
28
28
  return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
29
29
 
30
30
 
31
- def enforce_mfa_if_required(state: dict):
31
+ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None):
32
+ if mfa_config is None:
33
+ mfa_config = state.get('_mfa_config') or MFAConfig()
34
+
32
35
  _mfa : MFAState = state['_mfa']
33
36
  if _mfa['status'] == 'COMPLETED':
34
37
  return True
35
38
  generate_token_response = requests.post(
36
39
  build_path(
37
- base_url=MFARestAuthorizerEnv.GENERATE_TOKEN_BASE_URL.get_from_env(),
38
- path=MFARestAuthorizerEnv.GENERATE_TOKEN_PATH.get_from_env()
39
- ), json=_mfa['post_payload'], timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
40
+ base_url=mfa_config.generate_token_base_url,
41
+ path=mfa_config.generate_token_path
42
+ ),
43
+ json=_mfa['post_payload'],
44
+ timeout=mfa_config.api_timeout,
45
+ headers={"Authorization": f"Bearer {state['bearer_token']}"}
40
46
  )
41
47
  _, error = get_response(generate_token_response)
42
48
 
@@ -50,7 +56,10 @@ def enforce_mfa_if_required(state: dict):
50
56
  return False
51
57
 
52
58
 
53
- def mfa_validate_user_input(**state: dict):
59
+ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dict):
60
+ if mfa_config is None:
61
+ mfa_config = state.get('_mfa_config') or MFAConfig()
62
+
54
63
  _mfa : MFAState = state['_mfa']
55
64
  input_field_name = state['_active_input_field']
56
65
  if not state[input_field_name]:
@@ -61,9 +70,12 @@ def mfa_validate_user_input(**state: dict):
61
70
  post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
62
71
  validate_token_response = requests.post(
63
72
  build_path(
64
- base_url=MFARestAuthorizerEnv.VALIDATE_TOKEN_BASE_URL.get_from_env(),
65
- path=MFARestAuthorizerEnv.VALIDATE_TOKEN_PATH.get_from_env()
66
- ), json=post_payload, timeout=30, headers={"Authorization": f"Bearer {state['bearer_token']}"}
73
+ base_url=mfa_config.validate_token_base_url,
74
+ path=mfa_config.validate_token_path
75
+ ),
76
+ json=post_payload,
77
+ timeout=mfa_config.api_timeout,
78
+ headers={"Authorization": f"Bearer {state['bearer_token']}"}
67
79
  )
68
80
  _mfa['retry_count'] += 1
69
81
  response, error = get_response(validate_token_response)
@@ -78,11 +90,11 @@ def mfa_validate_user_input(**state: dict):
78
90
 
79
91
  authorize = requests.post(
80
92
  build_path(
81
- base_url=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_BASE_URL.get_from_env(),
82
- path=MFARestAuthorizerEnv.AUTHORIZE_TOKEN_PATH.get_from_env()
93
+ base_url=mfa_config.authorize_token_base_url,
94
+ path=mfa_config.authorize_token_path
83
95
  ),
84
96
  json=post_payload,
85
- timeout=30,
97
+ timeout=mfa_config.api_timeout,
86
98
  headers={"Authorization": f"Bearer {state['bearer_token']}"}
87
99
  )
88
100
  if authorize.status_code == 204:
@@ -0,0 +1,114 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class WorkflowKeys:
8
+ STEP_ID = '_step_id'
9
+ STATUS = '_status'
10
+ OUTCOME_ID = '_outcome_id'
11
+ MESSAGES = '_messages'
12
+ CONVERSATIONS = '_conversations'
13
+ STATE_HISTORY = '_state_history'
14
+ COLLECTOR_NODES = '_collector_nodes'
15
+ ATTEMPT_COUNTS = '_attempt_counts'
16
+ NODE_EXECUTION_ORDER = '_node_execution_order'
17
+ NODE_FIELD_MAP = '_node_field_map'
18
+ COMPUTED_FIELDS = '_computed_fields'
19
+ ERROR = 'error'
20
+
21
+
22
+ class ActionType(Enum):
23
+ COLLECT_INPUT_WITH_AGENT = 'collect_input_with_agent'
24
+ CALL_FUNCTION = 'call_function'
25
+
26
+
27
+ class DataType(Enum):
28
+ TEXT = 'text'
29
+ NUMBER = 'number'
30
+ DOUBLE = 'double'
31
+ BOOLEAN = 'boolean'
32
+ LIST = 'list'
33
+ DICT = 'dict'
34
+ ANY = "any"
35
+
36
+
37
+ class OutcomeType(Enum):
38
+ SUCCESS = 'success'
39
+ FAILURE = 'failure'
40
+
41
+
42
+ class StatusPattern:
43
+ COLLECTING = '{step_id}_collecting'
44
+ MAX_ATTEMPTS = '{step_id}_max_attempts'
45
+ NEXT_STEP = '{step_id}_{next_step}'
46
+ SUCCESS = '{step_id}_success'
47
+ FAILED = '{step_id}_failed'
48
+ INTENT_CHANGE = '{step_id}_{target_node}'
49
+
50
+
51
+ class TransitionPattern:
52
+ CAPTURED = '{field}_CAPTURED:'
53
+ FAILED = '{field}_FAILED:'
54
+ INTENT_CHANGE = 'INTENT_CHANGE:'
55
+
56
+
57
+ DEFAULT_MAX_ATTEMPTS = 3
58
+ DEFAULT_MODEL = 'gpt-4o-mini'
59
+ DEFAULT_TIMEOUT = 300
60
+
61
+ MAX_ATTEMPTS_MESSAGE = "I'm having trouble understanding your {field}. Please contact customer service for assistance."
62
+ WORKFLOW_COMPLETE_MESSAGE = "Workflow completed."
63
+
64
+
65
+ class MFAConfig(BaseSettings):
66
+ """
67
+ Configuration for MFA REST API endpoints.
68
+
69
+ Values can be provided during initialization or will be automatically
70
+ loaded from environment variables with the same name (uppercase).
71
+
72
+ Example:
73
+ # Load from environment variables
74
+ config = MFAConfig()
75
+
76
+ # Or provide specific values
77
+ config = MFAConfig(
78
+ generate_token_base_url="https://api.example.com",
79
+ generate_token_path="/v1/mfa/generate"
80
+ )
81
+ """
82
+ generate_token_base_url: Optional[str] = Field(
83
+ default=None,
84
+ description="Base URL for the generate token endpoint"
85
+ )
86
+ generate_token_path: Optional[str] = Field(
87
+ default=None,
88
+ description="Path for the generate token endpoint"
89
+ )
90
+ validate_token_base_url: Optional[str] = Field(
91
+ default=None,
92
+ description="Base URL for the validate token endpoint"
93
+ )
94
+ validate_token_path: Optional[str] = Field(
95
+ default=None,
96
+ description="Path for the validate token endpoint"
97
+ )
98
+ authorize_token_base_url: Optional[str] = Field(
99
+ default=None,
100
+ description="Base URL for the authorize token endpoint"
101
+ )
102
+ authorize_token_path: Optional[str] = Field(
103
+ default=None,
104
+ description="Path for the authorize token endpoint"
105
+ )
106
+ api_timeout: int = Field(
107
+ default=30,
108
+ description="API request timeout in seconds"
109
+ )
110
+
111
+ model_config = SettingsConfigDict(
112
+ case_sensitive=False,
113
+ extra='ignore'
114
+ )
@@ -7,7 +7,7 @@ from langgraph.constants import START
7
7
  from langgraph.graph import StateGraph
8
8
  from langgraph.graph.state import CompiledStateGraph
9
9
 
10
- from .constants import WorkflowKeys
10
+ from .constants import WorkflowKeys, MFAConfig
11
11
  from .state import create_state_model
12
12
  from ..nodes.factory import NodeFactory
13
13
  from ..routing.router import WorkflowRouter
@@ -19,7 +19,7 @@ from soprano_sdk.authenticators.mfa import MFANodeConfig
19
19
 
20
20
  class WorkflowEngine:
21
21
 
22
- def __init__(self, yaml_path: str, configs: dict):
22
+ def __init__(self, yaml_path: str, configs: dict, mfa_config: Optional[MFAConfig] = None):
23
23
  self.yaml_path = yaml_path
24
24
  self.configs = configs or {}
25
25
  logger.info(f"Loading workflow from: {yaml_path}")
@@ -29,7 +29,7 @@ class WorkflowEngine:
29
29
  self.config = yaml.safe_load(f)
30
30
 
31
31
  logger.info("Validating workflow configuration")
32
- validate_workflow(self.config)
32
+ validate_workflow(self.config, mfa_config=mfa_config or MFAConfig())
33
33
 
34
34
  self.workflow_name = self.config['name']
35
35
  self.workflow_description = self.config['description']
@@ -37,6 +37,7 @@ class WorkflowEngine:
37
37
  self.mfa_validator_steps: set[str] = set()
38
38
  self.steps: list = self.load_steps()
39
39
  self.step_map = {step['id']: step for step in self.steps}
40
+ self.mfa_config = (mfa_config or MFAConfig()) if self.mfa_validator_steps else None
40
41
  self.data_fields = self.load_data()
41
42
 
42
43
  self.outcomes = self.config['outcomes']
@@ -259,7 +260,7 @@ class WorkflowEngine:
259
260
  return data
260
261
 
261
262
 
262
- def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
263
+ def load_workflow(yaml_path: str, checkpointer=None, config=None, mfa_config: Optional[MFAConfig] = None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
263
264
  """
264
265
  Load a workflow from YAML configuration.
265
266
 
@@ -270,6 +271,8 @@ def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[Compi
270
271
  checkpointer: Optional checkpointer for state persistence.
271
272
  Defaults to InMemorySaver() if not provided.
272
273
  Example: MongoDBSaver for production persistence.
274
+ config: Optional configuration dictionary
275
+ mfa_config: Optional MFA configuration. If not provided, will load from environment variables.
273
276
 
274
277
  Returns:
275
278
  Tuple of (compiled_graph, engine) where:
@@ -278,11 +281,21 @@ def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[Compi
278
281
 
279
282
  Example:
280
283
  ```python
284
+ # Load with environment variables
281
285
  graph, engine = load_workflow("workflow.yaml")
286
+
287
+ # Or provide MFA configuration explicitly
288
+ from soprano_sdk.core.constants import MFAConfig
289
+ mfa_config = MFAConfig(
290
+ generate_token_base_url="https://api.example.com",
291
+ generate_token_path="/v1/mfa/generate"
292
+ )
293
+ graph, engine = load_workflow("workflow.yaml", mfa_config=mfa_config)
294
+
282
295
  result = graph.invoke({}, config={"configurable": {"thread_id": "123"}})
283
296
  message = engine.get_outcome_message(result)
284
297
  ```
285
298
  """
286
- engine = WorkflowEngine(yaml_path, configs=config)
299
+ engine = WorkflowEngine(yaml_path, configs=config, mfa_config=mfa_config)
287
300
  graph = engine.build_graph(checkpointer=checkpointer)
288
301
  return graph, engine
@@ -48,6 +48,7 @@ def create_state_model(data_fields: List[dict]):
48
48
  fields['_computed_fields'] = Annotated[List[str], replace]
49
49
  fields['error'] = Annotated[Optional[Dict[str, str]], replace]
50
50
  fields['_mfa'] = Annotated[Optional[Dict[str, str]], replace]
51
+ fields['_mfa_config'] = Annotated[Optional[Any], replace]
51
52
  fields['mfa_input'] = Annotated[Optional[Dict[str, str]], replace]
52
53
 
53
54
  return types.new_class('WorkflowState', (TypedDict,), {}, lambda ns: ns.update({'__annotations__': fields}))
@@ -39,6 +39,7 @@ class CallFunctionStrategy(ActionStrategy):
39
39
  if 'mfa' in self.step_config:
40
40
  state['_mfa'] = state.get('_mfa', {})
41
41
  state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
42
+ state['_mfa_config'] = self.engine_context.mfa_config
42
43
  template_loader = self.engine_context.get_config_value("template_loader", Environment())
43
44
  for k, v in self.step_config['mfa']['payload'].items():
44
45
  state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
@@ -116,6 +116,9 @@ class CollectInputStrategy(ActionStrategy):
116
116
 
117
117
  def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
118
118
  state['_active_input_field'] = self.step_config.get('field')
119
+ # Inject MFA config for MFA validator nodes
120
+ if self.step_id in self.engine_context.mfa_validator_steps:
121
+ state['_mfa_config'] = self.engine_context.mfa_config
119
122
 
120
123
  @property
121
124
  def _formatted_field_name(self) -> str:
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Workflow Tools - Wraps workflows as callable tools for agent frameworks
3
3
  """
4
-
4
+ from __future__ import annotations
5
5
  import uuid
6
6
  from typing import Optional, Dict, Any
7
7
  from .utils.logger import logger
@@ -9,6 +9,7 @@ from .utils.logger import logger
9
9
  from langfuse.langchain import CallbackHandler
10
10
 
11
11
  from .core.engine import load_workflow
12
+ from .core.constants import MFAConfig
12
13
 
13
14
 
14
15
  class WorkflowTool:
@@ -25,7 +26,8 @@ class WorkflowTool:
25
26
  name: str,
26
27
  description: str,
27
28
  checkpointer=None,
28
- config: Optional[Dict]=None
29
+ config: Optional[Dict]=None,
30
+ mfa_config: Optional[MFAConfig] = None
29
31
  ):
30
32
  """Initialize workflow tool
31
33
 
@@ -39,9 +41,13 @@ class WorkflowTool:
39
41
  self.name = name
40
42
  self.description = description
41
43
  self.checkpointer = checkpointer
44
+ self.mfa_config = mfa_config
42
45
 
43
46
  # Load workflow
44
- self.graph, self.engine = load_workflow(yaml_path, checkpointer=checkpointer, config=config)
47
+ self.graph, self.engine = load_workflow(
48
+ yaml_path, checkpointer=checkpointer,
49
+ config=config, mfa_config=mfa_config
50
+ )
45
51
 
46
52
  def execute(
47
53
  self,
@@ -1,9 +1,11 @@
1
- from typing import List, Set
1
+ from typing import List, Set, Optional, TYPE_CHECKING
2
2
 
3
3
  import jsonschema
4
4
 
5
5
  from .schema import WORKFLOW_SCHEMA
6
- from soprano_sdk.core.constants import MFARestAuthorizerEnv
6
+
7
+ if TYPE_CHECKING:
8
+ from soprano_sdk.core.constants import MFAConfig
7
9
 
8
10
 
9
11
  class ValidationResult:
@@ -21,8 +23,9 @@ class ValidationResult:
21
23
 
22
24
 
23
25
  class WorkflowValidator:
24
- def __init__(self, config: dict):
26
+ def __init__(self, config: dict, mfa_config: Optional['MFAConfig'] = None):
25
27
  self.config = config
28
+ self.mfa_config = mfa_config
26
29
  self.errors: List[str] = []
27
30
 
28
31
  def validate(self) -> ValidationResult:
@@ -110,20 +113,10 @@ class WorkflowValidator:
110
113
  )
111
114
 
112
115
  def _validate_authorizer(self, step):
113
-
114
- def _validate_rest_fields():
115
- if mfa_authorizer['type'] == 'REST':
116
- for field in MFARestAuthorizerEnv:
117
- is_present = field.get_from_env()
118
- if not is_present:
119
- self.errors.append(f"`{field.value}` needs to be set as Environment for REST MFA")
120
- else:
121
- self.errors.append(f"step({step['id']}) -> mfa -> type is unsupported")
122
-
123
116
  mfa_authorizer = step.get("mfa", None)
124
117
  if mfa_authorizer is None:
125
118
  return
126
-
119
+
127
120
  if mfa_authorizer and step['action'] != 'call_function':
128
121
  self.errors.append(
129
122
  f"MFA is enabled in step({step['id']}). MFA is supported only for `call_function` nodes"
@@ -133,7 +126,31 @@ class WorkflowValidator:
133
126
  if not model:
134
127
  self.errors.append(f"step({step['id']}) -> mfa -> model is missing")
135
128
 
136
- _validate_rest_fields()
129
+ mfa_type = mfa_authorizer.get('type')
130
+ if mfa_type and mfa_type != 'REST':
131
+ self.errors.append(f"step({step['id']}) -> mfa -> type '{mfa_type}' is unsupported. Only 'REST' is supported.")
132
+
133
+ # Validate mfa_config if provided
134
+ if self.mfa_config is not None:
135
+ missing_fields = []
136
+ if not self.mfa_config.generate_token_base_url:
137
+ missing_fields.append('generate_token_base_url')
138
+ if not self.mfa_config.generate_token_path:
139
+ missing_fields.append('generate_token_path')
140
+ if not self.mfa_config.validate_token_base_url:
141
+ missing_fields.append('validate_token_base_url')
142
+ if not self.mfa_config.validate_token_path:
143
+ missing_fields.append('validate_token_path')
144
+ if not self.mfa_config.authorize_token_base_url:
145
+ missing_fields.append('authorize_token_base_url')
146
+ if not self.mfa_config.authorize_token_path:
147
+ missing_fields.append('authorize_token_path')
148
+
149
+ if missing_fields:
150
+ self.errors.append(
151
+ f"MFA configuration is missing required fields: {', '.join(missing_fields)}. "
152
+ f"Either provide them via mfa_config parameter or set corresponding environment variables."
153
+ )
137
154
 
138
155
 
139
156
  def _validate_data_fields(self):
@@ -200,8 +217,8 @@ class WorkflowValidator:
200
217
  )
201
218
 
202
219
 
203
- def validate_workflow(config: dict) -> ValidationResult:
204
- validator = WorkflowValidator(config)
220
+ def validate_workflow(config: dict, mfa_config: Optional['MFAConfig'] = None) -> ValidationResult:
221
+ validator = WorkflowValidator(config, mfa_config=mfa_config)
205
222
  result = validator.validate()
206
223
 
207
224
  if not result.is_valid:
@@ -1,19 +1,10 @@
1
- """
2
- Comprehensive test cases for MFA (Multi-Factor Authentication) functionality
3
- Tests cover all scenarios including forward flow, loop backs, and multiple MFA steps
4
-
5
- Test Scenario: Concert Ticket Booking System
6
- """
7
1
  import os
8
- import sys
9
- from typing import Dict, Any
10
2
  import pytest
3
+ from soprano_sdk import load_workflow
11
4
 
12
- # Add examples directory to path
13
- examples_dir = os.path.join(os.path.dirname(__file__), "..", "examples", "concert_booking")
14
- sys.path.insert(0, os.path.abspath(examples_dir))
15
5
 
16
- from soprano_sdk import load_workflow
6
+ def get_examples_dir():
7
+ return os.path.join(os.path.dirname(__file__), "..", "examples", "concert_booking")
17
8
 
18
9
 
19
10
  @pytest.fixture(scope="module", autouse=True)
@@ -57,14 +48,14 @@ def test_mfa_nodes_created():
57
48
  print("TEST 1: MFA Node Creation")
58
49
  print("=" * 80)
59
50
 
60
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
51
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
61
52
  graph, engine = load_workflow(yaml_path)
62
53
 
63
54
  # Count MFA-protected steps in original config
64
55
  mfa_protected_steps = ['process_payment', 'send_confirmation']
65
56
 
66
57
  print(f"\nOriginal MFA-protected steps: {mfa_protected_steps}")
67
- print(f"Expected MFA nodes per protected step: 2 (start + collector)")
58
+ print("Expected MFA nodes per protected step: 2 (start + collector)")
68
59
  print(f"Total expected MFA nodes: {len(mfa_protected_steps) * 2}")
69
60
 
70
61
  # Verify MFA nodes were created
@@ -104,7 +95,7 @@ def test_mfa_previous_step_redirection():
104
95
  print("TEST 2: Previous Step Redirection (Forward Flow)")
105
96
  print("=" * 80)
106
97
 
107
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
98
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
108
99
  graph, engine = load_workflow(yaml_path)
109
100
 
110
101
  # Test Case 2a: check_seat_availability should point to process_payment_mfa_start
@@ -153,7 +144,7 @@ def test_mfa_loop_back_redirection():
153
144
  print("TEST 3: Loop Back Redirection (CRITICAL BUG FIX TEST)")
154
145
  print("=" * 80)
155
146
 
156
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
147
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
157
148
  graph, engine = load_workflow(yaml_path)
158
149
 
159
150
  print("\nScenario: User completes booking, then requests modification")
@@ -162,7 +153,7 @@ def test_mfa_loop_back_redirection():
162
153
 
163
154
  # Verify ask_modification_needed loops back to collect_seat_preference
164
155
  modification_step = engine.get_step_info('ask_modification_needed')
165
- print(f"\nStep: ask_modification_needed")
156
+ print("\nStep: ask_modification_needed")
166
157
 
167
158
  if transitions := modification_step.get('transitions'):
168
159
  loop_back = next((t for t in transitions if 'collect_seat_preference' in t.get('next', '')), None)
@@ -203,7 +194,7 @@ def test_mfa_loop_back_redirection():
203
194
 
204
195
  # Check if we've reached payment MFA
205
196
  if 'process_payment' in next_step and '_mfa_start' in next_step:
206
- print(f"\n✅ CRITICAL: Loop back correctly goes through MFA!")
197
+ print("\n✅ CRITICAL: Loop back correctly goes through MFA!")
207
198
  print(f" Full path: {' -> '.join(path)}")
208
199
  break
209
200
  else:
@@ -231,7 +222,7 @@ def test_mfa_multiple_steps_sequence():
231
222
  print("TEST 4: Multiple MFA Steps in Sequence")
232
223
  print("=" * 80)
233
224
 
234
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
225
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
235
226
  graph, engine = load_workflow(yaml_path)
236
227
 
237
228
  print("\nScenario: Two consecutive MFA-protected steps")
@@ -239,7 +230,7 @@ def test_mfa_multiple_steps_sequence():
239
230
 
240
231
  # Get process_payment step
241
232
  payment_step = engine.get_step_info('process_payment')
242
- print(f"\nStep: process_payment")
233
+ print("\nStep: process_payment")
243
234
 
244
235
  # Find transition to send_confirmation
245
236
  next_after_payment = None
@@ -286,7 +277,7 @@ def test_mfa_data_fields_created():
286
277
  print("TEST 5: MFA Data Fields Creation")
287
278
  print("=" * 80)
288
279
 
289
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
280
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
290
281
  graph, engine = load_workflow(yaml_path)
291
282
 
292
283
  print("\nChecking if MFA-specific data fields were created...")
@@ -308,7 +299,7 @@ def test_mfa_data_fields_created():
308
299
 
309
300
  assert field_name in data_fields, \
310
301
  f"MFA field {field_name} should be in data fields"
311
- print(f" ✅ Field exists in data schema")
302
+ print(" ✅ Field exists in data schema")
312
303
 
313
304
  print("\n✅ TEST PASSED: MFA data fields created correctly")
314
305
  print("=" * 80)
@@ -327,7 +318,7 @@ def test_mfa_alternative_paths():
327
318
  print("TEST 6: Alternative Paths to MFA Steps")
328
319
  print("=" * 80)
329
320
 
330
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
321
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
331
322
  graph, engine = load_workflow(yaml_path)
332
323
 
333
324
  print("\nScenario: User selects unavailable seats, tries alternatives")
@@ -335,7 +326,7 @@ def test_mfa_alternative_paths():
335
326
 
336
327
  # Check offer_alternative_seats loops back correctly
337
328
  alternative_step = engine.get_step_info('offer_alternative_seats')
338
- print(f"\nStep: offer_alternative_seats")
329
+ print("\nStep: offer_alternative_seats")
339
330
 
340
331
  if transitions := alternative_step.get('transitions'):
341
332
  for t in transitions:
@@ -371,7 +362,7 @@ def test_mfa_comprehensive_flow():
371
362
  print("TEST 7: Comprehensive MFA Flow Validation")
372
363
  print("=" * 80)
373
364
 
374
- yaml_path = os.path.join(examples_dir, "concert_ticket_booking.yaml")
365
+ yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
375
366
  graph, engine = load_workflow(yaml_path)
376
367
 
377
368
  mfa_protected_original = ['process_payment', 'send_confirmation']