soprano-sdk 0.2.18__py3-none-any.whl → 0.2.20__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.
@@ -59,7 +59,9 @@ class CrewAIAgentAdapter(AgentAdapter):
59
59
  return response
60
60
 
61
61
  # Convert to string for parsing
62
- response_str = str(response)
62
+ response_str_raw = str(response)
63
+
64
+ response_str = response_str_raw[response_str_raw.find("{"): response_str_raw.rfind("}")+1]
63
65
 
64
66
  # Strategy 2: Try json.loads()
65
67
  try:
@@ -78,8 +80,8 @@ class CrewAIAgentAdapter(AgentAdapter):
78
80
  except (ValueError, SyntaxError, TypeError) as e:
79
81
  logger.error(f"ast.literal_eval() failed: {e}")
80
82
 
81
- # No schema and all parsing failed - return as string
82
- logger.error("No schema provided and parsing failed, returning raw response")
83
+ # All parsing failed - return as string
84
+ logger.error("All parsing strategies failed, returning raw response")
83
85
  return response_str
84
86
 
85
87
  def invoke(self, messages: List[Dict[str, str]]) -> Any:
@@ -16,15 +16,19 @@ def create_structured_output_model(
16
16
  fields: List[Dict[str, Any]],
17
17
  model_name: str = "StructuredOutput",
18
18
  needs_intent_change: bool = False,
19
+ enable_out_of_scope: bool = False,
19
20
  ) -> Type[BaseModel]:
20
21
  if not fields:
21
22
  raise ValueError("At least one field definition is required")
22
-
23
+
23
24
  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
 
25
26
  if needs_intent_change:
26
27
  field_definitions["intent_change"] = (Optional[str], Field(None, description="node name for handling new intent"))
27
28
 
29
+ if enable_out_of_scope:
30
+ field_definitions["out_of_scope"] = (Optional[str], Field(None, description="Brief description of what the user is trying to do if their query is completely outside the scope of this workflow"))
31
+
28
32
  for field_def in fields:
29
33
  field_name = field_def.get("name")
30
34
  field_type = field_def.get("type")
@@ -23,12 +23,14 @@ class ActionType(Enum):
23
23
  COLLECT_INPUT_WITH_AGENT = 'collect_input_with_agent'
24
24
  CALL_FUNCTION = 'call_function'
25
25
  CALL_ASYNC_FUNCTION = 'call_async_function'
26
+ FOLLOW_UP = 'follow_up'
26
27
 
27
28
 
28
29
  class InterruptType:
29
30
  """Interrupt type markers for workflow pauses"""
30
31
  USER_INPUT = '__WORKFLOW_INTERRUPT__'
31
32
  ASYNC = '__ASYNC_INTERRUPT__'
33
+ OUT_OF_SCOPE = '__OUT_OF_SCOPE_INTERRUPT__'
32
34
 
33
35
 
34
36
  class DataType(Enum):
@@ -41,6 +41,7 @@ class WorkflowEngine:
41
41
  self.data_fields = self.load_data()
42
42
  self.outcomes = self.load_outcomes()
43
43
  self.metadata = self.config.get('metadata', {})
44
+ self.failure_message: Optional[str] = self.config.get('failure_message')
44
45
 
45
46
  self.StateType = create_state_model(self.data_fields)
46
47
 
soprano_sdk/core/state.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import types
2
- from typing import Annotated, Optional, Dict, List, Any
2
+ from typing import Annotated, Optional, Dict, List, Any, Union
3
3
 
4
4
  from typing_extensions import TypedDict
5
5
 
@@ -46,7 +46,7 @@ def create_state_model(data_fields: List[dict]):
46
46
  fields['_node_execution_order'] = Annotated[List[str], replace]
47
47
  fields['_node_field_map'] = Annotated[Dict[str, str], replace]
48
48
  fields['_computed_fields'] = Annotated[List[str], replace]
49
- fields['error'] = Annotated[Optional[Dict[str, str]], replace]
49
+ fields['error'] = Annotated[Union[str, Dict[str, str], None], replace]
50
50
  fields['_mfa'] = Annotated[Optional[Dict[str, str]], replace]
51
51
  fields['_mfa_config'] = Annotated[Optional[Any], replace]
52
52
  fields['mfa_input'] = Annotated[Optional[Dict[str, str]], replace]
soprano_sdk/nodes/base.py CHANGED
@@ -40,7 +40,12 @@ class ActionStrategy(ABC):
40
40
  except Exception as e:
41
41
  logger.error(f"Node {self.step_id} failed: {e}", exc_info=True)
42
42
  self._set_status(state, WorkflowKeys.ERROR)
43
- state[WorkflowKeys.ERROR] = {"error": f"Unable to complete the request: {str(e)}"}
43
+
44
+ # Use custom error message from engine context if available
45
+ failure_message = getattr(self.engine_context, 'failure_message', None)
46
+ error_message = failure_message if failure_message else f"Unable to complete the request: {str(e)}"
47
+
48
+ state[WorkflowKeys.ERROR] = error_message
44
49
  state[WorkflowKeys.OUTCOME_ID] = WorkflowKeys.ERROR
45
50
  return state
46
51
 
@@ -25,6 +25,31 @@ from ..utils.tracing import trace_node_execution, trace_agent_invocation, add_no
25
25
  VALIDATION_ERROR_MESSAGE = "validation failed for the provided input, please enter valid input"
26
26
  INVALID_INPUT_MESSAGE = "Looks like the input is invalid. Please double-check and re-enter it."
27
27
  COLLECTION_FAILURE_MESSAGE = "I couldn't understand your response. Please try again and provide the required information."
