soe-ai 0.2.0b1__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 (145) hide show
  1. soe/__init__.py +50 -0
  2. soe/broker.py +168 -0
  3. soe/builtin_tools/__init__.py +51 -0
  4. soe/builtin_tools/soe_add_signal.py +82 -0
  5. soe/builtin_tools/soe_call_tool.py +111 -0
  6. soe/builtin_tools/soe_copy_context.py +80 -0
  7. soe/builtin_tools/soe_explore_docs.py +290 -0
  8. soe/builtin_tools/soe_get_available_tools.py +42 -0
  9. soe/builtin_tools/soe_get_context.py +50 -0
  10. soe/builtin_tools/soe_get_context_schema.py +56 -0
  11. soe/builtin_tools/soe_get_identities.py +63 -0
  12. soe/builtin_tools/soe_get_workflows.py +63 -0
  13. soe/builtin_tools/soe_inject_context_schema_field.py +80 -0
  14. soe/builtin_tools/soe_inject_identity.py +64 -0
  15. soe/builtin_tools/soe_inject_node.py +86 -0
  16. soe/builtin_tools/soe_inject_workflow.py +105 -0
  17. soe/builtin_tools/soe_list_contexts.py +73 -0
  18. soe/builtin_tools/soe_remove_context_schema_field.py +61 -0
  19. soe/builtin_tools/soe_remove_identity.py +61 -0
  20. soe/builtin_tools/soe_remove_node.py +72 -0
  21. soe/builtin_tools/soe_remove_workflow.py +62 -0
  22. soe/builtin_tools/soe_update_context.py +54 -0
  23. soe/docs/_config.yml +10 -0
  24. soe/docs/advanced_patterns/guide_fanout_and_aggregations.md +318 -0
  25. soe/docs/advanced_patterns/guide_inheritance.md +435 -0
  26. soe/docs/advanced_patterns/hybrid_intelligence.md +237 -0
  27. soe/docs/advanced_patterns/index.md +49 -0
  28. soe/docs/advanced_patterns/operational.md +781 -0
  29. soe/docs/advanced_patterns/self_evolving_workflows.md +385 -0
  30. soe/docs/advanced_patterns/swarm_intelligence.md +211 -0
  31. soe/docs/builtins/context.md +164 -0
  32. soe/docs/builtins/context_schema.md +158 -0
  33. soe/docs/builtins/identity.md +139 -0
  34. soe/docs/builtins/soe_explore_docs.md +135 -0
  35. soe/docs/builtins/tools.md +164 -0
  36. soe/docs/builtins/workflows.md +199 -0
  37. soe/docs/guide_00_getting_started.md +341 -0
  38. soe/docs/guide_01_tool.md +206 -0
  39. soe/docs/guide_02_llm.md +143 -0
  40. soe/docs/guide_03_router.md +146 -0
  41. soe/docs/guide_04_patterns.md +475 -0
  42. soe/docs/guide_05_agent.md +159 -0
  43. soe/docs/guide_06_schema.md +397 -0
  44. soe/docs/guide_07_identity.md +540 -0
  45. soe/docs/guide_08_child.md +612 -0
  46. soe/docs/guide_09_ecosystem.md +690 -0
  47. soe/docs/guide_10_infrastructure.md +427 -0
  48. soe/docs/guide_11_builtins.md +126 -0
  49. soe/docs/index.md +104 -0
  50. soe/docs/primitives/backends.md +281 -0
  51. soe/docs/primitives/context.md +256 -0
  52. soe/docs/primitives/node_reference.md +259 -0
  53. soe/docs/primitives/primitives.md +331 -0
  54. soe/docs/primitives/signals.md +865 -0
  55. soe/docs_index.py +2 -0
  56. soe/init.py +165 -0
  57. soe/lib/__init__.py +0 -0
  58. soe/lib/child_context.py +46 -0
  59. soe/lib/context_fields.py +51 -0
  60. soe/lib/inheritance.py +172 -0
  61. soe/lib/jinja_render.py +113 -0
  62. soe/lib/operational.py +51 -0
  63. soe/lib/parent_sync.py +71 -0
  64. soe/lib/register_event.py +75 -0
  65. soe/lib/schema_validation.py +134 -0
  66. soe/lib/yaml_parser.py +14 -0
  67. soe/local_backends/__init__.py +18 -0
  68. soe/local_backends/factory.py +124 -0
  69. soe/local_backends/in_memory/context.py +38 -0
  70. soe/local_backends/in_memory/conversation_history.py +60 -0
  71. soe/local_backends/in_memory/identity.py +52 -0
  72. soe/local_backends/in_memory/schema.py +40 -0
  73. soe/local_backends/in_memory/telemetry.py +38 -0
  74. soe/local_backends/in_memory/workflow.py +33 -0
  75. soe/local_backends/storage/context.py +57 -0
  76. soe/local_backends/storage/conversation_history.py +82 -0
  77. soe/local_backends/storage/identity.py +118 -0
  78. soe/local_backends/storage/schema.py +96 -0
  79. soe/local_backends/storage/telemetry.py +72 -0
  80. soe/local_backends/storage/workflow.py +56 -0
  81. soe/nodes/__init__.py +13 -0
  82. soe/nodes/agent/__init__.py +10 -0
  83. soe/nodes/agent/factory.py +134 -0
  84. soe/nodes/agent/lib/loop_handlers.py +150 -0
  85. soe/nodes/agent/lib/loop_state.py +157 -0
  86. soe/nodes/agent/lib/prompts.py +65 -0
  87. soe/nodes/agent/lib/tools.py +35 -0
  88. soe/nodes/agent/stages/__init__.py +12 -0
  89. soe/nodes/agent/stages/parameter.py +37 -0
  90. soe/nodes/agent/stages/response.py +54 -0
  91. soe/nodes/agent/stages/router.py +37 -0
  92. soe/nodes/agent/state.py +111 -0
  93. soe/nodes/agent/types.py +66 -0
  94. soe/nodes/agent/validation/__init__.py +11 -0
  95. soe/nodes/agent/validation/config.py +95 -0
  96. soe/nodes/agent/validation/operational.py +24 -0
  97. soe/nodes/child/__init__.py +3 -0
  98. soe/nodes/child/factory.py +61 -0
  99. soe/nodes/child/state.py +59 -0
  100. soe/nodes/child/validation/__init__.py +11 -0
  101. soe/nodes/child/validation/config.py +126 -0
  102. soe/nodes/child/validation/operational.py +28 -0
  103. soe/nodes/lib/conditions.py +71 -0
  104. soe/nodes/lib/context.py +24 -0
  105. soe/nodes/lib/conversation_history.py +77 -0
  106. soe/nodes/lib/identity.py +64 -0
  107. soe/nodes/lib/llm_resolver.py +142 -0
  108. soe/nodes/lib/output.py +68 -0
  109. soe/nodes/lib/response_builder.py +91 -0
  110. soe/nodes/lib/signal_emission.py +79 -0
  111. soe/nodes/lib/signals.py +54 -0
  112. soe/nodes/lib/tools.py +100 -0
  113. soe/nodes/llm/__init__.py +7 -0
  114. soe/nodes/llm/factory.py +103 -0
  115. soe/nodes/llm/state.py +76 -0
  116. soe/nodes/llm/types.py +12 -0
  117. soe/nodes/llm/validation/__init__.py +11 -0
  118. soe/nodes/llm/validation/config.py +89 -0
  119. soe/nodes/llm/validation/operational.py +23 -0
  120. soe/nodes/router/__init__.py +3 -0
  121. soe/nodes/router/factory.py +37 -0
  122. soe/nodes/router/state.py +32 -0
  123. soe/nodes/router/validation/__init__.py +11 -0
  124. soe/nodes/router/validation/config.py +58 -0
  125. soe/nodes/router/validation/operational.py +16 -0
  126. soe/nodes/tool/factory.py +66 -0
  127. soe/nodes/tool/lib/__init__.py +11 -0
  128. soe/nodes/tool/lib/conditions.py +35 -0
  129. soe/nodes/tool/lib/failure.py +28 -0
  130. soe/nodes/tool/lib/parameters.py +67 -0
  131. soe/nodes/tool/state.py +66 -0
  132. soe/nodes/tool/types.py +27 -0
  133. soe/nodes/tool/validation/__init__.py +15 -0
  134. soe/nodes/tool/validation/config.py +132 -0
  135. soe/nodes/tool/validation/operational.py +16 -0
  136. soe/types.py +209 -0
  137. soe/validation/__init__.py +18 -0
  138. soe/validation/config.py +195 -0
  139. soe/validation/jinja.py +54 -0
  140. soe/validation/operational.py +110 -0
  141. soe_ai-0.2.0b1.dist-info/METADATA +262 -0
  142. soe_ai-0.2.0b1.dist-info/RECORD +145 -0
  143. soe_ai-0.2.0b1.dist-info/WHEEL +5 -0
  144. soe_ai-0.2.0b1.dist-info/licenses/LICENSE +21 -0
  145. soe_ai-0.2.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,206 @@
