soprano-sdk 0.2.10__py3-none-any.whl

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.
@@ -0,0 +1,12 @@
1
+ from .core.engine import WorkflowEngine, load_workflow
2
+ from .core.constants import MFAConfig
3
+ from .tools import WorkflowTool
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = [
8
+ "WorkflowEngine",
9
+ "load_workflow",
10
+ "MFAConfig",
11
+ "WorkflowTool",
12
+ ]
@@ -0,0 +1,30 @@
1
+ """Agent module for managing different agent frameworks."""
2
+
3
+ from .factory import (
4
+ AgentAdapter,
5
+ AgentCreator,
6
+ AgentFactory,
7
+ LangGraphAgentAdapter,
8
+ LangGraphAgentCreator,
9
+ CrewAIAgentAdapter,
10
+ CrewAIAgentCreator,
11
+ AgnoAgentAdapter,
12
+ AgnoAgentCreator,
13
+ PydanticAIAgentAdapter,
14
+ PydanticAIAgentCreator,
15
+ )
16
+
17
+ __all__ = [
18
+ "AgentAdapter",
19
+ "AgentCreator",
20
+ "AgentFactory",
21
+ "LangGraphAgentAdapter",
22
+ "LangGraphAgentCreator",
23
+ "CrewAIAgentAdapter",
24
+ "CrewAIAgentCreator",
25
+ "AgnoAgentAdapter",
26
+ "AgnoAgentCreator",
27
+ "PydanticAIAgentAdapter",
28
+ "PydanticAIAgentCreator",
29
+ ]
30
+
@@ -0,0 +1,90 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, List
3
+ from langgraph.graph.state import CompiledStateGraph
4
+ from agno.agent import Agent as AgnoAgent
5
+ from pydantic import BaseModel
6
+ from pydantic_ai.agent import Agent as PydanticAIAgent
7
+ from crewai.agent import Agent as CrewAIAgent
8
+ from ..utils.logger import logger
9
+
10
+ class AgentAdapter(ABC):
11
+
12
+ @abstractmethod
13
+ def invoke(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
14
+ pass
15
+
16
+
17
+ class LangGraphAgentAdapter(AgentAdapter):
18
+
19
+ def __init__(self, agent: CompiledStateGraph):
20
+ self.agent = agent
21
+
22
+ def invoke(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
23
+ logger.info("Invoking LangGraph agent with messages")
24
+ response = self.agent.invoke({"messages": messages})
25
+
26
+ if structured_response := response.get('structured_response'):
27
+ return structured_response.model_dump()
28
+
29
+ if not response or "messages" not in response:
30
+ raise ValueError("Agent response missing 'messages'")
31
+
32
+ response_messages = response.get("messages")
33
+ if not response_messages:
34
+ raise ValueError("Agent response 'messages' list is empty")
35
+
36
+ return response_messages[-1].content
37
+
38
+
39
+ class CrewAIAgentAdapter(AgentAdapter):
40
+
41
+ def __init__(self, agent: CrewAIAgent, output_schema: BaseModel):
42
+ self.agent = agent
43
+ self.output_schema=output_schema
44
+
45
+ def invoke(self, messages: List[Dict[str, str]]) -> Any:
46
+ try:
47
+ logger.info("Invoking LangGraph agent with messages")
48
+ result = self.agent.kickoff(messages, response_format=self.output_schema)
49
+
50
+ if structured_response := getattr(result, 'pydantic', None) :
51
+ return structured_response.model_dump()
52
+
53
+ if agent_response := getattr(result, 'raw', None) :
54
+ return agent_response
55
+
56
+ return str(result)
57
+ except Exception as e:
58
+ raise RuntimeError(f"CrewAI agent invocation failed: {e}")
59
+
60
+
61
+ class AgnoAgentAdapter(AgentAdapter):
62
+
63
+ def __init__(self, agent: AgnoAgent):
64
+ self.agent = agent
65
+
66
+ def invoke(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
67
+ try:
68
+ logger.info("Invoking LangGraph agent with messages")
69
+ response = self.agent.run(messages)
70
+ agent_response = response.content if hasattr(response, 'content') else str(response)
71
+
72
+ return agent_response
73
+ except Exception as e:
74
+ raise RuntimeError(f"Agno agent invocation failed: {e}")
75
+
76
+
77
+ class PydanticAIAgentAdapter(AgentAdapter):
78
+
79
+ def __init__(self, agent: PydanticAIAgent):
80
+ self.agent = agent
81
+
82
+ def invoke(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
83
+ try:
84
+ logger.info("Invoking LangGraph agent with messages")
85
+ result = self.agent.run_sync(messages)
86
+ agent_response = result.output if hasattr(result, 'output') else str(result)
87
+
88
+ return agent_response
89
+ except Exception as e:
90
+ raise RuntimeError(f"Pydantic-AI agent invocation failed: {e}")
@@ -0,0 +1,228 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, List, Literal, Tuple, Callable
3
+
4
+ from agno.models.openai import OpenAIChat
5
+ from crewai import LLM
6
+ from langchain_openai import ChatOpenAI
7
+ from pydantic import SecretStr, BaseModel
8
+ from typing import Optional
9
+
10
+ from .adaptor import (
11
+ AgentAdapter,
12
+ LangGraphAgentAdapter,
13
+ CrewAIAgentAdapter,
14
+ AgnoAgentAdapter,
15
+ PydanticAIAgentAdapter
16
+ )
17
+
18
+
19
+ def get_model(config: Dict[str, Any], framework: Literal['langgraph', 'crewai', 'agno', 'pydantic-ai'] = "langgraph", output_schema: Optional[BaseModel] = None, tools: Optional[List] = None):
20
+ errors = []
21
+
22
+ model_name: str = config.get("model_name", "")
23
+ if not model_name:
24
+ errors.append("Model name is required in model_config")
25
+
26
+ base_url = config.get("base_url")
27
+ if not base_url:
28
+ errors.append("Base url for model is required in model_config")
29
+
30
+ api_key = config.get("api_key", "")
31
+ if not api_key:
32
+ if auth_callback := config.get("auth_callback"):
33
+ api_key = auth_callback()
34
+ if not api_key:
35
+ errors.append("API key/Auth callback for model is required in model_config")
36
+
37
+ if errors:
38
+ raise ValueError("; ".join(errors))
39
+
40
+ if framework == "agno" :
41
+ return OpenAIChat(
42
+ id=model_name,
43
+ api_key=api_key,
44
+ base_url=base_url
45
+ )
46
+
47
+ if framework == "crewai" :
48
+ return LLM(
49
+ api_key=api_key,
50
+ model=f"openai/{model_name}",
51
+ base_url=base_url,
52
+ temperature=0.1,
53
+ top_p=0.7
54
+ )
55
+
56
+ llm = ChatOpenAI(
57
+ model=model_name,
58
+ api_key=SecretStr(api_key),
59
+ base_url=base_url,
60
+ )
61
+
62
+ if output_schema:
63
+ return llm.with_structured_output(output_schema)
64
+
65
+ if tools:
66
+ llm = llm.bind_tools(tools)
67
+
68
+ return llm
69
+
70
+
71
+ class AgentCreator(ABC):
72
+ @abstractmethod
73
+ def create_agent(
74
+ self,
75
+ name: str,
76
+ model_config: Dict[str, Any],
77
+ tools: List[Any],
78
+ system_prompt: str,
79
+ structured_output_model: Any = None
80
+ ) -> AgentAdapter:
81
+ pass
82
+
83
+
84
+ class LangGraphAgentCreator(AgentCreator):
85
+ def create_agent(
86
+ self,
87
+ name: str,
88
+ model_config: Dict[str, Any],
89
+ tools: List[Any],
90
+ system_prompt: str,
91
+ structured_output_model: Any = None,
92
+ ) -> LangGraphAgentAdapter:
93
+ from langchain.agents import create_agent
94
+ from langchain.tools import tool
95
+ from langchain.agents.structured_output import ProviderStrategy
96
+
97
+ tools = [tool(tool_name, description=description)(tool_callable) for tool_name, description, tool_callable in tools]
98
+
99
+ output_parser = None
100
+ if structured_output_model:
101
+ output_parser = ProviderStrategy(structured_output_model)
102
+
103
+ agent = create_agent(
104
+ name=name,
105
+ model=get_model(model_config, 'langgraph', tools=tools),
106
+ tools=tools,
107
+ system_prompt=system_prompt,
108
+ response_format=output_parser
109
+ )
110
+ return LangGraphAgentAdapter(agent)
111
+
112
+
113
+ class CrewAIAgentCreator(AgentCreator):
114
+ def create_agent(
115
+ self,
116
+ name: str,
117
+ model_config: Dict[str, Any],
118
+ tools: List[Any],
119
+ system_prompt: str,
120
+ structured_output_model: Any = None
121
+ ) -> CrewAIAgentAdapter:
122
+ from crewai.agent import Agent
123
+ from crewai.tools import tool
124
+
125
+ def create_crewai_tool(tool_name: str, description: str, tool_callable: Callable) -> Any:
126
+ tool_callable.__doc__ = description
127
+ return tool(tool_name)(tool_callable)
128
+
129
+ tools = [create_crewai_tool(tn, desc, tc) for tn, desc, tc in tools]
130
+
131
+ agent = Agent(
132
+ role=name,
133
+ backstory=system_prompt,
134
+ goal="Collect the required data from user messages using the available tools.",
135
+ tools=tools,
136
+ llm=get_model(model_config, 'crewai'),
137
+ max_retry_limit=2
138
+ )
139
+
140
+ return CrewAIAgentAdapter(agent, output_schema=structured_output_model)
141
+
142
+
143
+ class AgnoAgentCreator(AgentCreator):
144
+ def create_agent(
145
+ self,
146
+ name: str,
147
+ model_config: Dict[str, Any],
148
+ tools: List[Any],
149
+ system_prompt: str,
150
+ structured_output_model: Any = None
151
+ ) -> AgnoAgentAdapter:
152
+ from agno.agent import Agent
153
+ from agno.tools import tool
154
+
155
+ tools = [tool(name=tool_name, description=description)(tool_callable) for tool_name, description, tool_callable in tools]
156
+
157
+ agent = Agent(
158
+ name=name,
159
+ model=get_model(model_config, 'agno'),
160
+ tools=tools,
161
+ instructions=[system_prompt]
162
+ )
163
+
164
+ return AgnoAgentAdapter(agent)
165
+
166
+
167
+ class PydanticAIAgentCreator(AgentCreator):
168
+ def create_agent(
169
+ self,
170
+ name: str,
171
+ model_config: Dict[str, Any],
172
+ tools: List[Tuple[str, str, Callable]],
173
+ system_prompt: str,
174
+ structured_output_model: Any = None
175
+ ) -> PydanticAIAgentAdapter:
176
+ from pydantic_ai import Agent
177
+
178
+ agent = Agent(
179
+ model=get_model(model_config, 'pydantic-ai'),
180
+ system_prompt=system_prompt,
181
+ )
182
+
183
+ for tool_name, description, tool_callable in tools:
184
+ agent.tool(name=tool_name, description=description)(tool_callable)
185
+
186
+ return PydanticAIAgentAdapter(agent)
187
+
188
+
189
+ class AgentFactory:
190
+ _CREATORS = {
191
+ "langgraph": LangGraphAgentCreator,
192
+ "crewai": CrewAIAgentCreator,
193
+ "agno": AgnoAgentCreator,
194
+ "pydantic-ai": PydanticAIAgentCreator,
195
+ }
196
+
197
+ @classmethod
198
+ def get_creator(cls, framework: str) -> AgentCreator:
199
+ framework_lower = framework.lower()
200
+
201
+ if framework_lower not in cls._CREATORS:
202
+ supported = ", ".join(cls._CREATORS.keys())
203
+ raise ValueError(
204
+ f"Unsupported agent framework: '{framework}'. "
205
+ f"Supported frameworks: {supported}"
206
+ )
207
+
208
+ creator_class = cls._CREATORS[framework_lower]
209
+ return creator_class()
210
+
211
+ @classmethod
212
+ def create_agent(
213
+ cls,
214
+ framework: str,
215
+ name: str,
216
+ model_config: Dict[str, Any],
217
+ tools: List[Any],
218
+ system_prompt: str,
219
+ structured_output_model: Any
220
+ ) -> Any:
221
+ creator = cls.get_creator(framework)
222
+ return creator.create_agent(
223
+ name=name,
224
+ model_config=model_config,
225
+ tools=tools,
226
+ system_prompt=system_prompt,
227
+ structured_output_model=structured_output_model
228
+ )
@@ -0,0 +1,97 @@
1
+ from typing import Any, Dict, List, Optional, Type
2
+ from pydantic import BaseModel, Field, create_model
3
+
4
+
5
+ TYPE_MAP = {
6
+ "text": str,
7
+ "number": int,
8
+ "double": float,
9
+ "boolean": bool,
10
+ "list": list,
11
+ "dict": dict,
12
+ }
13
+
14
+
15
+ def create_structured_output_model(
16
+ fields: List[Dict[str, Any]],
17
+ model_name: str = "StructuredOutput",
18
+ needs_intent_change: bool = False,
19
+ ) -> Type[BaseModel]:
20
+ if not fields:
21
+ raise ValueError("At least one field definition is required")
22
+
23
+ field_definitions = {"bot_response": (Optional[str], Field(None, description="bot response for the user query, only use this for clarification or asking for more information"))}
24
+
25
+ if needs_intent_change:
26
+ field_definitions["intent_change"] = (Optional[str], Field(None, description="node name for handling new intent"))
27
+
28
+ for field_def in fields:
29
+ field_name = field_def.get("name")
30
+ field_type = field_def.get("type")
31
+ field_description = field_def.get("description", "")
32
+ is_required = field_def.get("required", True)
33
+
34
+ if not field_name:
35
+ raise ValueError("Field definition missing 'name'")
36
+
37
+ if not field_type:
38
+ raise ValueError(f"Field '{field_name}' missing 'type'")
39
+
40
+ python_type = TYPE_MAP.get(field_type)
41
+ if python_type is None:
42
+ raise ValueError(
43
+ f"Unknown type '{field_type}' for field '{field_name}'. "
44
+ f"Supported types: {list(TYPE_MAP.keys())}"
45
+ )
46
+
47
+ if is_required:
48
+ field_definitions[field_name] = (
49
+ python_type,
50
+ Field(..., description=field_description)
51
+ )
52
+ else:
53
+ python_type = Optional[python_type]
54
+ field_definitions[field_name] = (
55
+ python_type,
56
+ Field(default=None, description=field_description)
57
+ )
58
+
59
+ try:
60
+ model = create_model(model_name, **field_definitions)
61
+ return model
62
+ except Exception as e:
63
+ raise ValueError(f"Failed to create model '{model_name}': {e}")
64
+
65
+
66
+ def validate_field_definitions(fields: List[Dict[str, Any]]) -> bool:
67
+ if not isinstance(fields, list):
68
+ raise ValueError("Field definitions must be a list")
69
+
70
+ if not fields:
71
+ raise ValueError("At least one field definition is required")
72
+
73
+ field_names = set()
74
+
75
+ for i, field_def in enumerate(fields):
76
+ if not isinstance(field_def, dict):
77
+ raise ValueError(f"Field definition at index {i} must be a dictionary")
78
+
79
+ required_keys = ["name", "type", "description"]
80
+ for key in required_keys:
81
+ if key not in field_def:
82
+ raise ValueError(f"Field definition at index {i} missing required key '{key}'")
83
+
84
+ field_name = field_def["name"]
85
+
86
+ if field_name in field_names:
87
+ raise ValueError(f"Duplicate field name '{field_name}' found")
88
+ field_names.add(field_name)
89
+
90
+ field_type = field_def["type"]
91
+ if field_type not in TYPE_MAP:
92
+ raise ValueError(
93
+ f"Invalid type '{field_type}' for field '{field_name}'. "
94
+ f"Supported types: {list(TYPE_MAP.keys())}"
95
+ )
96
+
97
+ return True
File without changes
@@ -0,0 +1,205 @@
1
+ import requests
2
+ from typing import TypedDict, Literal, NotRequired, Optional
3
+ from soprano_sdk.core.constants import MFAConfig
4
+
5
+
6
+ class MFAChallenge(TypedDict):
7
+ value: str
8
+
9
+
10
+ class MFAState(TypedDict):
11
+ challengeType: Literal['OTP', 'dob']
12
+ post_payload: dict[str, str]
13
+ post_headers: NotRequired[dict[str, str]]
14
+ otpValue: NotRequired[str]
15
+ status: Literal['IN_PROGRESS', 'COMPLETED', 'ERRORED', 'FAILED'] | None
16
+ message: str
17
+ retry_count: int
18
+
19
+
20
+
21
+ def get_response(response: requests.Response):
22
+ if response.ok:
23
+ return response.json(), None
24
+ else:
25
+ return None, response.json()
26
+
27
+
28
+ def build_path(base_url: str, path: str):
29
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
30
+
31
+
32
+ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None):
33
+ if mfa_config is None:
34
+ mfa_config = state.get('_mfa_config') or MFAConfig()
35
+
36
+ _mfa : MFAState = state['_mfa']
37
+ if _mfa['status'] == 'COMPLETED':
38
+ return True
39
+
40
+ # Use custom headers if provided, otherwise empty dict
41
+ headers = _mfa.get('post_headers', {})
42
+
43
+ generate_token_response = requests.post(
44
+ build_path(
45
+ base_url=mfa_config.generate_token_base_url,
46
+ path=mfa_config.generate_token_path
47
+ ),
48
+ json=_mfa['post_payload'],
49
+ timeout=mfa_config.api_timeout,
50
+ headers=headers
51
+ )
52
+ _, error = get_response(generate_token_response)
53
+
54
+ challenge_type = error['additionalData']['challengeType']
55
+ _mfa['challengeType'] = challenge_type
56
+ _mfa['status'] = 'IN_PROGRESS'
57
+ _mfa['retry_count'] = 0
58
+ _mfa['message'] = f"Please enter the {challenge_type}"
59
+ if delivery_methods := error['additionalData'].get(f"{challenge_type.lower()}SentTo"):
60
+ _mfa['message'] += f" sent via {','.join(delivery_methods)}"
61
+ return False
62
+
63
+
64
+ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dict):
65
+ if mfa_config is None:
66
+ mfa_config = state.get('_mfa_config') or MFAConfig()
67
+
68
+ _mfa : MFAState = state['_mfa']
69
+ input_field_name = state['_active_input_field']
70
+ if not state[input_field_name]:
71
+ return False
72
+
73
+ # Use custom headers if provided, otherwise empty dict
74
+ headers = _mfa.get('post_headers', {})
75
+
76
+ post_payload = _mfa['post_payload']
77
+ challenge_field_name = f"{_mfa['challengeType'].lower()}Challenge"
78
+ post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
79
+ validate_token_response = requests.post(
80
+ build_path(
81
+ base_url=mfa_config.validate_token_base_url,
82
+ path=mfa_config.validate_token_path
83
+ ),
84
+ json=post_payload,
85
+ timeout=mfa_config.api_timeout,
86
+ headers=headers
87
+ )
88
+ _mfa['retry_count'] += 1
89
+ response, error = get_response(validate_token_response)
90
+ if error:
91
+ if _mfa['retry_count'] == 1:
92
+ _mfa['status'] = 'ERRORED'
93
+ return False, f"You Have Entered Invalid {_mfa['challengeType']}. {_mfa['message']}"
94
+
95
+ if response and 'token' in response:
96
+ token = response['token']
97
+ post_payload['token'] = token
98
+
99
+ authorize = requests.post(
100
+ build_path(
101
+ base_url=mfa_config.authorize_token_base_url,
102
+ path=mfa_config.authorize_token_path
103
+ ),
104
+ json=post_payload,
105
+ timeout=mfa_config.api_timeout,
106
+ headers=headers
107
+ )
108
+ if authorize.status_code == 204:
109
+ _mfa['status'] = 'COMPLETED'
110
+ return True, None
111
+ else:
112
+ _mfa['status'] = 'FAILED'
113
+ return False, f"You Have Entered Invalid {_mfa['challengeType']}. {_mfa['message']}"
114
+
115
+
116
+ class MFANodeConfig:
117
+
118
+ @classmethod
119
+ def get_call_function_template(cls, source_node: str, next_node: str, mfa: dict):
120
+ return dict(
121
+ id=f"{source_node}_mfa_start",
122
+ action="call_function",
123
+ function="soprano_sdk.authenticators.mfa.enforce_mfa_if_required",
124
+ output=f"{source_node}_mfa_start",
125
+ mfa=mfa,
126
+ transitions=[
127
+ dict(
128
+ condition=True,
129
+ next=source_node,
130
+ ),
131
+ dict(
132
+ condition=False,
133
+ next=next_node,
134
+ ),
135
+ ]
136
+ )
137
+
138
+ @classmethod
139
+ def get_validate_user_input(cls, source_node: str, next_node: str, mfa_config: dict):
140
+ model_name = mfa_config['model']
141
+ max_attempts = mfa_config.get('max_attempts', 3)
142
+ on_max_attempts_reached = mfa_config.get('on_max_attempts_reached')
143
+
144
+ input_field_name = f"{source_node}_mfa_input"
145
+ node_config = dict(
146
+ id=f"{source_node}_mfa_validate",
147
+ action="collect_input_with_agent",
148
+ description="Collect Input for MFA value",
149
+ field=input_field_name,
150
+ max_attempts=max_attempts,
151
+ validator="soprano_sdk.authenticators.mfa.mfa_validate_user_input",
152
+ agent=dict(
153
+ name="MFA Input Data Collector",
154
+ model=model_name,
155
+ initial_message="{{_mfa.message}}",
156
+ instructions="""
157
+ You are an authentication value extractor. Your job is to identify and extract MFA codes from user input, or detect if the user wants to cancel the authentication flow.
158
+
159
+ **Task:**
160
+ - Read the user's message carefully
161
+ - First, check if the user wants to cancel, stop, or exit the authentication process
162
+ - If they want to cancel, output: MFA_CANCELLED:
163
+ - Otherwise, extract ONLY the OTP/MFA code value and output in the format shown below
164
+
165
+ **Cancellation Detection:**
166
+ If the user expresses any intent to cancel, stop, exit, abort, or quit the authentication process, respond with: MFA_CANCELLED
167
+
168
+ Examples of cancellation phrases:
169
+ * "cancel" → MFA_CANCELLED:
170
+ * "I want to stop" → MFA_CANCELLED:
171
+ * "exit" → MFA_CANCELLED:
172
+ * "nevermind" → MFA_CANCELLED:
173
+ * "I don't want to continue" → MFA_CANCELLED:
174
+ * "stop this" → MFA_CANCELLED:
175
+ * "forget it" → MFA_CANCELLED:
176
+ * "abort" → MFA_CANCELLED:
177
+ * "quit" → MFA_CANCELLED:
178
+
179
+ **OTP Capture Examples:**
180
+ * "1234" → MFA_CAPTURED:1234
181
+ * "2345e" → MFA_CAPTURED:2345e
182
+ * "the code is 567890" → MFA_CAPTURED:567890
183
+ * "my otp is 123456" → MFA_CAPTURED:123456
184
+
185
+ **Output Format:**
186
+ - For OTP/MFA codes: MFA_CAPTURED:<otp_value>
187
+ - For cancellation: MFA_CANCELLED:
188
+
189
+ """),
190
+ transitions=[
191
+ dict(
192
+ pattern="MFA_CAPTURED:",
193
+ next=next_node
194
+ ),
195
+ dict(
196
+ pattern="MFA_CANCELLED:",
197
+ next="mfa_cancelled"
198
+ )
199
+ ]
200
+ )
201
+
202
+ if on_max_attempts_reached:
203
+ node_config['on_max_attempts_reached'] = on_max_attempts_reached
204
+
205
+ return node_config
File without changes