28
+ OUT_OF_SCOPE_PATTERN = "OUT_OF_SCOPE:"
29
+
30
+
31
+ def _wrap_instructions_with_out_of_scope_detection(
32
+ instructions: str,
33
+ scope_description: str,
34
+ with_structured_output: bool
35
+ ) -> str:
36
+ """Append out-of-scope detection instructions. Backward compatible - works with any scope_description."""
37
+ return f"""{instructions}
38
+
39
+ OUT-OF-SCOPE DETECTION:
40
+ Your current task is: {scope_description}
41
+
42
+ If the user's query is COMPLETELY UNRELATED to this task:
43
+ - {"Set out_of_scope to a brief description of what the user is trying to do" if with_structured_output else "Respond with: OUT_OF_SCOPE: <brief description of user intent>"}
44
+ - Do NOT attempt to answer or redirect the query
45
+ - Do NOT confuse this with intent changes between collection steps
46
+
47
+ Examples of out-of-scope queries:
48
+ - Asking about completely different products/services
49
+ - Requesting actions unrelated to the current task
50
+ - General questions that don't fit the current workflow
51
+ """
52
+
28
53
 
29
54
  def _wrap_instructions_with_intent_detection(
30
55
  instructions: str,
@@ -97,6 +122,13 @@ class CollectInputStrategy(ActionStrategy):
97
122
  self.next_step = self.step_config.get("next", None)
98
123
  self.is_structured_output = self.agent_config.get("structured_output", {}).get("enabled", False)
99
124
 
125
+ # Out-of-scope detection configuration (enabled by default)
126
+ self.enable_out_of_scope = self.agent_config.get("detect_out_of_scope", True)
127
+ self.scope_description = self.agent_config.get(
128
+ "scope_description",
129
+ self.agent_config.get("description", f"collecting {self.field}")
130
+ )
131
+
100
132
  rollback_strategy_name = engine_context.get_config_value("rollback_strategy", "history_based")
101
133
  self.rollback_strategy = _create_rollback_strategy(rollback_strategy_name)
102
134
  logger.info(f"Using rollback strategy: {self.rollback_strategy.get_strategy_name()}")
@@ -168,11 +200,22 @@ class CollectInputStrategy(ActionStrategy):
168
200
  ):
169
201
  agent_response = _get_agent_response(agent, conversation)
170
202
 
203
+ # Get user message for out-of-scope handling
204
+ user_message = next(
205
+ (msg['content'] for msg in reversed(conversation) if msg['role'] == 'user'),
206
+ ""
207
+ )
208
+
171
209
  if self.is_structured_output:
172
- state = self._handle_structured_output_transition(state, conversation, agent_response)
210
+ state = self._handle_structured_output_transition(state, conversation, agent_response, user_message)
173
211
  add_node_result(span, self.field, state.get(self.field), state.get(WorkflowKeys.STATUS))
174
212
  return self._add_node_to_execution_order(state)
175
213
 
214
+ # Check for out-of-scope before intent change (for non-structured output)
215
+ if self.enable_out_of_scope and agent_response.startswith(OUT_OF_SCOPE_PATTERN):
216
+ span.add_event("out_of_scope.detected")
217
+ return self._handle_out_of_scope(agent_response, state, user_message)
218
+
176
219
  if agent_response.startswith(TransitionPattern.INTENT_CHANGE):
177
220
  span.add_event("intent.change_detected")
178
221
  return self._handle_intent_change(agent_response, state)
@@ -311,6 +354,13 @@ class CollectInputStrategy(ActionStrategy):
311
354
  if node_id not in self.engine_context.mfa_validator_steps
312
355
  }
313
356
  instructions = _wrap_instructions_with_intent_detection(instructions, collector_nodes_for_intent_change, self.is_structured_output)
357
+
358
+ # Add out-of-scope detection instructions if enabled
359
+ if self.enable_out_of_scope:
360
+ instructions = _wrap_instructions_with_out_of_scope_detection(
361
+ instructions, self.scope_description, self.is_structured_output
362
+ )
363
+
314
364
  return instructions
315
365
 
316
366
  def _load_agent_tools(self, state: Dict[str, Any]) -> List:
@@ -323,18 +373,19 @@ class CollectInputStrategy(ActionStrategy):
323
373
  structured_output_config = self.agent_config.get('structured_output')
324
374
  if not structured_output_config or not structured_output_config.get('enabled'):
325
375
  return None
326
-
376
+
327
377
  fields = structured_output_config.get('fields', [])
328
378
  if not fields:
329
379
  return None
330
-
380
+
331
381
  validate_field_definitions(fields)
332
382
  model_name = f"{self.field.title().replace('_', '')}StructuredOutput"
333
-
383
+
334
384
  return create_structured_output_model(
335
385
  fields=fields,
336
386
  model_name=model_name,
337
- needs_intent_change=len(collector_nodes) > 0
387
+ needs_intent_change=len(collector_nodes) > 0,
388
+ enable_out_of_scope=self.enable_out_of_scope
338
389
  )
339
390
 
340
391
  def _create_agent(self, state: Dict[str, Any]) -> AgentAdapter:
@@ -399,7 +450,7 @@ class CollectInputStrategy(ActionStrategy):
399
450
  target_node = target_node_or_response.split(TransitionPattern.INTENT_CHANGE)[1].strip()
400
451
  else:
401
452
  target_node = target_node_or_response
402
-
453
+
403
454
  logger.info(f"Intent change detected: {self.step_id} -> {target_node}")
404
455
 
405
456
  rollback_state = self._rollback_state_to_node(state, target_node)
@@ -410,6 +461,24 @@ class CollectInputStrategy(ActionStrategy):
410
461
 
411
462
  return rollback_state
412
463
 