1
+
2
+ # SOE Guide: Chapter 1 - Tool Nodes
3
+
4
+ ## Introduction to Tool Nodes
5
+
6
+ The **Tool Node** executes a Python function directly. It's the most concrete node type in SOE—it does real work: calling APIs, processing data, interacting with databases.
7
+
8
+ We start with Tool nodes because they represent **actual execution**. While other nodes route signals or generate text, Tool nodes are where your workflow touches the real world.
9
+
10
+ ### When to Use Tool Nodes
11
+
12
+ - **External Operations**: Sending emails, processing payments, database queries
13
+ - **API Calls**: HTTP requests, third-party service integrations
14
+ - **Data Transformations**: Calculations, file processing, format conversions
15
+ - **Deterministic Logic**: When you need code, not AI
16
+
17
+ ## Your First Tool Node
18
+
19
+ Let's send an email using a tool node.
20
+
21
+ ### The Workflow
22
+
23
+ ```yaml
24
+ example_workflow:
25
+ SendEmail:
26
+ node_type: tool
27
+ event_triggers: [START]
28
+ tool_name: send_email
29
+ context_parameter_field: email_data
30
+ output_field: email_result
31
+ event_emissions:
32
+ - signal_name: EMAIL_SENT
33
+ ```
34
+
35
+ ### How It Works
36
+
37
+ 1. **`tool_name`**: The key in your `tools_registry` dict.
38
+ 2. **`context_parameter_field`**: The context field containing the tool's kwargs (a dict).
39
+ 3. **`output_field`**: Where to store the result in context.
40
+ 4. **`event_emissions`**: Signals to emit after execution (conditions evaluate `result`).
41
+
42
+ ### Understanding context_parameter_field
43
+
44
+ The `context_parameter_field` specifies which context field contains the parameters to pass to your tool. This field must contain a dictionary that will be unpacked as keyword arguments.
45
+
46
+ **Where does this data come from?**
47
+
48
+ 1. **Initial context**: You can pass it when starting the workflow:
49
+ ```python
50
+ orchestrate(
51
+ config=workflow,
52
+ initial_context={"email_data": {"to": "user@example.com", "subject": "Hello"}}
53
+ )
54
+ ```
55
+
56
+ 2. **LLM output**: An LLM node can generate structured parameters:
57
+ ```yaml
58
+ ExtractEmailParams:
59
+ node_type: llm
60
+ prompt: "Extract email parameters from: {{ context.user_request }}"
61
+ output_field: email_data # LLM returns {"to": "...", "subject": "...", "body": "..."}
62
+
63
+ SendEmail:
64
+ node_type: tool
65
+ tool_name: send_email
66
+ context_parameter_field: email_data # Uses LLM output as tool input
67
+ ```
68
+
69
+ 3. **Another tool's output**: Chain tools together:
70
+ ```yaml
71
+ PrepareData:
72
+ node_type: tool
73
+ tool_name: prepare_email_data
74
+ output_field: email_data
75
+
76
+ SendEmail:
77
+ node_type: tool
78
+ tool_name: send_email
79
+ context_parameter_field: email_data # Uses previous tool's output
80
+ ```
81
+
82
+ **Tip**: Use [Context Schema](guide_06_schema.md) to validate that LLM output has the correct structure before passing to tools.
83
+
84
+ ### The Tools Registry
85
+
86
+ Tools are registered as plain Python functions:
87
+
88
+ ```python
89
+ def send_email(to: str, subject: str, body: str) -> dict:
90
+ # Your email sending logic
91
+ return {"status": "sent", "to": to}
92
+
93
+ tools_registry = {
94
+ "send_email": send_email,
95
+ }
96
+ ```
97
+
98
+ ## Event Emissions with Conditions
99
+
100
+ Tool nodes use `event_emissions` with optional Jinja conditions. Signals without conditions are always emitted on success. Signals with conditions can reference:
101
+
102
+ - **`result`**: The tool's return value
103
+ - **`context`**: The full workflow context
104
+
105
+ ### Conditional Signal Example
106
+
107
+ ```yaml
108
+ example_workflow:
109
+ ProcessPayment:
110
+ node_type: tool
111
+ event_triggers: [START]
112
+ tool_name: process_payment
113
+ context_parameter_field: payment_data
114
+ output_field: payment_result
115
+ event_emissions:
116
+ - signal_name: PAYMENT_SUCCESS
117
+ condition: "{{ result.status == 'approved' }}"
118
+ - signal_name: PAYMENT_DECLINED
119
+ condition: "{{ result.status == 'declined' }}"
120
+ - signal_name: PAYMENT_PENDING
121
+ condition: "{{ result.status == 'pending' }}"
122
+ ```
123
+
124
+ ### How It Works
125
+
126
+ 1. Tool executes and stores result in `output_field`.
127
+ 2. Each `event_emissions` condition is evaluated against both `result` and `context`.
128
+ 3. Signals with matching conditions (or no condition) are emitted.
129
+
130
+ **Note:** `event_emissions` are only evaluated on successful execution. For failure handling, use `failure_signal` in the registry.
131
+
132
+ ### Combining Result and Context
133
+
134
+ Conditions can use both the tool's output and existing context values:
135
+
136
+ ```yaml
137
+ example_workflow:
138
+ ProcessPayment:
139
+ node_type: tool
140
+ event_triggers: [START]
141
+ tool_name: process_payment
142
+ context_parameter_field: payment_data
143
+ output_field: payment_result
144
+ event_emissions:
145
+ - signal_name: PAYMENT_SUCCESS
146
+ condition: "{{ result.status == 'approved' }}"
147
+ - signal_name: VIP_PAYMENT_SUCCESS
148
+ condition: "{{ result.status == 'approved' and context.customer.is_vip }}"
149
+ - signal_name: LARGE_PAYMENT_SUCCESS
150
+ condition: "{{ result.status == 'approved' and context.payment_data.amount > 1000 }}"
151
+ ```
152
+
153
+ This is useful when you need to:
154
+ - Combine tool output with customer data (VIP handling)
155
+ - Check result against thresholds from context
156
+ - Route based on both tool success and workflow state
157
+
158
+ ## Extended Tool Registry
159
+
160
+ For more control over tool behavior, use the extended format:
161
+
162
+ ```python
163
+ tools_registry = {
164
+ # Simple format (default: max_retries=1)
165
+ "send_email": send_email,
166
+
167
+ # Extended format with configuration
168
+ "process_payment": {
169
+ "function": process_payment,
170
+ "max_retries": 3, # Retry up to 3 times on failure
171
+ "failure_signal": "PAYMENT_FAILED", # Emit when all retries exhausted
172
+ },
173
+ }
174
+ ```
175
+
176
+ **Extended registry fields:**
177
+
178
+ | Field | Type | Default | Description |
179
+ |-------|------|---------|-------------|
180
+ | `function` | Callable | (required) | The Python function to execute |
181
+ | `max_retries` | int | 1 | Number of retries after initial failure |
182
+ | `failure_signal` | str | None | Signal to emit when all retries exhausted |
183
+ | `process_accumulated` | bool | False | Pass full history list (advanced) |
184
+
185
+ > **Note:** The `process_accumulated` option is an advanced feature for aggregation patterns. It allows a tool to receive the entire history of a context field instead of just the last value. See [Fan-Out, Fan-In & Aggregations](advanced_patterns/guide_fanout_and_aggregations.md) for details.
186
+
187
+ ### Success vs Failure Flow
188
+
189
+ ```
190
+ Tool executes
191
+
192
+ ├── Success → evaluate event_emissions → emit matching signals
193
+
194
+ └── Exception → retry up to max_retries
195
+
196
+ └── All retries failed → emit failure_signal (if defined)
197
+ ```
198
+
199
+ **When to use extended format:**
200
+
201
+ - **Flaky operations**: Set `max_retries` higher for network calls or external APIs
202
+ - **Critical failures**: Use `failure_signal` to handle unrecoverable errors
203
+
204
+ ## Next Steps
205
+
206
+ Now that you can execute real operations, let's add intelligence with [LLM Nodes](guide_02_llm.md) →
@@ -0,0 +1,143 @@
1
+
2
+ # SOE Guide: Chapter 2 - LLM Nodes
3
+
4
+ ## Introduction to LLM Nodes
5
+
6
+ The **LLM Node** is a simple way to call a language model directly. Unlike Agent nodes (which we'll cover later), LLM nodes make a single call to the model and store the response.
7
+
8
+ Think of an LLM node as a specialist: it receives context, generates a response, and passes it along.
9
+
10
+ ## Your First LLM Node: Text Summarization
11
+
12
+ Let's start with a common pattern: summarizing text.
13
+
14
+ ### The Workflow
15
+
16
+ ```yaml
17
+ example_workflow:
18
+ SimpleLLM:
19
+ node_type: llm
20
+ event_triggers: [START]
21
+ prompt: "Summarize the following text in one sentence: {{ context.text }}"
22
+ output_field: summary
23
+ event_emissions:
24
+ - signal_name: SUMMARY_COMPLETE
25
+ ```
26
+
27
+ ### How It Works
28
+
29
+ 1. **Event Trigger**: The `START` signal triggers the `SimpleLLM` node.
30
+ 2. **Prompt Rendering**: The Jinja2 template `{{ context.text }}` is replaced with the actual text.
31
+ 3. **LLM Call**: The rendered prompt is sent to your LLM provider.
32
+ 4. **Output Storage**: The response is stored in `context.summary` (the `output_field`).
33
+ 5. **Signal Emission**: `SUMMARY_COMPLETE` is emitted to continue the workflow.
34
+
35
+ ### Key Concepts
36
+
37
+ - **`prompt`**: A Jinja2 template. Use `{{ context.field }}` to inject context values.
38
+ - **`output_field`**: Where the LLM response is stored in context.
39
+ - **`event_emissions`**: Signals to emit after the LLM call completes.
40
+
41
+ ## Chaining LLM Nodes
42
+
43
+ LLM nodes become powerful when chained together. Each node can use the output of the previous one.
44
+
45
+ ### The Workflow
46
+
47
+ ```yaml
48
+ example_workflow:
49
+ Translator:
50
+ node_type: llm
51
+ event_triggers: [START]
52
+ prompt: "Translate the following to Spanish: {{ context.text }}"
53
+ output_field: spanish_text
54
+ event_emissions:
55
+ - signal_name: TRANSLATED
56
+
57
+ Summarizer:
58
+ node_type: llm
59
+ event_triggers: [TRANSLATED]
60
+ prompt: "Summarize this Spanish text: {{ context.spanish_text }}"
61
+ output_field: summary
62
+ event_emissions:
63
+ - signal_name: CHAIN_COMPLETE
64
+ ```
65
+
66
+ ### How It Works
67
+
68
+ 1. `START` triggers `Translator`
69
+ 2. Translator stores Spanish text in `context.spanish_text`
70
+ 3. Translator emits `TRANSLATED`
71
+ 4. `TRANSLATED` triggers `Summarizer`
72
+ 5. Summarizer reads `context.spanish_text` and stores summary
73
+ 6. Summarizer emits `CHAIN_COMPLETE`
74
+
75
+ This pattern is incredibly useful for:
76
+ - Multi-step processing pipelines
77
+ - Translation → Analysis workflows
78
+ - Extract → Transform → Load patterns
79
+
80
+ ## LLM Signal Selection (Resolution Step)
81
+
82
+ When an LLM node has multiple signals with **conditions** (plain text, not Jinja), the LLM itself decides which signal to emit.
83
+
84
+ ### The Workflow
85
+
86
+ ```yaml
87
+ example_workflow:
88
+ SentimentAnalyzer:
89
+ node_type: llm
90
+ event_triggers: [START]
91
+ prompt: "Analyze the sentiment of: {{ context.user_message }}"
92
+ output_field: analysis
93
+ event_emissions:
94
+ - signal_name: POSITIVE
95
+ condition: "The message expresses positive sentiment"
96
+ - signal_name: NEGATIVE
97
+ condition: "The message expresses negative sentiment"
98
+ - signal_name: NEUTRAL
99
+ condition: "The message is neutral or factual"
100
+ ```
101
+
102
+ ### How It Works
103
+
104
+ 1. The LLM analyzes the sentiment
105
+ 2. SOE sees multiple signals with plain-text conditions (no `{{ }}`)
106
+ 3. SOE asks the LLM: "Based on your analysis, which signal should be emitted?" using the conditions as descriptions
107
+ 4. The LLM returns the appropriate signal (POSITIVE, NEGATIVE, or NEUTRAL)
108
+
109
+ This is called the **resolution step** - it lets the LLM make routing decisions based on its understanding.
110
+
111
+ ### Signal Emission Rules
112
+
113
+ The `condition` field controls how signals are emitted:
114
+
115
+ | Condition | Behavior |
116
+ |-----------|----------|
117
+ | **No condition** | Signal is always emitted |
118
+ | **Plain text** | Semantic—LLM selects which signal to emit based on the description |
119
+ | **Jinja template (`{{ }}`)** | Programmatic—evaluated against `context`, emits if truthy |
120
+
121
+ **How SOE decides:**
122
+
123
+ 1. **No conditions**: All signals emit unconditionally after node execution
124
+ 2. **Has conditions**: SOE checks if they contain `{{ }}`:
125
+ - **Yes (Jinja)**: Evaluate expression against `context`—emit if result is truthy
126
+ - **No (plain text)**: Ask LLM to choose which signal best matches its output
127
+
128
+ ## Testing LLM Nodes
129
+
130
+ In tests, we inject a stub function instead of calling a real LLM:
131
+
132
+ ```python
133
+ def stub_llm(prompt: str, config: dict) -> str:
134
+ return "This is a predictable response."
135
+
136
+ nodes["llm"] = create_llm_node_caller(backends, stub_llm, broadcast_signals_caller)
137
+ ```
138
+
139
+ The stub knows the *contract* (prompt → response), not the implementation. This keeps tests fast and deterministic.
140
+
141
+ ## Next Steps
142
+
143
+ Now that you understand LLM nodes, let's see how [Router Nodes](guide_03_router.md) connect your workflows together →
@@ -0,0 +1,146 @@
1
+
2
+ # SOE Guide: Chapter 3 - Router Nodes
3
+
4
+ ## Introduction to Router Nodes
5
+
6
+ The **Router** is a pure routing node—it doesn't execute code or call LLMs. It simply reads the context, evaluates conditions, and emits signals to direct the workflow.
7
+
8
+ Think of a Router as a **traffic controller**: based on the current state (context), it decides which signal to emit, directing the workflow to the next step. Routers are the glue that connects your Tool and LLM nodes together.
9
+
10
+ ### When to Use Router Nodes
11
+
12
+ - **Entry Points**: Start a workflow and fan out to multiple paths
13
+ - **Conditional Branching**: Route based on context values
14
+ - **Signal Transformation**: Convert one signal to another
15
+ - **Checkpoints**: Create explicit decision points in your workflow
16
+
17
+ ### Why Router Comes Third
18
+
19
+ We covered Tool and LLM nodes first because they **do work**. Routers don't—they route. Now that you understand the nodes that execute, you can see how Routers connect them into powerful workflows.
20
+
21
+ ## Your First Router: Input Validation
22
+
23
+ Let's validate that user input exists before processing it.
24
+
25
+ ### The Workflow
26
+
27
+ ```yaml
28
+ example_workflow:
29
+ InputValidator:
30
+ node_type: router
31
+ event_triggers: [START]
32
+ event_emissions:
33
+ - signal_name: VALID_INPUT
34
+ condition: "{{ context.user_input is defined and context.user_input != '' }}"
35
+ - signal_name: INVALID_INPUT
36
+ condition: "{{ context.user_input is not defined or context.user_input == '' }}"
37
+ ```
38
+
39
+ ### How It Works
40
+
41
+ 1. **Event Trigger**: The `START` signal triggers the `InputValidator` node.
42
+ 2. **Condition Evaluation**: The router checks two conditions using Jinja2 templates:
43
+ - If `context.user_input` is defined and not empty → emit `VALID_INPUT`
44
+ - If `context.user_input` is missing or empty → emit `INVALID_INPUT`
45
+ 3. **Signal Emission**: One or more signals are emitted based on which conditions are true.
46
+
47
+ ### Key Concepts
48
+
49
+ - **Routers** evaluate Jinja2 conditions and emit signals
50
+ - **Conditions** can check if variables exist, compare values, etc.
51
+ - **Signals** are the "nervous system" of SOE—they trigger the next steps
52
+
53
+ ## Unconditional Signals
54
+
55
+ Not every router needs a condition. Sometimes you just want to forward a signal unconditionally.
56
+
57
+ ### The Workflow
58
+
59
+ ```yaml
60
+ example_workflow:
61
+ Forwarder:
62
+ node_type: router
63
+ event_triggers: [START]
64
+ event_emissions:
65
+ - signal_name: CONTINUE
66
+ ```
67
+
68
+ This router simply receives `START` and immediately emits `CONTINUE`. It's useful when you want to:
69
+ - **Rename signals**: Transform external signals to internal naming
70
+ - **Create checkpoints**: Make workflow structure explicit
71
+ - **Fan-out**: Emit multiple signals to trigger parallel processing
72
+
73
+ ## Decision Trees: Routers Calling Routers
74
+
75
+ The real power of routers emerges when you chain them together. Since each router emits signals that trigger other nodes, you can build **decision trees** (or more generally, **directed graphs**) where the workflow branches based on context.
76
+
77
+ ### The Pattern
78
+
79
+ Think of it as a flowchart:
80
+ - **Level 1 Router**: Classifies the initial request
81
+ - **Level 2+ Routers**: Handle sub-cases, triggered by signals from Level 1
82
+ - **Terminal Nodes**: Complete specific branches of the tree
83
+
84
+ ### The Workflow
85
+
86
+ ```yaml
87
+ example_workflow:
88
+ # Level 1: Is the user premium or free?
89
+ UserTierCheck:
90
+ node_type: router
91
+ event_triggers: [START]
92
+ event_emissions:
93
+ - signal_name: PREMIUM_PATH
94
+ condition: "{{ context.user_tier == 'premium' }}"
95
+ - signal_name: FREE_PATH
96
+ condition: "{{ context.user_tier == 'free' }}"
97
+
98
+ # Level 2a: Premium users get feature checks
99
+ PremiumFeatureRouter:
100
+ node_type: router
101
+ event_triggers: [PREMIUM_PATH]
102
+ event_emissions:
103
+ - signal_name: ENABLE_ADVANCED
104
+ condition: "{{ context.feature_level == 'advanced' }}"
105
+ - signal_name: ENABLE_BASIC
106
+ condition: "{{ context.feature_level != 'advanced' }}"
107
+
108
+ # Level 2b: Free users get upgrade prompts
109
+ FreeUserRouter:
110
+ node_type: router
111
+ event_triggers: [FREE_PATH]
112
+ event_emissions:
113
+ - signal_name: SHOW_UPGRADE
114
+ condition: "{{ context.show_upsell }}"
115
+ - signal_name: CONTINUE_FREE
116
+ condition: "{{ not context.show_upsell }}"
117
+ ```
118
+
119
+ ### How It Works
120
+
121
+ 1. `UserTierCheck` receives `START` and evaluates `context.user_tier`
122
+ 2. Depending on the tier, it emits either `PREMIUM_PATH` or `FREE_PATH`
123
+ 3. The corresponding router (`PremiumFeatureRouter` or `FreeUserRouter`) activates
124
+ 4. That router evaluates its own conditions and emits the final signal
125
+
126
+ ### Why This Matters
127
+
128
+ - **Modularity**: Each router handles one decision. Easy to test, easy to modify.
129
+ - **Composition**: Add new branches by adding new routers—no need to modify existing ones.
130
+ - **Visibility**: The workflow YAML *is* the flowchart. No hidden logic in code.
131
+
132
+ ## The Three Core Node Types
133
+
134
+ You now know the three core node types in SOE:
135
+
136
+ | Node | Purpose | Does Work? |
137
+ |------|---------|-----------|
138
+ | **Tool** | Execute Python functions | ✅ Yes |
139
+ | **LLM** | Call language models | ✅ Yes |
140
+ | **Router** | Route signals based on context | ❌ No (pure routing) |
141
+
142
+ With just these three nodes, you can build remarkably sophisticated workflows—including custom agent patterns, chain-of-thought reasoning, and more. The next chapter shows you how.
143
+
144
+ ## Next Steps
145
+
146
+ Now that you understand the three core node types, let's combine them into powerful patterns with [Building Custom Workflows](guide_04_patterns.md) →