soe-ai 0.1.1__py3-none-any.whl → 0.1.2__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.2.dist-info}/METADATA +4 -4
  130. soe_ai-0.1.2.dist-info/RECORD +137 -0
  131. {soe_ai-0.1.1.dist-info → soe_ai-0.1.2.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.2.dist-info}/licenses/LICENSE +0 -0
  134. {soe_ai-0.1.1.dist-info → soe_ai-0.1.2.dist-info}/top_level.txt +0 -0
soe/lib/__init__.py ADDED
File without changes
@@ -0,0 +1,46 @@
1
+ """
2
+ Child context preparation utilities.
3
+
4
+ Prepares initial context for child workflows with parent metadata.
5
+ """
6
+
7
+ import copy
8
+ from typing import Any, Dict
9
+
10
+ from .context_fields import get_field
11
+
12
+
13
+ PARENT_INFO_KEY = "__parent__"
14
+
15
+
16
+ def prepare_child_context(
17
+ parent_context: Dict[str, Any],
18
+ node_config: Dict[str, Any],
19
+ parent_execution_id: str,
20
+ main_execution_id: str,
21
+ ) -> Dict[str, Any]:
22
+ """
23
+ Prepare initial context for child workflow.
24
+
25
+ Includes:
26
+ - Input fields copied from parent context (current value, not full history)
27
+ - __parent__ metadata for communication back to parent
28
+ """
29
+ child_context: Dict[str, Any] = {}
30
+
31
+ # Copy specified input fields from parent (current value only)
32
+ # orchestrate() will wrap these in history lists
33
+ for field_name in node_config.get("input_fields", []):
34
+ if field_name in parent_context:
35
+ # Use get_field to get current value, not full history
36
+ child_context[field_name] = copy.deepcopy(get_field(parent_context, field_name))
37
+
38
+ # Inject parent info for child-to-parent communication
39
+ child_context[PARENT_INFO_KEY] = {
40
+ "parent_execution_id": parent_execution_id,
41
+ "signals_to_parent": node_config.get("signals_to_parent", []),
42
+ "context_updates_to_parent": node_config.get("context_updates_to_parent", []),
43
+ "main_execution_id": main_execution_id,
44
+ }
45
+
46
+ return child_context
@@ -0,0 +1,51 @@
1
+ """
2
+ Context field utilities for history-aware field storage.
3
+
4
+ Fields are stored as lists to maintain update history.
5
+ Reading always returns the last (most recent) value.
6
+ """
7
+
8
+ from typing import Any, Dict, List
9
+
10
+
11
+ def set_field(context: Dict[str, Any], field: str, value: Any) -> None:
12
+ """Set a context field, appending to history list."""
13
+ if field.startswith("__"):
14
+ context[field] = value
15
+ return
16
+
17
+ if field not in context:
18
+ context[field] = [value]
19
+ else:
20
+ context[field].append(value)
21
+
22
+
23
+ def get_field(context: Dict[str, Any], field: str) -> Any:
24
+ """Get a context field value (last item in history list)."""
25
+ if field.startswith("__"):
26
+ return context.get(field)
27
+
28
+ value = context.get(field)
29
+ if value is None:
30
+ return None
31
+
32
+ return value[-1]
33
+
34
+
35
+ def get_accumulated(context: Dict[str, Any], field: str) -> List[Any]:
36
+ """
37
+ Get full accumulated history for a field.
38
+
39
+ If history has exactly one entry and it's a list, returns that list
40
+ (common case: initial context passed a list as value for fan-out).
41
+ """
42
+ if field not in context:
43
+ return []
44
+
45
+ history = context[field]
46
+
47
+ # If history has exactly one entry and it's a list, return that list
48
+ if len(history) == 1 and isinstance(history[0], list):
49
+ return list(history[0])
50
+
51
+ return list(history)
soe/lib/inheritance.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ Configuration and context inheritance utilities.
3
+
4
+ Handles:
5
+ - Extracting and saving config sections (workflows, identity, schema) from parsed config
6
+ - Inheriting config from existing executions
7
+ - Inheriting context from existing executions
8
+
9
+ Used for workflow initialization and chaining.
10
+ """
11
+
12
+ import copy
13
+ from typing import Dict, Any, Optional
14
+
15
+ from ..types import Backends
16
+
17
+
18
+ def save_config_sections(
19
+ execution_id: str,
20
+ backends: Backends,
21
+ identities: Optional[Dict[str, str]] = None,
22
+ context_schema: Optional[Dict[str, Any]] = None,
23
+ ) -> None:
24
+ """
25
+ Save identities and context_schema to their respective backends.
26
+
27
+ This is the shared logic for both:
28
+ - Extracting sections from new config
29
+ - Inheriting sections from existing execution
30
+
31
+ Args:
32
+ execution_id: Target execution ID
33
+ backends: Backend services
34
+ identities: Identity definitions to save (optional)
35
+ context_schema: Context schema to save (optional)
36
+ """
37
+ if identities and backends.identity:
38
+ backends.identity.save_identities(execution_id, identities)
39
+
40
+ if context_schema and backends.context_schema:
41
+ backends.context_schema.save_context_schema(execution_id, context_schema)
42
+
43
+
44
+ def extract_and_save_config_sections(
45
+ parsed_config: Dict[str, Any],
46
+ execution_id: str,
47
+ backends: Backends,
48
+ ) -> Dict[str, Any]:
49
+ """
50
+ Extract workflows, context_schema, and identities from config.
51
+
52
+ If config has 'workflows' key, it's the combined structure:
53
+ - Extract and save context_schema to context_schema backend
54
+ - Extract and save identities to identity backend
55
+ - Return just the workflows portion
56
+
57
+ Args:
58
+ parsed_config: Parsed config dictionary
59
+ execution_id: Execution ID to save sections under
60
+ backends: Backend services
61
+
62
+ Returns:
63
+ The workflows registry portion of the config
64
+ """
65
+ if "workflows" in parsed_config:
66
+ workflows = parsed_config["workflows"]
67
+
68
+ save_config_sections(
69
+ execution_id=execution_id,
70
+ backends=backends,
71
+ identities=parsed_config.get("identities"),
72
+ context_schema=parsed_config.get("context_schema"),
73
+ )
74
+
75
+ return workflows
76
+
77
+ # Simple/Legacy structure - entire config is workflows
78
+ return parsed_config
79
+
80
+
81
+ def inherit_config(
82
+ source_execution_id: str,
83
+ target_execution_id: str,
84
+ backends: Backends,
85
+ ) -> Dict[str, Any]:
86
+ """
87
+ Inherit configuration from source execution to target execution.
88
+
89
+ Copies:
90
+ - Workflows registry
91
+ - Identities (if available)
92
+ - Context schema (if available)
93
+
94
+ Args:
95
+ source_execution_id: Execution ID to inherit from
96
+ target_execution_id: Execution ID to inherit to
97
+ backends: Backend services
98
+
99
+ Returns:
100
+ The workflows registry (for validation and use)
101
+
102
+ Raises:
103
+ ValueError: If source execution has no workflows registry
104
+ """
105
+ workflows_registry = backends.workflow.get_workflows_registry(source_execution_id)
106
+ if not workflows_registry:
107
+ raise ValueError(
108
+ f"Cannot inherit config from execution '{source_execution_id}': "
109
+ "no workflows registry found"
110
+ )
111
+
112
+ # Copy workflows to new execution
113
+ backends.workflow.save_workflows_registry(target_execution_id, workflows_registry)
114
+
115
+ # Get source identities and schema
116
+ source_identities = None
117
+ source_schema = None
118
+
119
+ if backends.identity:
120
+ source_identities = backends.identity.get_identities(source_execution_id)
121
+
122
+ if backends.context_schema:
123
+ source_schema = backends.context_schema.get_context_schema(source_execution_id)
124
+
125
+ # Save to target using shared logic
126
+ save_config_sections(
127
+ execution_id=target_execution_id,
128
+ backends=backends,
129
+ identities=source_identities,
130
+ context_schema=source_schema,
131
+ )
132
+
133
+ return workflows_registry
134
+
135
+
136
+ def inherit_context(
137
+ source_execution_id: str,
138
+ backends: Backends,
139
+ ) -> Dict[str, Any]:
140
+ """
141
+ Inherit context from source execution, resetting operational state.
142
+
143
+ Copies all context fields except __operational__, which is reset
144
+ for the new execution.
145
+
146
+ Args:
147
+ source_execution_id: Execution ID to inherit context from
148
+ backends: Backend services
149
+
150
+ Returns:
151
+ Context dictionary ready for new execution (without __operational__)
152
+
153
+ Raises:
154
+ ValueError: If source execution has no context
155
+ """
156
+ source_context = backends.context.get_context(source_execution_id)
157
+ if not source_context:
158
+ raise ValueError(
159
+ f"Cannot inherit context from execution '{source_execution_id}': "
160
+ "no context found"
161
+ )
162
+
163
+ # Deep copy context, excluding internal fields (will be reset for new execution)
164
+ # __operational__ - execution tracking state
165
+ # __parent__ - parent workflow metadata (not relevant for new execution)
166
+ inherited_context = {
167
+ k: copy.deepcopy(v)
168
+ for k, v in source_context.items()
169
+ if k not in ("__operational__", "__parent__")
170
+ }
171
+
172
+ return inherited_context
@@ -0,0 +1,113 @@
1
+ """
2
+ Jinja template rendering utilities for prompt processing.
3
+ """
4
+
5
+ import re
6
+ from typing import Dict, Any, Set, List, Tuple
7
+
8
+ from jinja2 import Environment, BaseLoader, TemplateSyntaxError
9
+
10
+ from .context_fields import get_field
11
+
12
+
13
+ def _create_accumulated_filter(full_context: Dict[str, Any]):
14
+ """Create an accumulated filter that returns full history for a field."""
15
+ def accumulated_filter(value):
16
+ """
17
+ Return the full accumulated history list for a context field.
18
+
19
+ Usage in templates:
20
+ {{ context.field | accumulated }} - returns full list
21
+ {{ context.field | accumulated | length }} - count of items
22
+ {{ context.field | accumulated | join(', ') }} - join all items
23
+
24
+ If history has exactly one entry and it's a list, returns that list
25
+ (common case: initial context passed a list as value).
26
+ """
27
+ # Find the field in full_context by matching the last value
28
+ for key, hist_list in full_context.items():
29
+ if key.startswith("__"):
30
+ continue
31
+ if isinstance(hist_list, list) and hist_list and hist_list[-1] == value:
32
+ # If history has exactly one entry and it's a list, return that list
33
+ if len(hist_list) == 1 and isinstance(hist_list[0], list):
34
+ return hist_list[0]
35
+ return hist_list
36
+ # Fallback: return value as single-item list
37
+ return [value] if value is not None else []
38
+
39
+ return accumulated_filter
40
+
41
+
42
+ def _extract_context_variables(template: str) -> Set[str]:
43
+ """Extract variable names from a Jinja template."""
44
+ if not template:
45
+ return set()
46
+
47
+ variables = set()
48
+
49
+ dot_pattern = r'\{\{[^}]*context\.([a-zA-Z_][a-zA-Z0-9_]*)'
50
+ for match in re.finditer(dot_pattern, template):
51
+ variables.add(match.group(1))
52
+
53
+ bracket_pattern = r"\{\{[^}]*context\[['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]"
54
+ for match in re.finditer(bracket_pattern, template):
55
+ variables.add(match.group(1))
56
+
57
+ return variables
58
+
59
+
60
+ def get_context_for_prompt(
61
+ full_context: Dict[str, Any],
62
+ template: str
63
+ ) -> Tuple[Dict[str, Any], List[str]]:
64
+ """Extract the context needed for a prompt template."""
65
+ required_fields = _extract_context_variables(template)
66
+ filtered_context = {}
67
+ warnings = []
68
+
69
+ for field in required_fields:
70
+ if field not in full_context:
71
+ warnings.append(f"Context field '{field}' referenced in prompt but not found in context")
72
+ else:
73
+ value = get_field(full_context, field)
74
+ if value is None:
75
+ warnings.append(f"Context field '{field}' is None")
76
+ filtered_context[field] = None
77
+ elif value == "":
78
+ warnings.append(f"Context field '{field}' is empty string")
79
+ filtered_context[field] = ""
80
+ else:
81
+ filtered_context[field] = value
82
+
83
+ return filtered_context, warnings
84
+
85
+
86
+ def render_prompt(prompt: str, context: Dict[str, Any]) -> Tuple[str, List[str]]:
87
+ """Render a Jinja template prompt with the given context."""
88
+ if not prompt:
89
+ return prompt, []
90
+
91
+ if "{{" not in prompt and "{%" not in prompt:
92
+ return prompt, []
93
+
94
+ _, warnings = get_context_for_prompt(context, prompt)
95
+
96
+ unwrapped = {k: get_field(context, k) for k in context if not k.startswith("__")}
97
+ for k, v in context.items():
98
+ if k.startswith("__"):
99
+ unwrapped[k] = v
100
+
101
+ try:
102
+ jinja_env = Environment(loader=BaseLoader())
103
+ # Register custom filters
104
+ jinja_env.filters["accumulated"] = _create_accumulated_filter(context)
105
+ template = jinja_env.from_string(prompt)
106
+ rendered = template.render(context=unwrapped)
107
+ return rendered, warnings
108
+ except TemplateSyntaxError as e:
109
+ warnings.append(f"Jinja syntax error: {e}")
110
+ return prompt, warnings
111
+ except Exception as e:
112
+ warnings.append(f"Template rendering error: {e}")
113
+ return prompt, warnings
soe/lib/operational.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ Operational context initialization.
3
+
4
+ This module handles initialization of the operational state structure.
5
+ Runtime updates are handled by register_event.py.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+
10
+ PARENT_INFO_KEY = "__parent__"
11
+
12
+
13
+ def wrap_context_fields(context: Dict[str, Any]) -> Dict[str, Any]:
14
+ """Wrap context field values in lists for history tracking.
15
+
16
+ Internal fields (starting with __) are not wrapped.
17
+ If context has __parent__, fields are already wrapped (from parent workflow).
18
+ """
19
+ # Child workflows receive pre-wrapped fields from parent
20
+ if PARENT_INFO_KEY in context:
21
+ return context
22
+
23
+ return {
24
+ k: [v] if not k.startswith("__") else v
25
+ for k, v in context.items()
26
+ }
27
+
28
+
29
+ def add_operational_state(
30
+ execution_id: str,
31
+ context: Dict[str, Any],
32
+ ) -> Dict[str, Any]:
33
+ """Add operational state to context if not present. Returns new context dict."""
34
+ if "__operational__" in context:
35
+ return context
36
+
37
+ parent_info = context.get(PARENT_INFO_KEY, {})
38
+ inherited_main_id = parent_info.get("main_execution_id")
39
+ main_id = inherited_main_id if inherited_main_id else execution_id
40
+
41
+ return {
42
+ **context,
43
+ "__operational__": {
44
+ "signals": [],
45
+ "nodes": {},
46
+ "llm_calls": 0,
47
+ "tool_calls": 0,
48
+ "errors": 0,
49
+ "main_execution_id": main_id
50
+ }
51
+ }
soe/lib/parent_sync.py ADDED
@@ -0,0 +1,71 @@
1
+ """
2
+ Parent sync utilities for sub-orchestration.
3
+
4
+ These functions check if a signal or context update should be propagated
5
+ to the parent workflow based on the injected __parent__ metadata.
6
+ """
7
+
8
+ from typing import Dict, Any, Tuple, Optional, List
9
+ from ..types import Backends
10
+
11
+ PARENT_INFO_KEY = "__parent__"
12
+
13
+
14
+ def get_signals_for_parent(
15
+ signals: List[str], context: Dict[str, Any]
16
+ ) -> Tuple[Optional[str], List[str]]:
17
+ """Get the subset of signals that should be propagated to the parent."""
18
+ parent_info = context.get(PARENT_INFO_KEY)
19
+ if not parent_info:
20
+ return (None, [])
21
+
22
+ parent_execution_id = parent_info.get("parent_execution_id")
23
+ signals_to_parent = set(parent_info.get("signals_to_parent", []))
24
+ matching_signals = [s for s in signals if s in signals_to_parent]
25
+
26
+ return (parent_execution_id, matching_signals)
27
+
28
+
29
+ def _check_parent_context_sync(
30
+ key: str, context: Dict[str, Any]
31
+ ) -> Tuple[Optional[str], bool]:
32
+ """Determine if a context update should be propagated to the parent."""
33
+ parent_info = context.get(PARENT_INFO_KEY)
34
+ if not parent_info:
35
+ return (None, False)
36
+
37
+ parent_execution_id = parent_info.get("parent_execution_id")
38
+ context_updates_to_parent = parent_info.get("context_updates_to_parent", [])
39
+
40
+ if key in context_updates_to_parent:
41
+ return (parent_execution_id, True)
42
+
43
+ return (parent_execution_id, False)
44
+
45
+
46
+ def sync_context_to_parent(
47
+ context: Dict[str, Any],
48
+ updated_keys: List[str],
49
+ backends: Backends,
50
+ ) -> None:
51
+ """Sync updated context keys to the parent workflow if configured.
52
+
53
+ For each key in updated_keys that is configured for parent sync:
54
+ - If parent doesn't have the key, copy the full list from child
55
+ - If parent already has the key, extend with new items from child
56
+ """
57
+ for key in updated_keys:
58
+ parent_id, should_sync = _check_parent_context_sync(key, context)
59
+ if should_sync and parent_id:
60
+ parent_context = backends.context.get_context(parent_id)
61
+ child_history = context[key]
62
+
63
+ if key in parent_context:
64
+ # Append new items from child to parent's existing list
65
+ parent_context[key].extend(child_history)
66
+ else:
67
+ # Initialize parent with child's history
68
+ parent_context[key] = child_history
69
+
70
+ backends.context.save_context(parent_id, parent_context)
71
+ sync_context_to_parent(parent_context, [key], backends)
@@ -0,0 +1,75 @@
1
+ """
2
+ Unified event registration for telemetry and operational state.
3
+
4
+ Records events to telemetry backend and updates operational context state
5
+ (signals, nodes, llm_calls, tool_calls, errors).
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Dict, Any, Optional
10
+ from ..types import Backends, EventTypes
11
+
12
+
13
+ def register_event(
14
+ backends: Backends,
15
+ execution_id: str,
16
+ event_type: str,
17
+ data: Optional[Dict[str, Any]] = None
18
+ ) -> None:
19
+ """
20
+ Log telemetry and update operational state based on event type.
21
+
22
+ Args:
23
+ backends: Backend services
24
+ execution_id: The execution ID
25
+ event_type: Event type from EventTypes enum
26
+ data: Event-specific data
27
+ """
28
+ data = data or {}
29
+
30
+ # Log to telemetry if available
31
+ if backends.telemetry is not None:
32
+ backends.telemetry.log_event(
33
+ execution_id,
34
+ event_type,
35
+ timestamp=datetime.utcnow().isoformat() + "Z",
36
+ context=data
37
+ )
38
+
39
+ # Update operational state based on event type
40
+
41
+ if event_type == EventTypes.SIGNALS_BROADCAST:
42
+ context = backends.context.get_context(execution_id)
43
+ operational = context["__operational__"]
44
+ signals = data.get("signals", [])
45
+ operational["signals"].extend(signals)
46
+ backends.context.save_context(execution_id, context)
47
+
48
+ elif event_type == EventTypes.NODE_EXECUTION:
49
+ node_name = data.get("node_name")
50
+ if node_name:
51
+ context = backends.context.get_context(execution_id)
52
+ operational = context["__operational__"]
53
+ nodes = operational["nodes"]
54
+ if node_name not in nodes:
55
+ nodes[node_name] = 0
56
+ nodes[node_name] += 1
57
+ backends.context.save_context(execution_id, context)
58
+
59
+ elif event_type == EventTypes.LLM_CALL:
60
+ context = backends.context.get_context(execution_id)
61
+ operational = context["__operational__"]
62
+ operational["llm_calls"] += 1
63
+ backends.context.save_context(execution_id, context)
64
+
65
+ elif event_type == EventTypes.NODE_ERROR:
66
+ context = backends.context.get_context(execution_id)
67
+ operational = context["__operational__"]
68
+ operational["errors"] += 1
69
+ backends.context.save_context(execution_id, context)
70
+
71
+ elif event_type in (EventTypes.TOOL_CALL, EventTypes.AGENT_TOOL_CALL):
72
+ context = backends.context.get_context(execution_id)
73
+ operational = context["__operational__"]
74
+ operational["tool_calls"] += 1
75
+ backends.context.save_context(execution_id, context)