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.
- soprano_sdk/agents/adaptor.py +5 -3
- soprano_sdk/agents/structured_output.py +5 -1
- soprano_sdk/core/constants.py +2 -0
- soprano_sdk/core/engine.py +1 -0
- soprano_sdk/core/state.py +2 -2
- soprano_sdk/nodes/base.py +6 -1
- soprano_sdk/nodes/collect_input.py +80 -7
- soprano_sdk/nodes/factory.py +2 -0
- soprano_sdk/nodes/follow_up.py +346 -0
- soprano_sdk/routing/router.py +6 -2
- soprano_sdk/tools.py +18 -0
- soprano_sdk/validation/schema.py +4 -0
- {soprano_sdk-0.2.18.dist-info → soprano_sdk-0.2.20.dist-info}/METADATA +108 -1
- {soprano_sdk-0.2.18.dist-info → soprano_sdk-0.2.20.dist-info}/RECORD +16 -15
- {soprano_sdk-0.2.18.dist-info → soprano_sdk-0.2.20.dist-info}/WHEEL +0 -0
- {soprano_sdk-0.2.18.dist-info → soprano_sdk-0.2.20.dist-info}/licenses/LICENSE +0 -0
soprano_sdk/agents/adaptor.py
CHANGED
|
@@ -59,7 +59,9 @@ class CrewAIAgentAdapter(AgentAdapter):
|
|
|
59
59
|
return response
|
|
60
60
|
|
|
61
61
|
# Convert to string for parsing
|
|
62
|
-
|
|
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
|
-
#
|
|
82
|
-
logger.error("
|
|
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")
|
soprano_sdk/core/constants.py
CHANGED
|
@@ -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):
|
soprano_sdk/core/engine.py
CHANGED
|
@@ -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[
|
|
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
|
-
|
|
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
|
|
soprano_sdk/nodes/factory.py
CHANGED
|
@@ -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)
|
soprano_sdk/routing/router.py
CHANGED
|
@@ -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
|
|
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
|
|
soprano_sdk/validation/schema.py
CHANGED
|
@@ -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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
12
|
-
soprano_sdk/core/engine.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
20
|
-
soprano_sdk/nodes/factory.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
33
|
-
soprano_sdk-0.2.
|
|
34
|
-
soprano_sdk-0.2.
|
|
35
|
-
soprano_sdk-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|