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.
Files changed (134) hide show
  1. soe/builtin_tools/__init__.py +39 -0
  2. soe/builtin_tools/soe_add_signal.py +82 -0
  3. soe/builtin_tools/soe_call_tool.py +111 -0
  4. soe/builtin_tools/soe_copy_context.py +80 -0
  5. soe/builtin_tools/soe_explore_docs.py +290 -0
  6. soe/builtin_tools/soe_get_available_tools.py +42 -0
  7. soe/builtin_tools/soe_get_context.py +50 -0
  8. soe/builtin_tools/soe_get_workflows.py +63 -0
  9. soe/builtin_tools/soe_inject_node.py +86 -0
  10. soe/builtin_tools/soe_inject_workflow.py +105 -0
  11. soe/builtin_tools/soe_list_contexts.py +73 -0
  12. soe/builtin_tools/soe_remove_node.py +72 -0
  13. soe/builtin_tools/soe_remove_workflow.py +62 -0
  14. soe/builtin_tools/soe_update_context.py +54 -0
  15. soe/docs/_config.yml +10 -0
  16. soe/docs/advanced_patterns/guide_fanout_and_aggregations.md +318 -0
  17. soe/docs/advanced_patterns/guide_inheritance.md +435 -0
  18. soe/docs/advanced_patterns/hybrid_intelligence.md +237 -0
  19. soe/docs/advanced_patterns/index.md +49 -0
  20. soe/docs/advanced_patterns/operational.md +781 -0
  21. soe/docs/advanced_patterns/self_evolving_workflows.md +385 -0
  22. soe/docs/advanced_patterns/swarm_intelligence.md +211 -0
  23. soe/docs/builtins/context.md +164 -0
  24. soe/docs/builtins/explore_docs.md +135 -0
  25. soe/docs/builtins/tools.md +164 -0
  26. soe/docs/builtins/workflows.md +199 -0
  27. soe/docs/guide_00_getting_started.md +341 -0
  28. soe/docs/guide_01_tool.md +206 -0
  29. soe/docs/guide_02_llm.md +143 -0
  30. soe/docs/guide_03_router.md +146 -0
  31. soe/docs/guide_04_patterns.md +475 -0
  32. soe/docs/guide_05_agent.md +159 -0
  33. soe/docs/guide_06_schema.md +397 -0
  34. soe/docs/guide_07_identity.md +540 -0
  35. soe/docs/guide_08_child.md +612 -0
  36. soe/docs/guide_09_ecosystem.md +690 -0
  37. soe/docs/guide_10_infrastructure.md +427 -0
  38. soe/docs/guide_11_builtins.md +118 -0
  39. soe/docs/index.md +104 -0
  40. soe/docs/primitives/backends.md +281 -0
  41. soe/docs/primitives/context.md +256 -0
  42. soe/docs/primitives/node_reference.md +259 -0
  43. soe/docs/primitives/primitives.md +331 -0
  44. soe/docs/primitives/signals.md +865 -0
  45. soe/docs_index.py +1 -1
  46. soe/lib/__init__.py +0 -0
  47. soe/lib/child_context.py +46 -0
  48. soe/lib/context_fields.py +51 -0
  49. soe/lib/inheritance.py +172 -0
  50. soe/lib/jinja_render.py +113 -0
  51. soe/lib/operational.py +51 -0
  52. soe/lib/parent_sync.py +71 -0
  53. soe/lib/register_event.py +75 -0
  54. soe/lib/schema_validation.py +134 -0
  55. soe/lib/yaml_parser.py +14 -0
  56. soe/local_backends/__init__.py +18 -0
  57. soe/local_backends/factory.py +124 -0
  58. soe/local_backends/in_memory/context.py +38 -0
  59. soe/local_backends/in_memory/conversation_history.py +60 -0
  60. soe/local_backends/in_memory/identity.py +52 -0
  61. soe/local_backends/in_memory/schema.py +40 -0
  62. soe/local_backends/in_memory/telemetry.py +38 -0
  63. soe/local_backends/in_memory/workflow.py +33 -0
  64. soe/local_backends/storage/context.py +57 -0
  65. soe/local_backends/storage/conversation_history.py +82 -0
  66. soe/local_backends/storage/identity.py +118 -0
  67. soe/local_backends/storage/schema.py +96 -0
  68. soe/local_backends/storage/telemetry.py +72 -0
  69. soe/local_backends/storage/workflow.py +56 -0
  70. soe/nodes/__init__.py +13 -0
  71. soe/nodes/agent/__init__.py +10 -0
  72. soe/nodes/agent/factory.py +134 -0
  73. soe/nodes/agent/lib/loop_handlers.py +150 -0
  74. soe/nodes/agent/lib/loop_state.py +157 -0
  75. soe/nodes/agent/lib/prompts.py +65 -0
  76. soe/nodes/agent/lib/tools.py +35 -0
  77. soe/nodes/agent/stages/__init__.py +12 -0
  78. soe/nodes/agent/stages/parameter.py +37 -0
  79. soe/nodes/agent/stages/response.py +54 -0
  80. soe/nodes/agent/stages/router.py +37 -0
  81. soe/nodes/agent/state.py +111 -0
  82. soe/nodes/agent/types.py +66 -0
  83. soe/nodes/agent/validation/__init__.py +11 -0
  84. soe/nodes/agent/validation/config.py +95 -0
  85. soe/nodes/agent/validation/operational.py +24 -0
  86. soe/nodes/child/__init__.py +3 -0
  87. soe/nodes/child/factory.py +61 -0
  88. soe/nodes/child/state.py +59 -0
  89. soe/nodes/child/validation/__init__.py +11 -0
  90. soe/nodes/child/validation/config.py +126 -0
  91. soe/nodes/child/validation/operational.py +28 -0
  92. soe/nodes/lib/conditions.py +71 -0
  93. soe/nodes/lib/context.py +24 -0
  94. soe/nodes/lib/conversation_history.py +77 -0
  95. soe/nodes/lib/identity.py +64 -0
  96. soe/nodes/lib/llm_resolver.py +142 -0
  97. soe/nodes/lib/output.py +68 -0
  98. soe/nodes/lib/response_builder.py +91 -0
  99. soe/nodes/lib/signal_emission.py +79 -0
  100. soe/nodes/lib/signals.py +54 -0
  101. soe/nodes/lib/tools.py +100 -0
  102. soe/nodes/llm/__init__.py +7 -0
  103. soe/nodes/llm/factory.py +103 -0
  104. soe/nodes/llm/state.py +76 -0
  105. soe/nodes/llm/types.py +12 -0
  106. soe/nodes/llm/validation/__init__.py +11 -0
  107. soe/nodes/llm/validation/config.py +89 -0
  108. soe/nodes/llm/validation/operational.py +23 -0
  109. soe/nodes/router/__init__.py +3 -0
  110. soe/nodes/router/factory.py +37 -0
  111. soe/nodes/router/state.py +32 -0
  112. soe/nodes/router/validation/__init__.py +11 -0
  113. soe/nodes/router/validation/config.py +58 -0
  114. soe/nodes/router/validation/operational.py +16 -0
  115. soe/nodes/tool/factory.py +66 -0
  116. soe/nodes/tool/lib/__init__.py +11 -0
  117. soe/nodes/tool/lib/conditions.py +35 -0
  118. soe/nodes/tool/lib/failure.py +28 -0
  119. soe/nodes/tool/lib/parameters.py +67 -0
  120. soe/nodes/tool/state.py +66 -0
  121. soe/nodes/tool/types.py +27 -0
  122. soe/nodes/tool/validation/__init__.py +15 -0
  123. soe/nodes/tool/validation/config.py +132 -0
  124. soe/nodes/tool/validation/operational.py +16 -0
  125. soe/validation/__init__.py +18 -0
  126. soe/validation/config.py +195 -0
  127. soe/validation/jinja.py +54 -0
  128. soe/validation/operational.py +110 -0
  129. {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/METADATA +5 -5
  130. soe_ai-0.1.3.dist-info/RECORD +137 -0
  131. {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/WHEEL +1 -1
  132. soe_ai-0.1.1.dist-info/RECORD +0 -10
  133. {soe_ai-0.1.1.dist-info → soe_ai-0.1.3.dist-info}/licenses/LICENSE +0 -0
  134. {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
+ )
@@ -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
+ )
@@ -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
+ ]
@@ -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
+ )
@@ -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
+ )