464
+ def _handle_out_of_scope(self, reason: str, state: Dict[str, Any], user_message: str) -> Dict[str, Any]:
465
+ """Handle out-of-scope user input by signaling to parent orchestrator"""
466
+ if isinstance(reason, str) and OUT_OF_SCOPE_PATTERN in reason:
467
+ reason = reason.split(OUT_OF_SCOPE_PATTERN)[1].strip()
468
+
469
+ logger.info(f"Out-of-scope detected in step '{self.step_id}': {reason}")
470
+
471
+ # Store the out-of-scope reason and interrupt with user message
472
+ state['_out_of_scope_reason'] = reason
473
+ interrupt({
474
+ "type": "out_of_scope",
475
+ "step_id": self.step_id,
476
+ "reason": reason,
477
+ "user_message": user_message
478
+ })
479
+
480
+ return state
481
+
413
482
  def _rollback_state_to_node(
414
483
  self,
415
484
  state: Dict[str, Any],
@@ -491,13 +560,17 @@ class CollectInputStrategy(ActionStrategy):
491
560
 
492
561
  return state
493
562
 
494
- def _handle_structured_output_transition(self, state: Dict[str, Any], conversation: List, agent_response: Any) -> Dict[str, Any]:
563
+ def _handle_structured_output_transition(self, state: Dict[str, Any], conversation: List, agent_response: Any, user_message: str = "") -> Dict[str, Any]:
495
564
 
496
565
  try:
497
566
  agent_response = json.loads(agent_response) if isinstance(agent_response, str) else agent_response
498
567
  except (json.JSONDecodeError, TypeError, ValueError):
499
568
  pass
500
569
 
570
+ # Check for out-of-scope first (before intent change)
571
+ if self.enable_out_of_scope and (out_of_scope_reason := agent_response.get("out_of_scope")):
572
+ return self._handle_out_of_scope(out_of_scope_reason, state, user_message)
573
+
501
574
  if target_node := agent_response.get("intent_change"):
502
575
  return self._handle_intent_change(target_node, state)
503
576
 
@@ -4,6 +4,7 @@ from .base import ActionStrategy
4
4
  from .call_function import CallFunctionStrategy
5
5
  from .collect_input import CollectInputStrategy
6
6
  from .async_function import AsyncFunctionStrategy
7
+ from .follow_up import FollowUpStrategy
7
8
  from ..core.constants import ActionType
8
9
  from ..utils.logger import logger
9
10
 
@@ -46,3 +47,4 @@ class NodeFactory:
46
47
  NodeFactory.register(ActionType.COLLECT_INPUT_WITH_AGENT.value, CollectInputStrategy)
47
48
  NodeFactory.register(ActionType.CALL_FUNCTION.value, CallFunctionStrategy)
48
49
  NodeFactory.register(ActionType.CALL_ASYNC_FUNCTION.value, AsyncFunctionStrategy)
50
+ NodeFactory.register(ActionType.FOLLOW_UP.value, FollowUpStrategy)
@@ -0,0 +1,346 @@
1
+ """
2
+ Follow-up node strategy for handling conversational Q&A.
3
+
4
+ This node type allows users to ask follow-up questions and receive answers
5
+ based on the full workflow context. Unlike collect_input_with_agent where
6
+ the agent initiates, here the user initiates by asking questions first.
7
+ """
8
+ from typing import Dict, Any, List
9
+
10
+ from langgraph.types import interrupt
11
+
12
+ from .base import ActionStrategy
13
+ from ..agents.factory import AgentFactory, AgentAdapter
14
+ from ..core.constants import WorkflowKeys
15
+ from ..core.state import initialize_state
16
+ from ..utils.logger import logger
17
+ from ..utils.tracing import trace_node_execution, trace_agent_invocation, add_node_result
18
+
19
+ # Patterns for detecting special responses
20
+ OUT_OF_SCOPE_PATTERN = "OUT_OF_SCOPE:"
21
+ INTENT_CHANGE_PATTERN = "INTENT_CHANGE:"
22
+ CLOSURE_PATTERN = "CLOSURE:"
23
+
24
+ # Default patterns that indicate user is done with follow-up
25
+ DEFAULT_CLOSURE_PATTERNS = [
26
+ "ok", "okay", "thank you", "thanks", "got it", "done",
27
+ "that's all", "no more questions", "i understand", "perfect",
28
+ "great", "alright", "understood"
29
+ ]
30
+
31
+
32
+ def _build_follow_up_instructions(
33
+ base_instructions: str,
34
+ context_summary: str,
35
+ collector_nodes: Dict[str, str],
36
+ enable_out_of_scope: bool,
37
+ scope_description: str
38
+ ) -> str:
39
+ """Build complete agent instructions for follow-up node."""
40
+ instructions = f"""{base_instructions}
41
+
42
+ {context_summary}
43
+
44
+ RESPONSE GUIDELINES:
45
+ 1. Answer the user's follow-up questions using the context above
46
+ 2. Be concise and helpful
47
+ 3. Do NOT ask for new information - just answer questions about existing data
48
+ """
49
+
50
+ # Add intent change detection if collector nodes exist
51
+ if collector_nodes:
52
+ nodes_str = "\n".join(f"- {k}: {v}" for k, v in collector_nodes.items())
53
+ instructions += f"""
54
+ INTENT CHANGE DETECTION:
55
+ If the user wants to change or update previously collected information, respond ONLY with:
56
+ INTENT_CHANGE: <node_name>
57
+
58
+ Do NOT provide any other response when detecting intent change.
59
+
60
+ Available nodes for intent change:
61
+ {nodes_str}
62
+ """
63
+
64
+ # Add out-of-scope detection if enabled
65
+ if enable_out_of_scope:
66
+ instructions += f"""
67
+ OUT-OF-SCOPE DETECTION:
68
+ Your current task is: {scope_description}
69
+
70
+ If the user's query is COMPLETELY UNRELATED to this task, respond ONLY with:
71
+ OUT_OF_SCOPE: <brief description of what user is asking about>
72
+
73
+ Do NOT attempt to answer out-of-scope questions.
74
+ """
75
+
76
+ return instructions
77
+
78
+
79
+ class FollowUpStrategy(ActionStrategy):
80
+ """
81
+ Strategy for handling follow-up questions and routing based on user intent.
82
+
83
+ Key difference from CollectInputStrategy:
84
+ - collect_input: Agent asks first, user responds
85
+ - follow_up: User asks first, agent responds
86
+
87
+ Features:
88
+ - Multi-turn conversation with full workflow state context
89
+ - Closure detection (user says "ok", "thank you", etc.)
90
+ - Intent change detection (route to collector nodes)
91
+ - Transition-based routing (route to any configured node)
92
+ - Out-of-scope detection (signal to parent orchestrator)
93
+ """
94
+
95
+ def __init__(self, step_config: Dict[str, Any], engine_context: Any):
96
+ super().__init__(step_config, engine_context)
97
+ self.agent_config = step_config.get('agent', {})
98
+ self.transitions = self._get_transitions()
99
+ self.next_step = step_config.get('next')
100
+
101
+ # Follow-up specific config
102
+ self.closure_patterns = step_config.get(
103
+ 'closure_patterns',
104
+ DEFAULT_CLOSURE_PATTERNS
105
+ )
106
+ self.enable_out_of_scope = self.agent_config.get('detect_out_of_scope', True)
107
+ self.scope_description = self.agent_config.get(
108
+ 'scope_description',
109
+ self.agent_config.get('description', 'answering follow-up questions')
110
+ )
111
+
112
+ @property
113
+ def _conversation_key(self) -> str:
114
+ return f'{self.step_id}_conversation'
115
+
116
+ def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
117
+ """Setup before execution - minimal for follow-up node."""
118
+ pass
119
+
120
+ def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
121
+ """Execute the follow-up node logic."""
122
+ with trace_node_execution(
123
+ node_id=self.step_id,
124
+ node_type="follow_up",
125
+ output_field=None
126
+ ) as span:
127
+ state = initialize_state(state)
128
+ conversation = self._get_or_create_conversation(state)
129
+ is_self_loop = self._is_self_loop(state)
130
+
131
+ # Get prompt for user
132
+ # - First entry: No prompt, user initiates
133
+ # - Self-loop: Show last assistant response as prompt
134
+ if is_self_loop:
135
+ prompt = self._get_last_assistant_message(conversation)
136
+ else:
137
+ prompt = None # User initiates on first entry
138
+ span.add_event("follow_up.first_entry")
139
+
140
+ # Interrupt to get user input
141
+ user_input = interrupt(prompt)
142
+ conversation.append({"role": "user", "content": user_input})
143
+ span.add_event("user.input_received", {"input_length": len(user_input)})
144
+
145
+ # Check for closure (user is done)
146
+ if self._is_closure_intent(user_input):
147
+ span.add_event("closure.detected")
148
+ return self._handle_closure(state)
149
+
150
+ # Create agent with full state context
151
+ agent = self._create_agent(state)
152
+
153
+ # Get agent response
154
+ with trace_agent_invocation(
155
+ agent_name=self.agent_config.get('name', self.step_id),
156
+ model=self.agent_config.get('model', 'default')
157
+ ):
158
+ agent_response = agent.invoke(conversation)
159
+ conversation.append({"role": "assistant", "content": str(agent_response)})
160
+
161
+ # Check for out-of-scope
162
+ if self.enable_out_of_scope and self._is_out_of_scope(agent_response):
163
+ span.add_event("out_of_scope.detected")
164
+ return self._handle_out_of_scope(agent_response, state, user_input)
165
+
166
+ # Check for intent change
167
+ if self._is_intent_change(agent_response):
168
+ span.add_event("intent_change.detected")
169
+ return self._handle_intent_change(agent_response, state)
170
+
171
+ # Check for explicit routing via transitions
172
+ if routing_target := self._check_transitions(agent_response):
173
+ span.add_event("transition.matched", {"target": routing_target})
174
+ self._set_status(state, routing_target)
175
+ if routing_target in self.engine_context.outcome_map:
176
+ self._set_outcome(state, routing_target)
177
+ return state
178
+
179
+ # Default: self-loop for more Q&A
180
+ self._set_status(state, 'answering')
181
+ self._update_conversation(state, conversation)
182
+ add_node_result(span, None, None, state.get(WorkflowKeys.STATUS))
183
+
184
+ return state
185
+
186
+ def _is_self_loop(self, state: Dict[str, Any]) -> bool:
187
+ """Check if we're in a self-loop (returning after answering)."""
188
+ return state.get(WorkflowKeys.STATUS) == f'{self.step_id}_answering'
189
+
190
+ def _get_or_create_conversation(self, state: Dict[str, Any]) -> List[Dict[str, str]]:
191
+ """Get or create conversation history for this node."""
192
+ conversations = state.get(WorkflowKeys.CONVERSATIONS, {})
193
+ if self._conversation_key not in conversations:
194
+ conversations[self._conversation_key] = []
195
+ state[WorkflowKeys.CONVERSATIONS] = conversations
196
+ return conversations[self._conversation_key]
197
+
198
+ def _update_conversation(self, state: Dict[str, Any], conversation: List[Dict[str, str]]):
199
+ """Update conversation in state."""
200
+ state[WorkflowKeys.CONVERSATIONS][self._conversation_key] = conversation
201
+
202
+ def _get_last_assistant_message(self, conversation: List[Dict[str, str]]) -> str:
203
+ """Get the last assistant message from conversation."""
204
+ return next(
205
+ (msg['content'] for msg in reversed(conversation) if msg['role'] == 'assistant'),
206
+ None
207
+ )
208
+
209
+ def _is_closure_intent(self, user_input: str) -> bool:
210
+ """Check if user input indicates they're done with follow-up."""
211
+ normalized = user_input.lower().strip()
212
+ # Check for exact matches or patterns within the input
213
+ for pattern in self.closure_patterns:
214
+ if pattern in normalized:
215
+ return True
216
+ return False
217
+
218
+ def _handle_closure(self, state: Dict[str, Any]) -> Dict[str, Any]:
219
+ """Handle user indicating they're done - proceed to next step."""
220
+ logger.info(f"Closure detected in follow-up node '{self.step_id}'")
221
+ if self.next_step:
222
+ self._set_status(state, self.next_step)
223
+ if self.next_step in self.engine_context.outcome_map:
224
+ self._set_outcome(state, self.next_step)
225
+ else:
226
+ self._set_status(state, 'complete')
227
+ state[WorkflowKeys.MESSAGES] = ["Follow-up complete."]
228
+ return state
229
+
230
+ def _is_out_of_scope(self, agent_response: str) -> bool:
231
+ """Check if agent response indicates out-of-scope query."""
232
+ return str(agent_response).startswith(OUT_OF_SCOPE_PATTERN)
233
+
234
+ def _handle_out_of_scope(
235
+ self,
236
+ agent_response: str,
237
+ state: Dict[str, Any],
238
+ user_message: str
239
+ ) -> Dict[str, Any]:
240
+ """Handle out-of-scope user input by signaling to parent orchestrator."""
241
+ reason = str(agent_response).split(OUT_OF_SCOPE_PATTERN)[1].strip()
242
+ logger.info(f"Out-of-scope detected in follow-up '{self.step_id}': {reason}")
243
+
244
+ state['_out_of_scope_reason'] = reason
245
+ interrupt({
246
+ "type": "out_of_scope",
247
+ "step_id": self.step_id,
248
+ "reason": reason,
249
+ "user_message": user_message
250
+ })
251
+ return state
252
+
253
+ def _is_intent_change(self, agent_response: str) -> bool:
254
+ """Check if agent response indicates intent change."""
255
+ return str(agent_response).startswith(INTENT_CHANGE_PATTERN)
256
+
257
+ def _handle_intent_change(self, agent_response: str, state: Dict[str, Any]) -> Dict[str, Any]:
258
+ """Handle intent change to another collector node."""
259
+ target_node = str(agent_response).split(INTENT_CHANGE_PATTERN)[1].strip()
260
+ logger.info(f"Intent change detected in follow-up '{self.step_id}' -> {target_node}")
261
+
262
+ # Route to the target collector node
263
+ self._set_status(state, target_node)
264
+ return state
265
+
266
+ def _check_transitions(self, agent_response: str) -> str:
267
+ """Check if agent response matches any transition pattern."""
268
+ response_str = str(agent_response)
269
+ for transition in self.transitions:
270
+ patterns = transition.get('pattern', [])
271
+ if isinstance(patterns, str):
272
+ patterns = [patterns]
273
+
274
+ for pattern in patterns:
275
+ if pattern in response_str:
276
+ return transition.get('next')
277
+ return None
278
+
279
+ def _get_model_config(self) -> Dict[str, Any]:
280
+ """Get model configuration for the agent."""
281
+ model_config = self.engine_context.get_config_value('model_config')
282
+ if not model_config:
283
+ raise ValueError("Model config not found in engine context")
284
+
285
+ if model_id := self.agent_config.get("model"):
286
+ model_config = model_config.copy()
287
+ model_config["model_name"] = model_id
288
+
289
+ return model_config
290
+
291
+ def _load_agent_tools(self, state: Dict[str, Any]) -> List:
292
+ """Load tools for the agent."""
293
+ return [
294
+ self.engine_context.tool_repository.load(tool_name, state)
295
+ for tool_name in self.agent_config.get('tools', [])
296
+ ]
297
+
298
+ def _create_agent(self, state: Dict[str, Any]) -> AgentAdapter:
299
+ """Create agent with full workflow context in instructions."""
300
+ try:
301
+ model_config = self._get_model_config()
302
+ agent_tools = self._load_agent_tools(state)
303
+ collector_nodes = state.get(WorkflowKeys.COLLECTOR_NODES, {})
304
+
305
+ # Build context summary from collected fields
306
+ context_summary = self._build_context_summary(state)
307
+
308
+ # Build complete instructions
309
+ base_instructions = self.agent_config.get(
310
+ 'instructions',
311
+ "You are a helpful assistant answering follow-up questions."
312
+ )
313
+ instructions = _build_follow_up_instructions(
314
+ base_instructions=base_instructions,
315
+ context_summary=context_summary,
316
+ collector_nodes=collector_nodes,
317
+ enable_out_of_scope=self.enable_out_of_scope,
318
+ scope_description=self.scope_description
319
+ )
320
+
321
+ framework = self.engine_context.get_config_value('agent_framework', 'langgraph')
322
+
323
+ return AgentFactory.create_agent(
324
+ framework=framework,
325
+ name=self.agent_config.get('name', f'{self.step_id}FollowUp'),
326
+ model_config=model_config,
327
+ tools=agent_tools,
328
+ system_prompt=instructions
329
+ )
330
+
331
+ except Exception as e:
332
+ raise RuntimeError(f"Failed to create agent for follow-up '{self.step_id}': {e}")
333
+
334
+ def _build_context_summary(self, state: Dict[str, Any]) -> str:
335
+ """Build a summary of collected workflow data for the agent."""
336
+ context_lines = ["Current workflow context:"]
337
+
338
+ for field in self.engine_context.data_fields:
339
+ field_name = field['name']
340
+ if field_name in state and state[field_name] is not None:
341
+ context_lines.append(f"- {field_name}: {state[field_name]}")
342
+
343
+ if len(context_lines) == 1:
344
+ context_lines.append("- (No data collected yet)")
345
+
346
+ return "\n".join(context_lines)
@@ -36,6 +36,10 @@ class WorkflowRouter:
36
36
  logger.info(f"Self-loop: {self.step_id} (async pending)")
37
37
  return self.step_id
38
38
 
39
+ if status == f'{self.step_id}_answering':
40
+ logger.info(f"Self-loop: {self.step_id} (follow-up answering)")
41
+ return self.step_id
42
+
39
43
  if status == f'{self.step_id}_error' :
40
44
  logger.info(f"Error encountered in {self.step_id}, ending workflow")
41
45
  return END
@@ -77,8 +81,8 @@ class WorkflowRouter:
77
81
  def get_routing_map(self, collector_nodes: List[str]) -> Dict[str, str]:
78
82
  routing_map = {}
79
83
 
80
- # Self-loop for nodes that can interrupt (agent input or async)
81
- if self.action in ('collect_input_with_agent', 'call_async_function'):
84
+ # Self-loop for nodes that can interrupt (agent input, async, or follow-up)
85
+ if self.action in ('collect_input_with_agent', 'call_async_function', 'follow_up'):
82
86
  routing_map[self.step_id] = self.step_id
83
87
 
84
88
  for transition in self.transitions:
soprano_sdk/tools.py CHANGED
@@ -129,6 +129,16 @@ class WorkflowTool:
129
129
  pending_metadata = json.dumps(interrupt_value.get("pending", {}))
130
130
  return f"{InterruptType.ASYNC}|{thread_id}|{self.name}|{pending_metadata}"
131
131
 
132
+ # Check if this is an out-of-scope interrupt
133
+ if isinstance(interrupt_value, dict) and interrupt_value.get("type") == "out_of_scope":
134
+ span.set_attribute("workflow.status", "out_of_scope")
135
+ span.set_attribute("out_of_scope.step_id", interrupt_value.get("step_id", ""))
136
+ payload = json.dumps({
137
+ "reason": interrupt_value.get("reason", "User query is out of scope"),
138
+ "user_message": interrupt_value.get("user_message", "")
139
+ })
140
+ return f"{InterruptType.OUT_OF_SCOPE}|{thread_id}|{self.name}|{payload}"
141
+
132
142
  # User input interrupt (existing behavior)
133
143
  span.set_attribute("workflow.status", "interrupted")
134
144
  prompt = interrupt_value
@@ -211,6 +221,14 @@ class WorkflowTool:
211
221
  pending_metadata = json.dumps(interrupt_value.get("pending", {}))
212
222
  return f"{InterruptType.ASYNC}|{thread_id}|{self.name}|{pending_metadata}"
213
223
 
224
+ # Check if this is an out-of-scope interrupt
225
+ if isinstance(interrupt_value, dict) and interrupt_value.get("type") == "out_of_scope":
226
+ payload = json.dumps({
227
+ "reason": interrupt_value.get("reason", "User query is out of scope"),
228
+ "user_message": interrupt_value.get("user_message", "")
229
+ })
230
+ return f"{InterruptType.OUT_OF_SCOPE}|{thread_id}|{self.name}|{payload}"
231
+
214
232
  # User input interrupt
215
233
  return f"{InterruptType.USER_INPUT}|{thread_id}|{self.name}|{interrupt_value}"
216
234
 
@@ -58,6 +58,10 @@ WORKFLOW_SCHEMA = {
58
58
  "description": "Name of a field from the data array"
59
59
  }
60
60
  },
