soe-ai 0.1.1__py3-none-any.whl → 0.1.3__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.
- soe/builtin_tools/__init__.py +39 -0
- soe/builtin_tools/soe_add_signal.py +82 -0
- soe/builtin_tools/soe_call_tool.py +111 -0
- soe/builtin_tools/soe_copy_context.py +80 -0
- soe/builtin_tools/soe_explore_docs.py +290 -0
- soe/builtin_tools/soe_get_available_tools.py +42 -0
- soe/builtin_tools/soe_get_context.py +50 -0
- soe/builtin_tools/soe_get_workflows.py +63 -0
- soe/builtin_tools/soe_inject_node.py +86 -0
- soe/builtin_tools/soe_inject_workflow.py +105 -0
- soe/builtin_tools/soe_list_contexts.py +73 -0
- soe/builtin_tools/soe_remove_node.py +72 -0
- soe/builtin_tools/soe_remove_workflow.py +62 -0
- soe/builtin_tools/soe_update_context.py +54 -0
- soe/docs/_config.yml +10 -0
- soe/docs/advanced_patterns/guide_fanout_and_aggregations.md +318 -0
- soe/docs/advanced_patterns/guide_inheritance.md +435 -0
- soe/docs/advanced_patterns/hybrid_intelligence.md +237 -0
- soe/docs/advanced_patterns/index.md +49 -0
- soe/docs/advanced_patterns/operational.md +781 -0
- soe/docs/advanced_patterns/self_evolving_workflows.md +385 -0
- soe/docs/advanced_patterns/swarm_intelligence.md +211 -0
- soe/docs/builtins/context.md +164 -0
- soe/docs/builtins/explore_docs.md +135 -0
- soe/docs/builtins/tools.md +164 -0
- soe/docs/builtins/workflows.md +199 -0
- soe/docs/guide_00_getting_started.md +341 -0
- soe/docs/guide_01_tool.md +206 -0
- soe/docs/guide_02_llm.md +143 -0
- soe/docs/guide_03_router.md +146 -0
- soe/docs/guide_04_patterns.md +475 -0
- soe/docs/guide_05_agent.md +159 -0
- soe/docs/guide_06_schema.md +397 -0
- soe/docs/guide_07_identity.md +540 -0
- soe/docs/guide_08_child.md +612 -0
- soe/docs/guide_09_ecosystem.md +690 -0
- soe/docs/guide_10_infrastructure.md +427 -0
- soe/docs/guide_11_builtins.md +118 -0
- soe/docs/index.md +104 -0
- soe/docs/primitives/backends.md +281 -0
- soe/docs/primitives/context.md +256 -0
- soe/docs/primitives/node_reference.md +259 -0
- soe/docs/primitives/primitives.md +331 -0
- soe/docs/primitives/signals.md +865 -0
- soe/docs_index.py +1 -1
- soe/lib/__init__.py +0 -0
- soe/lib/child_context.py +46 -0
- soe/lib/context_fields.py +51 -0
- soe/lib/inheritance.py +172 -0
- soe/lib/jinja_render.py +113 -0
- soe/lib/operational.py +51 -0
- soe/lib/parent_sync.py +71 -0
- soe/lib/register_event.py +75 -0
- soe/lib/schema_validation.py +134 -0
- soe/lib/yaml_parser.py +14 -0
- soe/local_backends/__init__.py +18 -0
- soe/local_backends/factory.py +124 -0
- soe/local_backends/in_memory/context.py +38 -0
- soe/local_backends/in_memory/conversation_history.py +60 -0
- soe/local_backends/in_memory/identity.py +52 -0
- soe/local_backends/in_memory/schema.py +40 -0
- soe/local_backends/in_memory/telemetry.py +38 -0
- soe/local_backends/in_memory/workflow.py +33 -0
- soe/local_backends/storage/context.py +57 -0
- soe/local_backends/storage/conversation_history.py +82 -0
- soe/local_backends/storage/identity.py +118 -0
- soe/local_backends/storage/schema.py +96 -0
- soe/local_backends/storage/telemetry.py +72 -0
- soe/local_backends/storage/workflow.py +56 -0
- soe/nodes/__init__.py +13 -0
- soe/nodes/agent/__init__.py +10 -0
- soe/nodes/agent/factory.py +134 -0
- soe/nodes/agent/lib/loop_handlers.py +150 -0
- soe/nodes/agent/lib/loop_state.py +157 -0
- soe/nodes/agent/lib/prompts.py +65 -0
- soe/nodes/agent/lib/tools.py +35 -0
- soe/nodes/agent/stages/__init__.py +12 -0
- soe/nodes/agent/stages/parameter.py +37 -0
- soe/nodes/agent/stages/response.py +54 -0
- soe/nodes/agent/stages/router.py +37 -0
- soe/nodes/agent/state.py +111 -0
- soe/nodes/agent/types.py +66 -0
- soe/nodes/agent/validation/__init__.py +11 -0
- soe/nodes/agent/validation/config.py +95 -0
- soe/nodes/agent/validation/operational.py +24 -0
- soe/nodes/child/__init__.py +3 -0
- soe/nodes/child/factory.py +61 -0
- soe/nodes/child/state.py +59 -0
- soe/nodes/child/validation/__init__.py +11 -0
- soe/nodes/child/validation/config.py +126 -0
- soe/nodes/child/validation/operational.py +28 -0
- soe/nodes/lib/conditions.py +71 -0
- soe/nodes/lib/context.py +24 -0
- soe/nodes/lib/conversation_history.py +77 -0
- soe/nodes/lib/identity.py +64 -0
- soe/nodes/lib/llm_resolver.py +142 -0
- soe/nodes/lib/output.py +68 -0
- soe/nodes/lib/response_builder.py +91 -0
- soe/nodes/lib/signal_emission.py +79 -0
- soe/nodes/lib/signals.py +54 -0
- soe/nodes/lib/tools.py +100 -0
- soe/nodes/llm/__init__.py +7 -0
- soe/nodes/llm/factory.py +103 -0
- soe/nodes/llm/state.py +76 -0
- soe/nodes/llm/types.py +12 -0
- soe/nodes/llm/validation/__init__.py +11 -0
- soe/nodes/llm/validation/config.py +89 -0
- soe/nodes/llm/validation/operational.py +23 -0
- soe/nodes/router/__init__.py +3 -0
- soe/nodes/router/factory.py +37 -0
- soe/nodes/router/state.py +32 -0
- soe/nodes/router/validation/__init__.py +11 -0
- soe/nodes/router/validation/config.py +58 -0
- soe/nodes/router/validation/operational.py +16 -0
- soe/nodes/tool/factory.py +66 -0
- soe/nodes/tool/lib/__init__.py +11 -0
- soe/nodes/tool/lib/conditions.py +35 -0
- soe/nodes/tool/lib/failure.py +28 -0
- soe/nodes/tool/lib/parameters.py +67 -0
- soe/nodes/tool/state.py +66 -0
- soe/nodes/tool/types.py +27 -0
- soe/nodes/tool/validation/__init__.py +15 -0
- soe/nodes/tool/validation/config.py +132 -0
- soe/nodes/tool/validation/operational.py +16 -0
- soe/validation/__init__.py +18 -0
- soe/validation/config.py +195 -0
- soe/validation/jinja.py +54 -0
- soe/validation/operational.py +110 -0
- {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/METADATA +5 -5
- soe_ai-0.1.3.dist-info/RECORD +137 -0
- {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/WHEEL +1 -1
- soe_ai-0.1.1.dist-info/RECORD +0 -10
- {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tool parameter extraction and validation utilities."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Dict, Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from ....lib.context_fields import get_field
|
|
7
|
+
from ..types import ToolParameterError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_tool_parameters(
|
|
11
|
+
context: Dict[str, Any],
|
|
12
|
+
context_parameter_field: Optional[str],
|
|
13
|
+
) -> Dict[str, Any]:
|
|
14
|
+
"""Extract tool parameters from context.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
context: The workflow context
|
|
18
|
+
context_parameter_field: Name of the context field containing tool kwargs
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Dict of parameters to pass to the tool function
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ToolParameterError: If field is missing or not a dict
|
|
25
|
+
"""
|
|
26
|
+
if not context_parameter_field:
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
if context_parameter_field not in context:
|
|
30
|
+
raise ToolParameterError(f"Context missing required field: {context_parameter_field}")
|
|
31
|
+
|
|
32
|
+
parameters = get_field(context, context_parameter_field)
|
|
33
|
+
|
|
34
|
+
if not isinstance(parameters, dict):
|
|
35
|
+
raise ToolParameterError(
|
|
36
|
+
f"Context field '{context_parameter_field}' must be a dict of parameters, got {type(parameters)}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return parameters
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_tool_parameters(
|
|
43
|
+
tool_function: Callable, parameters: Dict[str, Any], tool_name: str
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Validate parameters match tool function signature."""
|
|
46
|
+
signature = inspect.signature(tool_function)
|
|
47
|
+
|
|
48
|
+
has_var_keyword = any(
|
|
49
|
+
param.kind == inspect.Parameter.VAR_KEYWORD
|
|
50
|
+
for param in signature.parameters.values()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for param_name, param in signature.parameters.items():
|
|
54
|
+
if param.kind in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL):
|
|
55
|
+
continue
|
|
56
|
+
if param.default == inspect.Parameter.empty:
|
|
57
|
+
if param_name not in parameters:
|
|
58
|
+
raise ToolParameterError(
|
|
59
|
+
f"Tool '{tool_name}' missing required parameter: {param_name}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not has_var_keyword:
|
|
63
|
+
for param_name in parameters.keys():
|
|
64
|
+
if param_name not in signature.parameters:
|
|
65
|
+
raise ToolParameterError(
|
|
66
|
+
f"Tool '{tool_name}' unexpected parameter: {param_name}"
|
|
67
|
+
)
|
soe/nodes/tool/state.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool node state retrieval.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Dict, Any, List, Optional
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
from ...types import Backends
|
|
9
|
+
from ...lib.yaml_parser import parse_yaml
|
|
10
|
+
from ...lib.context_fields import get_field
|
|
11
|
+
from ..lib.tools import get_tool_from_registry
|
|
12
|
+
from .types import ToolsRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolOperationalState(BaseModel):
|
|
16
|
+
"""All data needed for tool node execution."""
|
|
17
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
18
|
+
|
|
19
|
+
context: Dict[str, Any]
|
|
20
|
+
main_execution_id: str
|
|
21
|
+
tool_name: str
|
|
22
|
+
tool_function: Callable
|
|
23
|
+
max_retries: int
|
|
24
|
+
failure_signal: Optional[str]
|
|
25
|
+
output_field: Optional[str]
|
|
26
|
+
event_emissions: List[Dict[str, Any]]
|
|
27
|
+
parameters: Any # Can be Dict or List when process_accumulated=True
|
|
28
|
+
process_accumulated: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_operational_state(
|
|
32
|
+
execution_id: str,
|
|
33
|
+
node_config: Dict[str, Any],
|
|
34
|
+
backends: Backends,
|
|
35
|
+
tools_registry: ToolsRegistry,
|
|
36
|
+
) -> ToolOperationalState:
|
|
37
|
+
"""Retrieve all state needed for tool node execution."""
|
|
38
|
+
context = backends.context.get_context(execution_id)
|
|
39
|
+
operational = context["__operational__"]
|
|
40
|
+
tool_name = node_config["tool_name"]
|
|
41
|
+
tool_function, max_retries, failure_signal, process_accumulated = get_tool_from_registry(
|
|
42
|
+
tool_name, tools_registry, execution_id, backends
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
context_parameter_field = node_config.get("context_parameter_field")
|
|
46
|
+
if context_parameter_field and context_parameter_field in context:
|
|
47
|
+
if process_accumulated:
|
|
48
|
+
raw_params = context[context_parameter_field]
|
|
49
|
+
else:
|
|
50
|
+
raw_params = get_field(context, context_parameter_field)
|
|
51
|
+
parameters = parse_yaml(raw_params) if isinstance(raw_params, str) else raw_params
|
|
52
|
+
else:
|
|
53
|
+
parameters = {}
|
|
54
|
+
|
|
55
|
+
return ToolOperationalState(
|
|
56
|
+
context=context,
|
|
57
|
+
main_execution_id=operational["main_execution_id"],
|
|
58
|
+
tool_name=tool_name,
|
|
59
|
+
tool_function=tool_function,
|
|
60
|
+
max_retries=max_retries,
|
|
61
|
+
failure_signal=failure_signal,
|
|
62
|
+
output_field=node_config.get("output_field"),
|
|
63
|
+
event_emissions=node_config.get("event_emissions", []),
|
|
64
|
+
parameters=parameters,
|
|
65
|
+
process_accumulated=process_accumulated,
|
|
66
|
+
)
|
soe/nodes/tool/types.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool node models and exceptions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, TypedDict, Union, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistryEntry(TypedDict, total=False):
|
|
9
|
+
"""Extended tool registry entry with optional configuration"""
|
|
10
|
+
function: Callable
|
|
11
|
+
max_retries: int
|
|
12
|
+
failure_signal: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ToolsRegistry = Dict[str, Union[Callable, ToolRegistryEntry]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolNodeConfigurationError(Exception):
|
|
19
|
+
"""Raised when tool node configuration is invalid"""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolParameterError(Exception):
|
|
25
|
+
"""Raised when tool parameters don't match signature"""
|
|
26
|
+
|
|
27
|
+
pass
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool node validation.
|
|
3
|
+
|
|
4
|
+
- config.py: Config validation at orchestration start
|
|
5
|
+
- operational.py: Runtime validation before execution (fail-fast)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .config import validate_node_config, validate_tool_node_config
|
|
9
|
+
from .operational import validate_tool_node_runtime
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"validate_node_config",
|
|
13
|
+
"validate_tool_node_config",
|
|
14
|
+
"validate_tool_node_runtime",
|
|
15
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool node configuration validation.
|
|
3
|
+
|
|
4
|
+
Called once at orchestration start, not during node execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, Any, Callable, Union
|
|
8
|
+
|
|
9
|
+
from ....types import WorkflowValidationError
|
|
10
|
+
from ....builtin_tools import get_builtin_tool_factory
|
|
11
|
+
from ..types import ToolRegistryEntry, ToolsRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_function_from_entry(entry: Union[Callable, ToolRegistryEntry]) -> Callable:
|
|
15
|
+
"""Extract the callable from a registry entry"""
|
|
16
|
+
if callable(entry):
|
|
17
|
+
return entry
|
|
18
|
+
return entry.get("function")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_registry_entry(tool_name: str, entry: Union[Callable, ToolRegistryEntry]) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Validate a tool registry entry format.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
WorkflowValidationError: If entry format is invalid
|
|
27
|
+
"""
|
|
28
|
+
if callable(entry):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if not isinstance(entry, dict):
|
|
32
|
+
raise WorkflowValidationError(
|
|
33
|
+
f"Tool '{tool_name}' registry entry must be a callable or dict with 'function' key"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if "function" not in entry:
|
|
37
|
+
raise WorkflowValidationError(
|
|
38
|
+
f"Tool '{tool_name}' registry entry dict must have 'function' key"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not callable(entry["function"]):
|
|
42
|
+
raise WorkflowValidationError(
|
|
43
|
+
f"Tool '{tool_name}' 'function' must be callable"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
max_retries = entry.get("max_retries")
|
|
47
|
+
if max_retries is not None:
|
|
48
|
+
if not isinstance(max_retries, int) or max_retries < 0:
|
|
49
|
+
raise WorkflowValidationError(
|
|
50
|
+
f"Tool '{tool_name}' 'max_retries' must be a non-negative integer"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
failure_signal = entry.get("failure_signal")
|
|
54
|
+
if failure_signal is not None:
|
|
55
|
+
if not isinstance(failure_signal, str):
|
|
56
|
+
raise WorkflowValidationError(
|
|
57
|
+
f"Tool '{tool_name}' 'failure_signal' must be a string"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_node_config(node_config: Dict[str, Any]) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Validate tool node configuration (structure only, no runtime checks).
|
|
64
|
+
Called once at orchestration start, not during node execution.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
WorkflowValidationError: If configuration is invalid
|
|
68
|
+
"""
|
|
69
|
+
event_triggers = node_config.get("event_triggers")
|
|
70
|
+
if not event_triggers:
|
|
71
|
+
raise WorkflowValidationError(
|
|
72
|
+
"'event_triggers' is required - specify which signals activate this tool node"
|
|
73
|
+
)
|
|
74
|
+
if not isinstance(event_triggers, list):
|
|
75
|
+
raise WorkflowValidationError(
|
|
76
|
+
"'event_triggers' must be a list, e.g., [\"EXECUTE_TOOL\"]"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if "tool_name" not in node_config:
|
|
80
|
+
raise WorkflowValidationError(
|
|
81
|
+
"'tool_name' is required - specify which tool to execute"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
event_emissions = node_config.get("event_emissions")
|
|
85
|
+
if event_emissions is not None:
|
|
86
|
+
if not isinstance(event_emissions, list):
|
|
87
|
+
raise WorkflowValidationError(
|
|
88
|
+
"'event_emissions' must be a list of signal definitions"
|
|
89
|
+
)
|
|
90
|
+
for i, emission in enumerate(event_emissions):
|
|
91
|
+
if not isinstance(emission, dict):
|
|
92
|
+
raise WorkflowValidationError(
|
|
93
|
+
f"'event_emissions[{i}]' must be a dict with 'signal_name'"
|
|
94
|
+
)
|
|
95
|
+
if "signal_name" not in emission:
|
|
96
|
+
raise WorkflowValidationError(
|
|
97
|
+
f"'event_emissions[{i}]' must have 'signal_name'"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
output_field = node_config.get("output_field")
|
|
101
|
+
if output_field is not None:
|
|
102
|
+
if not isinstance(output_field, str):
|
|
103
|
+
raise WorkflowValidationError(
|
|
104
|
+
"'output_field' must be a string - the context field name to store the tool output"
|
|
105
|
+
)
|
|
106
|
+
if output_field == "__operational__":
|
|
107
|
+
raise WorkflowValidationError(
|
|
108
|
+
"'output_field' cannot be '__operational__' - this is a reserved system field"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_tool_node_config(
|
|
113
|
+
node_config: Dict[str, Any], tools_registry: ToolsRegistry
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Validate tool node configuration with runtime checks (tool registry)"""
|
|
116
|
+
|
|
117
|
+
validate_node_config(node_config)
|
|
118
|
+
|
|
119
|
+
tool_name = node_config["tool_name"]
|
|
120
|
+
if tool_name not in tools_registry:
|
|
121
|
+
if not get_builtin_tool_factory(tool_name):
|
|
122
|
+
raise WorkflowValidationError(
|
|
123
|
+
f"Tool '{tool_name}' not found in tools_registry or builtin tools"
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
entry = tools_registry[tool_name]
|
|
128
|
+
_validate_registry_entry(tool_name, entry)
|
|
129
|
+
|
|
130
|
+
tool_function = _get_function_from_entry(entry)
|
|
131
|
+
if not callable(tool_function):
|
|
132
|
+
raise WorkflowValidationError(f"Tool '{tool_name}' is not callable")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Tool node operational validation.
|
|
2
|
+
|
|
3
|
+
Calls shared operational validation. Tool node has no additional backend requirements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from ....types import Backends
|
|
8
|
+
from ....validation.operational import validate_operational
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_tool_node_runtime(
|
|
12
|
+
execution_id: str,
|
|
13
|
+
backends: Backends,
|
|
14
|
+
) -> Dict[str, Any]:
|
|
15
|
+
"""Validate runtime state for Tool node. Delegates to shared validation."""
|
|
16
|
+
return validate_operational(execution_id, backends)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation module for SOE.
|
|
3
|
+
|
|
4
|
+
Two types of validation:
|
|
5
|
+
1. config.py - Validates config structure at orchestration start
|
|
6
|
+
2. operational.py - Validates runtime state before node execution (fail-fast)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .config import validate_config, validate_workflow, validate_orchestrate_params
|
|
10
|
+
from .operational import validate_operational, OperationalValidationError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"validate_config",
|
|
14
|
+
"validate_workflow",
|
|
15
|
+
"validate_orchestrate_params",
|
|
16
|
+
"validate_operational",
|
|
17
|
+
"OperationalValidationError",
|
|
18
|
+
]
|
soe/validation/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config validation - validates config structure before orchestration starts.
|
|
3
|
+
|
|
4
|
+
Supports two config formats:
|
|
5
|
+
1. Single Workflow format: Dict of workflow definitions directly
|
|
6
|
+
2. Combined config format: Dict with 'workflows', 'context_schema', 'identities' keys
|
|
7
|
+
|
|
8
|
+
Runs once at orchestration start, before any execution.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Dict, Any
|
|
12
|
+
|
|
13
|
+
from ..types import WorkflowValidationError
|
|
14
|
+
from ..nodes.router.validation import validate_node_config as validate_router
|
|
15
|
+
from ..nodes.agent.validation import validate_node_config as validate_agent
|
|
16
|
+
from ..nodes.llm.validation import validate_node_config as validate_llm
|
|
17
|
+
from ..nodes.child.validation import validate_node_config as validate_child
|
|
18
|
+
from ..nodes.tool.validation import validate_node_config as validate_tool
|
|
19
|
+
from ..lib.yaml_parser import parse_yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
NODE_VALIDATORS = {
|
|
23
|
+
"router": validate_router,
|
|
24
|
+
"agent": validate_agent,
|
|
25
|
+
"llm": validate_llm,
|
|
26
|
+
"child": validate_child,
|
|
27
|
+
"tool": validate_tool,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_workflow_section(workflow_name: str, workflow: Dict[str, Any]) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Validate all nodes in a workflow section.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
workflow_name: Name of the workflow (for error messages)
|
|
37
|
+
workflow: Workflow definition dict
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
WorkflowValidationError: If any node configuration is invalid
|
|
41
|
+
"""
|
|
42
|
+
if not workflow:
|
|
43
|
+
raise WorkflowValidationError(
|
|
44
|
+
f"Workflow '{workflow_name}' is empty - at least one node is required"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for node_name, node_config in workflow.items():
|
|
48
|
+
if node_name.startswith("__"):
|
|
49
|
+
raise WorkflowValidationError(
|
|
50
|
+
f"Workflow '{workflow_name}': node name '{node_name}' is reserved - "
|
|
51
|
+
f"names starting with '__' are reserved for internal use"
|
|
52
|
+
)
|
|
53
|
+
node_type = node_config.get("node_type")
|
|
54
|
+
|
|
55
|
+
if not node_type:
|
|
56
|
+
raise WorkflowValidationError(
|
|
57
|
+
f"Workflow '{workflow_name}', node '{node_name}': "
|
|
58
|
+
f"'node_type' is required - specify the type (router, agent, llm, tool, child)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if node_type.startswith("_"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
validator = NODE_VALIDATORS.get(node_type)
|
|
65
|
+
if not validator:
|
|
66
|
+
valid_types = ", ".join(NODE_VALIDATORS.keys())
|
|
67
|
+
raise WorkflowValidationError(
|
|
68
|
+
f"Workflow '{workflow_name}', node '{node_name}': "
|
|
69
|
+
f"unknown node_type '{node_type}'. Valid types are: {valid_types}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
validator(node_config)
|
|
74
|
+
except WorkflowValidationError as e:
|
|
75
|
+
raise WorkflowValidationError(
|
|
76
|
+
f"Workflow '{workflow_name}', node '{node_name}': {e}"
|
|
77
|
+
) from e
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_context_schema_section(context_schema: Dict[str, Any]) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Validate context_schema section of config.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
context_schema: Context schema definitions
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
WorkflowValidationError: If schema format is invalid
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(context_schema, dict):
|
|
91
|
+
raise WorkflowValidationError(
|
|
92
|
+
"'context_schema' section must be an object mapping field names to schemas"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
for field_name, field_schema in context_schema.items():
|
|
96
|
+
if not isinstance(field_schema, (dict, str)):
|
|
97
|
+
raise WorkflowValidationError(
|
|
98
|
+
f"context_schema.{field_name}: schema must be an object or type string"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_identities_section(identities: Dict[str, str]) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Validate identities section of config.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
identities: Identity definitions (name -> system prompt)
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
WorkflowValidationError: If identity format is invalid
|
|
111
|
+
"""
|
|
112
|
+
if not isinstance(identities, dict):
|
|
113
|
+
raise WorkflowValidationError(
|
|
114
|
+
"'identities' section must be an object mapping identity names to prompts"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
for identity_name, identity_prompt in identities.items():
|
|
118
|
+
if not isinstance(identity_prompt, str):
|
|
119
|
+
raise WorkflowValidationError(
|
|
120
|
+
f"identities.{identity_name}: identity prompt must be a string, "
|
|
121
|
+
f"got {type(identity_prompt).__name__}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_config(config) -> Dict[str, Any]:
|
|
126
|
+
"""
|
|
127
|
+
Parse and validate config.
|
|
128
|
+
|
|
129
|
+
Supports two formats:
|
|
130
|
+
1. Legacy format: Dict of workflow definitions directly
|
|
131
|
+
2. Combined config format: Dict with 'workflows', 'context_schema', 'identities' keys
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
config: YAML string or dict (workflows only or combined config)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Parsed config dict (either workflows directly or combined structure)
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
WorkflowValidationError: If any configuration is invalid
|
|
141
|
+
"""
|
|
142
|
+
parsed = parse_yaml(config)
|
|
143
|
+
|
|
144
|
+
if "workflows" in parsed:
|
|
145
|
+
workflows = parsed["workflows"]
|
|
146
|
+
if not isinstance(workflows, dict):
|
|
147
|
+
raise WorkflowValidationError(
|
|
148
|
+
"'workflows' section must be an object containing workflow definitions"
|
|
149
|
+
)
|
|
150
|
+
for workflow_name, workflow in workflows.items():
|
|
151
|
+
if not isinstance(workflow, dict):
|
|
152
|
+
raise WorkflowValidationError(
|
|
153
|
+
f"Workflow '{workflow_name}' must be an object containing node definitions"
|
|
154
|
+
)
|
|
155
|
+
_validate_workflow_section(workflow_name, workflow)
|
|
156
|
+
|
|
157
|
+
context_schema = parsed.get("context_schema")
|
|
158
|
+
if context_schema is not None:
|
|
159
|
+
_validate_context_schema_section(context_schema)
|
|
160
|
+
|
|
161
|
+
identities = parsed.get("identities")
|
|
162
|
+
if identities is not None:
|
|
163
|
+
_validate_identities_section(identities)
|
|
164
|
+
else:
|
|
165
|
+
for workflow_name, workflow in parsed.items():
|
|
166
|
+
if not isinstance(workflow, dict):
|
|
167
|
+
raise WorkflowValidationError(
|
|
168
|
+
f"Workflow '{workflow_name}' must be an object containing node definitions"
|
|
169
|
+
)
|
|
170
|
+
_validate_workflow_section(workflow_name, workflow)
|
|
171
|
+
|
|
172
|
+
return parsed
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
validate_workflow = _validate_workflow_section
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_orchestrate_params(
|
|
179
|
+
initial_workflow_name: str,
|
|
180
|
+
initial_signals: list,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Validate orchestrate() parameters before execution starts."""
|
|
183
|
+
if not isinstance(initial_signals, list):
|
|
184
|
+
raise WorkflowValidationError(
|
|
185
|
+
f"'initial_signals' must be a list, got {type(initial_signals).__name__}. "
|
|
186
|
+
f"Example: initial_signals=['START']"
|
|
187
|
+
)
|
|
188
|
+
if not initial_signals:
|
|
189
|
+
raise WorkflowValidationError(
|
|
190
|
+
"'initial_signals' cannot be empty - at least one signal is required to start execution"
|
|
191
|
+
)
|
|
192
|
+
if not isinstance(initial_workflow_name, str) or not initial_workflow_name:
|
|
193
|
+
raise WorkflowValidationError(
|
|
194
|
+
"'initial_workflow_name' must be a non-empty string"
|
|
195
|
+
)
|
soe/validation/jinja.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jinja template validation utilities.
|
|
3
|
+
|
|
4
|
+
Used during config validation to catch Jinja errors early.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from jinja2 import Environment, BaseLoader, TemplateSyntaxError
|
|
8
|
+
from ..types import WorkflowValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dummy_accumulated_filter(value):
|
|
12
|
+
"""Dummy accumulated filter for validation - just returns value as list."""
|
|
13
|
+
return [value] if value is not None else []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_jinja_syntax(template: str, context_description: str) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Validate Jinja template syntax at config time.
|
|
19
|
+
|
|
20
|
+
Called during config validation to catch Jinja errors early.
|
|
21
|
+
|
|
22
|
+
Catches:
|
|
23
|
+
- Unclosed braces {{ without }}
|
|
24
|
+
- Unknown filters like | capitalize_all
|
|
25
|
+
- Basic syntax errors
|
|
26
|
+
|
|
27
|
+
Does NOT catch:
|
|
28
|
+
- Runtime errors like division by zero (depends on context values)
|
|
29
|
+
- Undefined variables (depends on context at runtime)
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
template: The Jinja template string to validate
|
|
33
|
+
context_description: Description for error messages (e.g., "condition for signal 'DONE'")
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
WorkflowValidationError: If template has syntax or filter errors
|
|
37
|
+
"""
|
|
38
|
+
if not template or ("{{" not in template and "{%" not in template):
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
env = Environment(loader=BaseLoader())
|
|
42
|
+
env.filters["accumulated"] = _dummy_accumulated_filter
|
|
43
|
+
env.parse(template)
|
|
44
|
+
env.from_string(template)
|
|
45
|
+
except TemplateSyntaxError as e:
|
|
46
|
+
raise WorkflowValidationError(
|
|
47
|
+
f"{context_description}: Jinja syntax error - {e.message}"
|
|
48
|
+
)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
error_msg = str(e)
|
|
51
|
+
if "filter" in error_msg.lower():
|
|
52
|
+
raise WorkflowValidationError(
|
|
53
|
+
f"{context_description}: {error_msg}"
|
|
54
|
+
)
|