soe-ai 0.1.0__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 (138) hide show
  1. soe/broker.py +4 -5
  2. soe/builtin_tools/__init__.py +39 -0
  3. soe/builtin_tools/soe_add_signal.py +82 -0
  4. soe/builtin_tools/soe_call_tool.py +111 -0
  5. soe/builtin_tools/soe_copy_context.py +80 -0
  6. soe/builtin_tools/soe_explore_docs.py +290 -0
  7. soe/builtin_tools/soe_get_available_tools.py +42 -0
  8. soe/builtin_tools/soe_get_context.py +50 -0
  9. soe/builtin_tools/soe_get_workflows.py +63 -0
  10. soe/builtin_tools/soe_inject_node.py +86 -0
  11. soe/builtin_tools/soe_inject_workflow.py +105 -0
  12. soe/builtin_tools/soe_list_contexts.py +73 -0
  13. soe/builtin_tools/soe_remove_node.py +72 -0
  14. soe/builtin_tools/soe_remove_workflow.py +62 -0
  15. soe/builtin_tools/soe_update_context.py +54 -0
  16. soe/docs/_config.yml +10 -0
  17. soe/docs/advanced_patterns/guide_fanout_and_aggregations.md +318 -0
  18. soe/docs/advanced_patterns/guide_inheritance.md +435 -0
  19. soe/docs/advanced_patterns/hybrid_intelligence.md +237 -0
  20. soe/docs/advanced_patterns/index.md +49 -0
  21. soe/docs/advanced_patterns/operational.md +781 -0
  22. soe/docs/advanced_patterns/self_evolving_workflows.md +385 -0
  23. soe/docs/advanced_patterns/swarm_intelligence.md +211 -0
  24. soe/docs/builtins/context.md +164 -0
  25. soe/docs/builtins/explore_docs.md +135 -0
  26. soe/docs/builtins/tools.md +164 -0
  27. soe/docs/builtins/workflows.md +199 -0
  28. soe/docs/guide_00_getting_started.md +341 -0
  29. soe/docs/guide_01_tool.md +206 -0
  30. soe/docs/guide_02_llm.md +143 -0
  31. soe/docs/guide_03_router.md +146 -0
  32. soe/docs/guide_04_patterns.md +475 -0
  33. soe/docs/guide_05_agent.md +159 -0
  34. soe/docs/guide_06_schema.md +397 -0
  35. soe/docs/guide_07_identity.md +540 -0
  36. soe/docs/guide_08_child.md +612 -0
  37. soe/docs/guide_09_ecosystem.md +690 -0
  38. soe/docs/guide_10_infrastructure.md +427 -0
  39. soe/docs/guide_11_builtins.md +118 -0
  40. soe/docs/index.md +104 -0
  41. soe/docs/primitives/backends.md +281 -0
  42. soe/docs/primitives/context.md +256 -0
  43. soe/docs/primitives/node_reference.md +259 -0
  44. soe/docs/primitives/primitives.md +331 -0
  45. soe/docs/primitives/signals.md +865 -0
  46. soe/docs_index.py +1 -1
  47. soe/init.py +2 -2
  48. soe/lib/__init__.py +0 -0
  49. soe/lib/child_context.py +46 -0
  50. soe/lib/context_fields.py +51 -0
  51. soe/lib/inheritance.py +172 -0
  52. soe/lib/jinja_render.py +113 -0
  53. soe/lib/operational.py +51 -0
  54. soe/lib/parent_sync.py +71 -0
  55. soe/lib/register_event.py +75 -0
  56. soe/lib/schema_validation.py +134 -0
  57. soe/lib/yaml_parser.py +14 -0
  58. soe/local_backends/__init__.py +18 -0
  59. soe/local_backends/factory.py +124 -0
  60. soe/local_backends/in_memory/context.py +38 -0
  61. soe/local_backends/in_memory/conversation_history.py +60 -0
  62. soe/local_backends/in_memory/identity.py +52 -0
  63. soe/local_backends/in_memory/schema.py +40 -0
  64. soe/local_backends/in_memory/telemetry.py +38 -0
  65. soe/local_backends/in_memory/workflow.py +33 -0
  66. soe/local_backends/storage/context.py +57 -0
  67. soe/local_backends/storage/conversation_history.py +82 -0
  68. soe/local_backends/storage/identity.py +118 -0
  69. soe/local_backends/storage/schema.py +96 -0
  70. soe/local_backends/storage/telemetry.py +72 -0
  71. soe/local_backends/storage/workflow.py +56 -0
  72. soe/nodes/__init__.py +13 -0
  73. soe/nodes/agent/__init__.py +10 -0
  74. soe/nodes/agent/factory.py +134 -0
  75. soe/nodes/agent/lib/loop_handlers.py +150 -0
  76. soe/nodes/agent/lib/loop_state.py +157 -0
  77. soe/nodes/agent/lib/prompts.py +65 -0
  78. soe/nodes/agent/lib/tools.py +35 -0
  79. soe/nodes/agent/stages/__init__.py +12 -0
  80. soe/nodes/agent/stages/parameter.py +37 -0
  81. soe/nodes/agent/stages/response.py +54 -0
  82. soe/nodes/agent/stages/router.py +37 -0
  83. soe/nodes/agent/state.py +111 -0
  84. soe/nodes/agent/types.py +66 -0
  85. soe/nodes/agent/validation/__init__.py +11 -0
  86. soe/nodes/agent/validation/config.py +95 -0
  87. soe/nodes/agent/validation/operational.py +24 -0
  88. soe/nodes/child/__init__.py +3 -0
  89. soe/nodes/child/factory.py +61 -0
  90. soe/nodes/child/state.py +59 -0
  91. soe/nodes/child/validation/__init__.py +11 -0
  92. soe/nodes/child/validation/config.py +126 -0
  93. soe/nodes/child/validation/operational.py +28 -0
  94. soe/nodes/lib/conditions.py +71 -0
  95. soe/nodes/lib/context.py +24 -0
  96. soe/nodes/lib/conversation_history.py +77 -0
  97. soe/nodes/lib/identity.py +64 -0
  98. soe/nodes/lib/llm_resolver.py +142 -0
  99. soe/nodes/lib/output.py +68 -0
  100. soe/nodes/lib/response_builder.py +91 -0
  101. soe/nodes/lib/signal_emission.py +79 -0
  102. soe/nodes/lib/signals.py +54 -0
  103. soe/nodes/lib/tools.py +100 -0
  104. soe/nodes/llm/__init__.py +7 -0
  105. soe/nodes/llm/factory.py +103 -0
  106. soe/nodes/llm/state.py +76 -0
  107. soe/nodes/llm/types.py +12 -0
  108. soe/nodes/llm/validation/__init__.py +11 -0
  109. soe/nodes/llm/validation/config.py +89 -0
  110. soe/nodes/llm/validation/operational.py +23 -0
  111. soe/nodes/router/__init__.py +3 -0
  112. soe/nodes/router/factory.py +37 -0
  113. soe/nodes/router/state.py +32 -0
  114. soe/nodes/router/validation/__init__.py +11 -0
  115. soe/nodes/router/validation/config.py +58 -0
  116. soe/nodes/router/validation/operational.py +16 -0
  117. soe/nodes/tool/factory.py +66 -0
  118. soe/nodes/tool/lib/__init__.py +11 -0
  119. soe/nodes/tool/lib/conditions.py +35 -0
  120. soe/nodes/tool/lib/failure.py +28 -0
  121. soe/nodes/tool/lib/parameters.py +67 -0
  122. soe/nodes/tool/state.py +66 -0
  123. soe/nodes/tool/types.py +27 -0
  124. soe/nodes/tool/validation/__init__.py +15 -0
  125. soe/nodes/tool/validation/config.py +132 -0
  126. soe/nodes/tool/validation/operational.py +16 -0
  127. soe/types.py +40 -28
  128. soe/validation/__init__.py +18 -0
  129. soe/validation/config.py +195 -0
  130. soe/validation/jinja.py +54 -0
  131. soe/validation/operational.py +110 -0
  132. {soe_ai-0.1.0.dist-info → soe_ai-0.1.2.dist-info}/METADATA +72 -9
  133. soe_ai-0.1.2.dist-info/RECORD +137 -0
  134. {soe_ai-0.1.0.dist-info → soe_ai-0.1.2.dist-info}/WHEEL +1 -1
  135. soe/validation.py +0 -8
  136. soe_ai-0.1.0.dist-info/RECORD +0 -11
  137. {soe_ai-0.1.0.dist-info → soe_ai-0.1.2.dist-info}/licenses/LICENSE +0 -0
  138. {soe_ai-0.1.0.dist-info → soe_ai-0.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,341 @@
1
+ # SOE Guide: Getting Started
2
+
3
+ ## What is SOE?
4
+
5
+ SOE (Signal-driven Orchestration Engine) is a Python library for building workflows through **signal-based orchestration**. Workflows are defined in YAML and executed by nodes that communicate through signals.
6
+
7
+ **If you've already seen the [README](../README.md)**, this guide expands on the quick start with explanations and guidance to other guides.
8
+
9
+ ---
10
+
11
+ ## The 7 Primitives
12
+
13
+ SOE is built on **7 core concepts**. Understanding these gives you complete control:
14
+
15
+ | Primitive | What It Is | Quick Example |
16
+ |-----------|------------|---------------|
17
+ | **Signals** | Named events for node communication | `START`, `DONE`, `ERROR` |
18
+ | **Context** | Shared state dictionary (with Jinja access) | `{{ context.user_id }}` |
19
+ | **Workflows** | YAML definitions of nodes and their relationships | See below |
20
+ | **Backends** | Pluggable storage implementations | PostgreSQL, DynamoDB, in-memory |
21
+ | **Nodes** | Execution units (router, llm, tool, agent, child) | `node_type: llm` |
22
+ | **Identities** | System prompts for LLM/Agent nodes | Optional role definitions |
23
+ | **Context Schema** | Type validation for LLM outputs | Optional field definitions |
24
+
25
+ **Key insight**: Context is accessible via Jinja2 in conditions and prompts. This makes workflows readable and debuggable.
26
+
27
+ For detailed coverage, see [The 7 Primitives](primitives/primitives.md).
28
+
29
+ ---
30
+
31
+ ## Signal Types
32
+
33
+ SOE supports different signal emission patterns:
34
+
35
+ | Type | Description | Example |
36
+ |------|-------------|---------|
37
+ | **Conditional** | Emitted when Jinja condition is true | `condition: "{{ context.valid }}"` |
38
+ | **Unconditional** | Always emitted (no condition) | `signal_name: DONE` |
39
+ | **Semantic** | LLM selects signal (for testing/prototyping) | `signal_name: APPROVE` with LLM choice |
40
+
41
+ ---
42
+
43
+ ## Key Concept: Synchronous Execution
44
+
45
+ SOE executes **synchronously** within a single Python process. This is intentional:
46
+
47
+ ```python
48
+ # This is how SOE works - synchronous, blocking execution
49
+ execution_id = orchestrate(
50
+ config=my_workflow,
51
+ initial_workflow_name="example_workflow",
52
+ initial_signals=["START"],
53
+ initial_context={"user": "alice"},
54
+ backends=backends,
55
+ broadcast_signals_caller=broadcast_signals_caller,
56
+ )
57
+ # When this returns, the entire workflow has completed
58
+ ```
59
+
60
+ **Why synchronous?**
61
+
62
+ 1. **Simplicity** — No async/await complexity, no event loop management
63
+ 2. **Predictability** — Easy to debug, test, and reason about
64
+ 3. **Portability** — The same workflow runs locally, in Lambda, in Jenkins, anywhere
65
+
66
+ **But what about distribution?**
67
+
68
+ The synchronous nature is about *how a single workflow executes*. Distribution happens at the **caller level**:
69
+
70
+ - Replace `broadcast_signals_caller` with an HTTP webhook → signals trigger remote services
71
+ - Replace `broadcast_signals_caller` with an SQS publisher → signals become queue messages
72
+ - Replace backends with PostgreSQL/DynamoDB → state becomes distributed
73
+
74
+ Your workflow YAML stays exactly the same. See [Chapter 10: Custom Infrastructure](guide_10_infrastructure.md) for details.
75
+
76
+ ---
77
+
78
+ ## Installation
79
+
80
+ ```bash
81
+ # With uv (recommended)
82
+ uv add soe-ai
83
+
84
+ # Or with pip
85
+ pip install soe-ai
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Quick Start: Copy-Paste Template
91
+
92
+ Here's a minimal working example. Copy this, add your own workflow, and run:
93
+
94
+ ```python
95
+ from soe import orchestrate, broadcast_signals
96
+ from soe.local_backends import create_in_memory_backends
97
+ from soe.nodes.router.factory import create_router_node_caller
98
+
99
+ # 1. Define your workflow in YAML
100
+ workflow_yaml = """
101
+ example_workflow:
102
+ Start:
103
+ node_type: router
104
+ event_triggers: [START]
105
+ event_emissions:
106
+ - signal_name: VALID
107
+ condition: "{{ context.user_input is defined }}"
108
+ - signal_name: INVALID
109
+ condition: "{{ context.user_input is not defined }}"
110
+
111
+ # Routers can also act as signal converters (tunneling)
112
+ # This decouples downstream nodes from knowing validation logic
113
+ OnValid:
114
+ node_type: router
115
+ event_triggers: [VALID]
116
+ event_emissions:
117
+ - signal_name: DONE
118
+
119
+ OnInvalid:
120
+ node_type: router
121
+ event_triggers: [INVALID]
122
+ event_emissions:
123
+ - signal_name: DONE
124
+ """
125
+
126
+ # 2. Create in-memory backends (simplest option)
127
+ backends = create_in_memory_backends()
128
+
129
+ # 3. Set up nodes and caller
130
+ nodes = {}
131
+
132
+ def broadcast_signals_caller(id: str, signals):
133
+ broadcast_signals(id, signals, nodes, backends)
134
+
135
+ nodes["router"] = create_router_node_caller(backends, broadcast_signals_caller)
136
+
137
+ # 4. Execute!
138
+ execution_id = orchestrate(
139
+ config=workflow_yaml,
140
+ initial_workflow_name="example_workflow",
141
+ initial_signals=["START"],
142
+ initial_context={"user_input": "Hello, SOE!"},
143
+ backends=backends,
144
+ broadcast_signals_caller=broadcast_signals_caller,
145
+ )
146
+
147
+ # 5. Check the result
148
+ context = backends.context.get_context(execution_id)
149
+ print(f"Status: {context['status']}") # Output: Status: success
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Adding More Node Types
155
+
156
+ The template above only uses `router` nodes. To add more capabilities:
157
+
158
+ ### Adding Tool Nodes
159
+
160
+ ```python
161
+ from soe.nodes.tool.factory import create_tool_node_caller
162
+
163
+ # Define your tools
164
+ def greet(name: str) -> dict:
165
+ return {"greeting": f"Hello, {name}!"}
166
+
167
+ tools_registry = {"greet": greet}
168
+
169
+ # Add to nodes
170
+ nodes["tool"] = create_tool_node_caller(backends, tools_registry, broadcast_signals_caller)
171
+ ```
172
+
173
+ ### Adding LLM Nodes
174
+
175
+ SOE **does not provide** an LLM. Instead, you provide a `call_llm` function that wraps your chosen provider:
176
+
177
+ ```python
178
+ from soe.nodes.llm.factory import create_llm_node_caller
179
+
180
+ # Define your LLM caller (wrap your API)
181
+ def call_llm(prompt: str, config: dict) -> str:
182
+ """
183
+ The LLM caller contract:
184
+ - prompt: The rendered prompt string (text in)
185
+ - config: The full node configuration from YAML
186
+ - returns: The LLM response (text out)
187
+ """
188
+ # Example: Using OpenAI
189
+ import openai
190
+ response = openai.chat.completions.create(
191
+ model=config.get("model", "gpt-4o"), # Custom field from YAML
192
+ messages=[{"role": "user", "content": prompt}]
193
+ )
194
+ return response.choices[0].message.content
195
+
196
+ # Add to nodes
197
+ nodes["llm"] = create_llm_node_caller(backends, call_llm, broadcast_signals_caller)
198
+ ```
199
+
200
+ The `config` parameter receives the **entire node configuration** from your YAML. This lets you add custom fields:
201
+
202
+ ```yaml
203
+ MyLLM:
204
+ node_type: llm
205
+ prompt: "Summarize: {{ context.text }}"
206
+ model: gpt-4o-mini # Custom field - your call_llm can read this
207
+ temperature: 0.7 # Another custom field
208
+ output_field: summary
209
+ ```
210
+
211
+ See [Chapter 10: Infrastructure](guide_10_infrastructure.md#the-llm-caller) for detailed examples.
212
+
213
+ ### Adding Agent Nodes
214
+
215
+ ```python
216
+ from soe.nodes.agent.factory import create_agent_node_caller
217
+
218
+ # Agent needs both LLM and tools
219
+ tools = [{"function": greet, "max_retries": 0}]
220
+ nodes["agent"] = create_agent_node_caller(backends, tools, call_llm, broadcast_signals_caller)
221
+ ```
222
+
223
+ ### Adding Child Workflow Nodes
224
+
225
+ ```python
226
+ import copy
227
+ from soe import orchestrate, broadcast_signals
228
+ from soe.nodes.child.factory import create_child_node_caller
229
+ from soe.lib.yaml_parser import parse_yaml
230
+
231
+ # Create orchestrate caller for child workflows
232
+ def orchestrate_caller(config, initial_workflow_name, initial_signals, initial_context, backends):
233
+ if isinstance(config, str):
234
+ config = parse_yaml(config)
235
+ else:
236
+ config = copy.deepcopy(config)
237
+
238
+ def broadcast_signals_caller(execution_id, signals):
239
+ broadcast_signals(execution_id, signals, nodes, backends)
240
+
241
+ return orchestrate(
242
+ config=config,
243
+ initial_workflow_name=initial_workflow_name,
244
+ initial_signals=initial_signals,
245
+ initial_context=initial_context,
246
+ backends=backends,
247
+ broadcast_signals_caller=broadcast_signals_caller,
248
+ )
249
+
250
+ nodes["child"] = create_child_node_caller(backends, orchestrate_caller)
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Helper Function: All Nodes at Once
256
+
257
+ For convenience, you can set up all node types in one call using the built-in `create_all_nodes` helper:
258
+
259
+ ```python
260
+ from soe import create_all_nodes, orchestrate
261
+ from soe.local_backends import create_in_memory_backends
262
+
263
+ backends = create_in_memory_backends()
264
+
265
+ # Set up all nodes (pass your call_llm and tools_registry)
266
+ nodes, broadcast = create_all_nodes(
267
+ backends,
268
+ call_llm=my_call_llm,
269
+ tools_registry=my_tools
270
+ )
271
+
272
+ # Usage
273
+ execution_id = orchestrate(
274
+ config=workflow_yaml,
275
+ initial_workflow_name="example_workflow",
276
+ initial_signals=["START"],
277
+ initial_context={"user_input": "Hello!"},
278
+ backends=backends,
279
+ broadcast_signals_caller=broadcast,
280
+ )
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Choosing Your Infrastructure
286
+
287
+ SOE supports different infrastructure configurations depending on your needs:
288
+
289
+ | Scenario | Backends | Caller | Use Case |
290
+ |----------|----------|--------|----------|
291
+ | **Development** | In-Memory | Local | Fast iteration, unit tests |
292
+ | **Local Debugging** | Local Files | Local | Inspect state in JSON files |
293
+ | **Distributed State** | PostgreSQL/DynamoDB | Local | Shared state, multiple workers |
294
+ | **Fully Distributed** | PostgreSQL/DynamoDB | HTTP/SQS/Lambda | Scalable production |
295
+
296
+ For custom backends and distributed callers, see [Chapter 10: Infrastructure](guide_10_infrastructure.md).
297
+
298
+ ---
299
+
300
+ ## Workflow Portability
301
+
302
+ A critical design principle: **your workflow YAML works everywhere**.
303
+
304
+ ```yaml
305
+ # This exact workflow runs:
306
+ # - Locally with in-memory backends
307
+ # - In production with PostgreSQL + Lambda callers
308
+ # - In Jenkins with file backends + webhook callers
309
+ example_workflow:
310
+ ValidateInput:
311
+ node_type: router
312
+ event_triggers: [START]
313
+ event_emissions:
314
+ - signal_name: VALID
315
+ condition: "{{ context.data is defined }}"
316
+ ```
317
+
318
+ The workflow defines *what* happens. The infrastructure (backends + callers) defines *how* it executes. Change the infrastructure without touching workflow logic.
319
+
320
+ ---
321
+
322
+ ## Next Steps
323
+
324
+ **Understand the Building Blocks:**
325
+ - **[The 7 Primitives](primitives/primitives.md)** — Deep dive into signals, context, workflows, and backends
326
+
327
+ **Core Guides:**
328
+ 1. **[Chapter 1: Tool Nodes](guide_01_tool.md)** — Execute Python functions—APIs, databases, real work
329
+ 2. **[Chapter 2: LLM Nodes](guide_02_llm.md)** — Add language model capabilities
330
+ 3. **[Chapter 3: Router Nodes](guide_03_router.md)** — Pure routing, branching, no execution
331
+ 4. **[Chapter 4: Patterns](guide_04_patterns.md)** — Combine primitives: ReAct, chain-of-thought
332
+ 5. **[Chapter 5: Agent Nodes](guide_05_agent.md)** — Built-in ReAct loop for tool-using agents
333
+ 6. **[Chapter 6: Schema](guide_06_schema.md)** — Validate LLM outputs with context schema
334
+ 7. **[Chapter 7: Identity](guide_07_identity.md)** — System prompts and conversation history
335
+ 8. **[Chapter 8: Child Workflows](guide_08_child.md)** — Nested workflows with signal communication
336
+ 9. **[Chapter 9: Ecosystem](guide_09_ecosystem.md)** — Multi-workflow registries and versioning
337
+ 10. **[Chapter 10: Infrastructure](guide_10_infrastructure.md)** — Custom backends and callers
338
+ 11. **[Chapter 11: Built-in Tools](guide_11_builtins.md)** — Self-evolution, documentation exploration, and runtime modification
339
+
340
+ **Advanced:**
341
+ - **[Self-Evolving Workflows](advanced_patterns/self_evolving_workflows.md)** — Workflows that modify themselves at runtime
@@ -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 →