61
+ "failure_message":{
62
+ "type": "string",
63
+ "description": "Default message to be returned when any error occurs within the framework."
64
+ },
61
65
  "steps": {
62
66
  "type": "array",
63
67
  "description": "Workflow steps",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soprano-sdk
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: YAML-driven workflow engine with AI agent integration for building conversational SOPs
5
5
  Author: Arvind Thangamani
6
6
  License: MIT
@@ -51,6 +51,9 @@ A YAML-driven workflow engine with AI agent integration for building conversatio
51
51
  - **External Context Injection**: Support for pre-populated fields from external orchestrators
52
52
  - **Pattern Matching**: Flexible transition logic based on patterns and conditions
53
53
  - **Visualization**: Generate workflow graphs as images or Mermaid diagrams
54
+ - **Follow-up Conversations**: Handle user follow-up questions with full workflow context
55
+ - **Intent Detection**: Route users between collector nodes based on detected intent
56
+ - **Out-of-Scope Detection**: Signal when user queries are unrelated to the current workflow
54
57
 
55
58
  ## Installation
56
59
 
@@ -268,6 +271,108 @@ Calls a Python function with workflow state.
268
271
  next: failure_step
269
272
  ```
270
273
 
274
+ ### call_async_function
275
+
276
+ Calls an async function that may return a pending status, triggering an interrupt until the async operation completes.
277
+
278
+ ```yaml
279
+ - id: verify_payment
280
+ action: call_async_function
281
+ function: "payments.start_verification"
282
+ output: verification_result
283
+ transitions:
284
+ - condition: "verified"
285
+ next: payment_approved
286
+ - condition: "failed"
287
+ next: payment_rejected
288
+ ```
289
+
290
+ ### follow_up
291
+
292
+ Handles follow-up questions from users. Unlike `collect_input_with_agent` where the agent asks first, here the **user initiates** by asking questions. The agent responds using full workflow context.
293
+
294
+ ```yaml
295
+ - id: handle_questions
296
+ action: follow_up
297
+ next: final_confirmation # Where to go when user says "done"
298
+ closure_patterns: # Optional: customize closure detection
299
+ - "ok"
300
+ - "thank you"
301
+ - "done"
302
+ agent:
303
+ name: "FollowUpAssistant"
304
+ model: "gpt-4o-mini"
305
+ description: "Answering questions about the order"
306
+ instructions: |
307
+ Help the user with any questions about their order.
308
+ Be concise and helpful.
309
+ detect_out_of_scope: true # Signal when user asks unrelated questions
310
+ transitions: # Optional: route based on patterns
311
+ - pattern: "ROUTE_TO_PAYMENT:"
312
+ next: payment_step
313
+ ```
314
+
315
+ **Key features:**
316
+ - **User initiates**: No initial prompt - waits for user to ask a question
317
+ - **Full state context**: Agent sees all collected workflow data
318
+ - **Closure detection**: Detects "ok", "thanks", "done" → proceeds to next step
319
+ - **Intent change**: Routes to collector nodes when user wants to change data
320
+ - **Out-of-scope**: Signals to parent orchestrator for unrelated queries
321
+
322
+ ## Interrupt Types
323
+
324
+ The workflow engine uses three interrupt types to pause execution and communicate with the caller:
325
+
326
+ | Type | Marker | Triggered By | Use Case |
327
+ |------|--------|--------------|----------|
328
+ | **USER_INPUT** | `__WORKFLOW_INTERRUPT__` | `collect_input_with_agent`, `follow_up` | Waiting for user input |
329
+ | **ASYNC** | `__ASYNC_INTERRUPT__` | `call_async_function` | Waiting for async operation callback |
330
+ | **OUT_OF_SCOPE** | `__OUT_OF_SCOPE_INTERRUPT__` | `collect_input_with_agent`, `follow_up` | User query unrelated to current task |
331
+
332
+ ### Handling Interrupts
333
+
334
+ ```python
335
+ result = graph.invoke({}, config=config)
336
+
337
+ if "__interrupt__" in result and result["__interrupt__"]:
338
+ interrupt_value = result["__interrupt__"][0].value
339
+
340
+ # Check interrupt type
341
+ if isinstance(interrupt_value, dict):
342
+ if interrupt_value.get("type") == "async":
343
+ # Async interrupt - wait for external callback
344
+ pending_metadata = interrupt_value.get("pending")
345
+ # ... handle async operation ...
346
+ result = graph.invoke(Command(resume=async_result), config=config)
347
+
348
+ elif interrupt_value.get("type") == "out_of_scope":
349
+ # Out-of-scope - user asking unrelated question
350
+ reason = interrupt_value.get("reason")
351
+ user_message = interrupt_value.get("user_message")
352
+ # ... route to different workflow or handle appropriately ...
353
+ else:
354
+ # User input interrupt - prompt is a string
355
+ prompt = interrupt_value
356
+ user_input = input(f"Bot: {prompt}\nYou: ")
357
+ result = graph.invoke(Command(resume=user_input), config=config)
358
+ ```
359
+
360
+ ### Out-of-Scope Detection
361
+
362
+ Data collector and follow-up nodes can detect when user queries are unrelated to the current task. This is useful for multi-workflow systems where a supervisor agent needs to route users to different SOPs.
363
+
364
+ **Configuration:**
365
+ ```yaml
366
+ agent:
367
+ detect_out_of_scope: true # Enabled by default
368
+ scope_description: "collecting order information for returns" # Optional
369
+ ```
370
+
371
+ **Response format:**
372
+ ```
373
+ __OUT_OF_SCOPE_INTERRUPT__|{thread_id}|{workflow_name}|{"reason":"...","user_message":"..."}
374
+ ```
375
+
271
376
  ## Examples
272
377
 
273
378
  See the `examples/` directory for complete workflow examples:
@@ -411,6 +516,8 @@ Contributions are welcome! Please open an issue or submit a pull request.
411
516
  - ✅ Database persistence (SqliteSaver, PostgresSaver supported)
412
517
  - ✅ Pluggable checkpointer system
413
518
  - ✅ Thread ID strategies and examples
519
+ - ✅ Follow-up node for conversational Q&A
520
+ - ✅ Out-of-scope detection for multi-workflow routing
414
521
  - Additional action types (webhook, conditional branching, parallel execution)
415
522
  - More workflow examples (customer onboarding, support ticketing, approval flows)
416
523
  - Workflow testing utilities
@@ -1,25 +1,26 @@
1
1
  soprano_sdk/__init__.py,sha256=YZVl_SwQ0C-E_5_f1AwUe_hPcbgCt8k7k4_WAHM8vjE,243
2
2
  soprano_sdk/engine.py,sha256=EFK91iTHjp72otLN6Kg-yeLye2J3CAKN0QH4FI2taL8,14838
3
- soprano_sdk/tools.py,sha256=dmJ0OZ7Bj3rvjBQvLzgWlYRFVtNJOyMO2jLqaS13cAc,10971
3
+ soprano_sdk/tools.py,sha256=g2G86-PpSsnWkEmukRl0mVSj0ANk_Q39QrulFvfvA1M,12116
4
4
  soprano_sdk/agents/__init__.py,sha256=Yzbtv6iP_ABRgZo0IUjy9vDofEvLFbOjuABw758176A,636
5
- soprano_sdk/agents/adaptor.py,sha256=xJ3MBTU91fQxA9O7V5Xds6m8-n_NxkF0YiKGaWKLmrQ,4959
5
+ soprano_sdk/agents/adaptor.py,sha256=5y9e2F_ZILOPrkunvv0GhjVdniAnOOTaD4j3Ig-xd3c,5041
6
6
  soprano_sdk/agents/factory.py,sha256=CvXhpvtjf_Hb4Ce8WKAa0FzyY5Jm6lssvIIfSXu7jPY,9788
7
- soprano_sdk/agents/structured_output.py,sha256=7DSVzfMPsZAqBwI3v6XL15qG5Gh4jJ-qddcVPaa3gdc,3326
7
+ soprano_sdk/agents/structured_output.py,sha256=VnSRmgarbgsozQSdCr9fVSjclwi04GJ6Nz5lut9qiUk,3593
8
8
  soprano_sdk/authenticators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  soprano_sdk/authenticators/mfa.py,sha256=xnur1lxbx4r_pUaNPD8vtQiNccQTjbPnqgi_vqdRZaQ,7336
10
10
  soprano_sdk/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- soprano_sdk/core/constants.py,sha256=UPXlRbF7gsOUNOV0Lm0jvgFfgZX7JrsV6n9I5csMfns,3508
12
- soprano_sdk/core/engine.py,sha256=HKYoqwDm541pWSWwEKHxLlL3PX90Ux_5l_-HqihgL-g,12245
11
+ soprano_sdk/core/constants.py,sha256=SpFf2_G0-qWYWLxbom-G9vF3lsX1jzJEXI9rVyRRC7I,3584
12
+ soprano_sdk/core/engine.py,sha256=RcUsWEyC9VljTxsv11-jTpmdO4k8lejK_5_OpHR3pS8,12330
13
13
  soprano_sdk/core/rollback_strategies.py,sha256=NjDTtBCZlqyDql5PSwI9SMDLK7_BNlTxbW_cq_5gV0g,7783
14
- soprano_sdk/core/state.py,sha256=k8ojLfWgjES3p9XWMeGU5s4UK-Xa5T8mS4VtZzTrcDw,2961
14
+ soprano_sdk/core/state.py,sha256=BZWL5W9zecjNe-IFg_4zv0lSLPqRST67mN01Zi8H2mk,2976
15
15
  soprano_sdk/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  soprano_sdk/nodes/async_function.py,sha256=v6WujLKm8NXX2iAkJ7Gz_QIVCtWFrpC6nnPyyfuCxXs,9354
17
- soprano_sdk/nodes/base.py,sha256=idFyOGGPnjsASYnrOF_NIh7eFcSuJqw61EoVN_WCTaU,2360
17
+ soprano_sdk/nodes/base.py,sha256=gjgAe5dqNk5ItOETLhwF6is7l9BJtrAouLXFOiDKA3E,2601
18
18
  soprano_sdk/nodes/call_function.py,sha256=afYBmj5Aditbkvb_7gD3CsXBEEUohcsC1_cdHfcOunE,5847
19
- soprano_sdk/nodes/collect_input.py,sha256=PySlghXOWDl6AYKgimY_7BnVFN7odYG656aBK_z4ACE,24617
20
- soprano_sdk/nodes/factory.py,sha256=IbBzT4FKBnYw5PuSo7uDONV3HSFtoyqjBQQtXtUY2IY,1756
19
+ soprano_sdk/nodes/collect_input.py,sha256=5b_QbFQ3eIgohhchvDY_-o3Q66I5llWaqIoKkB56t5A,27744
20
+ soprano_sdk/nodes/factory.py,sha256=i37whA0ugMSwbmWk6H798Suhq6J108a5VenuFqxCVu8,1863
21
+ soprano_sdk/nodes/follow_up.py,sha256=UKy6OswSoMeJ0U5Basf5CAvrbFzERvmwYB-ii8JAt6U,13873
21
22
  soprano_sdk/routing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
- soprano_sdk/routing/router.py,sha256=Z218r4BMbmlL9282ombutAoKsIs1WHZ2d5YHnbCeet8,3698
23
+ soprano_sdk/routing/router.py,sha256=ECZn7NIVrVQ5l5eZr8sfVNT0MShixnQEsqh4YO6XwRs,3882
23
24
  soprano_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
25
  soprano_sdk/utils/function.py,sha256=yqkY4MlHOenv-Q3NciiovK1lamyrGQljpy6Q41wviy8,1216
25
26
  soprano_sdk/utils/logger.py,sha256=hMYaNHt5syGOXRkglTUKzkgfSbWerix_pHQntcYyep8,157
@@ -27,9 +28,9 @@ soprano_sdk/utils/template.py,sha256=MG_B9TMx1ShpnSGo7s7TO-VfQzuFByuRNhJTvZ668kM
27
28
  soprano_sdk/utils/tool.py,sha256=hWN826HIKmLdswLCTURLH8hWlb2WU0MB8nIUErbpB-8,1877
28
29
  soprano_sdk/utils/tracing.py,sha256=gSHeBDLe-MbAZ9rkzpCoGFveeMdR9KLaA6tteB0IWjk,1991
29
30
  soprano_sdk/validation/__init__.py,sha256=ImChmO86jYHU90xzTttto2-LmOUOmvY_ibOQaLRz5BA,262
30
- soprano_sdk/validation/schema.py,sha256=eRGXXMDlJyaH0XYMOXY1azvJlJwOdbHFrHjB1yuGROg,15434
31
+ soprano_sdk/validation/schema.py,sha256=THGjMdS9qqAiSJgWjhDZ8jwHA_WaPgjKop_8CfHeTeY,15607
31
32
  soprano_sdk/validation/validator.py,sha256=f-e2MMRL70asOIXr_0Fsd5CgGKVRiQp7AaYsHA45Km0,8792
32
- soprano_sdk-0.2.18.dist-info/METADATA,sha256=hr2us3VV7sfP7P74YQxTmViJ1L8jkmCLtYeOGDyvvhE,11374
33
- soprano_sdk-0.2.18.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
34
- soprano_sdk-0.2.18.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
35
- soprano_sdk-0.2.18.dist-info/RECORD,,
33
+ soprano_sdk-0.2.20.dist-info/METADATA,sha256=q_6TFRVADa6_NV3MKKuAqVESfka8WpZT6IXOlqjNFW8,15563
34
+ soprano_sdk-0.2.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
35
+ soprano_sdk-0.2.20.dist-info/licenses/LICENSE,sha256=A1aBauSjPNtVehOXJe3WuvdU2xvM9H8XmigFMm6665s,1073
36
+ soprano_sdk-0.2.20.dist-info/RECORD,,