stroma 0.2.7__tar.gz → 0.3.2__tar.gz
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.
- {stroma-0.2.7/src/stroma.egg-info → stroma-0.3.2}/PKG-INFO +119 -26
- stroma-0.3.2/README.md +244 -0
- {stroma-0.2.7 → stroma-0.3.2}/pyproject.toml +4 -2
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/__init__.py +26 -3
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/adapters/__init__.py +4 -0
- stroma-0.3.2/src/stroma/adapters/base.py +100 -0
- stroma-0.3.2/src/stroma/adapters/crewai.py +95 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/adapters/langgraph.py +1 -22
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/contracts.py +4 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/cost.py +43 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/failures.py +2 -0
- stroma-0.3.2/src/stroma/middleware.py +327 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/runner.py +163 -172
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/trace.py +5 -0
- {stroma-0.2.7 → stroma-0.3.2/src/stroma.egg-info}/PKG-INFO +119 -26
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma.egg-info/SOURCES.txt +5 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma.egg-info/requires.txt +4 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_cost.py +78 -0
- stroma-0.3.2/tests/test_middleware.py +196 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_runner.py +114 -3
- stroma-0.3.2/tests/test_step_aliases.py +116 -0
- stroma-0.2.7/README.md +0 -154
- {stroma-0.2.7 → stroma-0.3.2}/LICENSE +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/setup.cfg +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/adapters/deepagents.py +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/checkpoint.py +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma/py.typed +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma.egg-info/dependency_links.txt +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/src/stroma.egg-info/top_level.txt +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_checkpoint.py +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_contracts.py +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_failures.py +0 -0
- {stroma-0.2.7 → stroma-0.3.2}/tests/test_trace.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stroma
|
|
3
|
-
Version: 0.2
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: The framework handles the graph. Stroma handles the guarantees.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jengroff/stroma
|
|
7
7
|
Project-URL: Repository, https://github.com/jengroff/stroma
|
|
@@ -18,11 +18,14 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
18
18
|
Requires-Python: >=3.12
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
+
Requires-Dist: crewai>=1.13.0
|
|
21
22
|
Requires-Dist: pydantic>=2.0
|
|
22
23
|
Provides-Extra: redis
|
|
23
24
|
Requires-Dist: redis>=5.0; extra == "redis"
|
|
24
25
|
Provides-Extra: langgraph
|
|
25
26
|
Requires-Dist: langgraph>=0.2; extra == "langgraph"
|
|
27
|
+
Provides-Extra: crewai
|
|
28
|
+
Requires-Dist: crewai>=0.70; extra == "crewai"
|
|
26
29
|
Provides-Extra: deepagents
|
|
27
30
|
Requires-Dist: deepagents>=0.4.0; extra == "deepagents"
|
|
28
31
|
Requires-Dist: langgraph>=0.2; extra == "deepagents"
|
|
@@ -42,44 +45,103 @@ Dynamic: license-file
|
|
|
42
45
|
|
|
43
46
|
**dbt didn't replace your data warehouse. Stroma doesn't replace your agent framework.**
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
dbt gave you typed models, tested transformations, and documented lineage — a software engineering layer that worked regardless of which warehouse you were running. Stroma does the same thing for agent execution graphs: typed node contracts, formal failure classification, and cost-aware execution — portable across whatever orchestration framework you're building on.
|
|
49
|
+
|
|
50
|
+
The framework handles the graph. Stroma handles the guarantees.
|
|
51
|
+
|
|
52
|
+
## The problem it solves
|
|
53
|
+
|
|
54
|
+
LLM pipelines fail in ways that traditional software doesn't. A node returns malformed data and the error surfaces three steps later. A transient timeout kills a 20-minute run and you start over from scratch. Costs spiral past budget with no enforcement mechanism. Failures are silent until they're catastrophic.
|
|
55
|
+
|
|
56
|
+
Stroma gives you the building blocks to handle this — without locking you into a framework.
|
|
57
|
+
|
|
58
|
+
## See it in action
|
|
59
|
+
|
|
60
|
+
The scenario below is the kind that breaks raw LangGraph pipelines: a multi-step run that crashes midway, resumes from checkpoint, and gives you a diff of what changed between the failed and successful run.
|
|
46
61
|
|
|
47
62
|
```python
|
|
63
|
+
import asyncio
|
|
48
64
|
from pydantic import BaseModel
|
|
49
|
-
from stroma import
|
|
65
|
+
from stroma import (
|
|
66
|
+
AsyncInMemoryStore,
|
|
67
|
+
CheckpointManager,
|
|
68
|
+
ContractRegistry,
|
|
69
|
+
NodeContract,
|
|
70
|
+
RunConfig,
|
|
71
|
+
StromaRunner,
|
|
72
|
+
stroma_node,
|
|
73
|
+
)
|
|
50
74
|
|
|
51
|
-
class Input(BaseModel):
|
|
52
|
-
value: int
|
|
53
75
|
|
|
54
|
-
class
|
|
55
|
-
|
|
76
|
+
class Document(BaseModel):
|
|
77
|
+
text: str
|
|
56
78
|
|
|
57
|
-
runner = StromaRunner.quick()
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return {"result": state.value * 2}
|
|
80
|
+
class Extracted(BaseModel):
|
|
81
|
+
entities: list[str]
|
|
62
82
|
|
|
63
|
-
result = await runner.run([double], Input(value=5))
|
|
64
|
-
print(result.final_state) # result=10
|
|
65
|
-
```
|
|
66
83
|
|
|
67
|
-
|
|
84
|
+
class Summary(BaseModel):
|
|
85
|
+
entities: list[str]
|
|
86
|
+
count: int
|
|
68
87
|
|
|
69
|
-
Requires **Python 3.12+**.
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
registry = ContractRegistry()
|
|
90
|
+
store = AsyncInMemoryStore()
|
|
91
|
+
manager = CheckpointManager(store)
|
|
74
92
|
|
|
75
|
-
|
|
93
|
+
c1 = NodeContract(node_id="extract", input_schema=Document, output_schema=Extracted)
|
|
94
|
+
c2 = NodeContract(node_id="summarize", input_schema=Extracted, output_schema=Summary)
|
|
95
|
+
registry.register(c1)
|
|
96
|
+
registry.register(c2)
|
|
76
97
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
|
|
99
|
+
@stroma_node("extract", c1)
|
|
100
|
+
async def extract(state: Document) -> dict:
|
|
101
|
+
return {"entities": state.text.split()}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@stroma_node("summarize", c2)
|
|
105
|
+
async def summarize_failing(state: Extracted) -> dict:
|
|
106
|
+
raise TimeoutError("downstream API unavailable") # (1)!
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@stroma_node("summarize", c2)
|
|
110
|
+
async def summarize_fixed(state: Extracted) -> dict:
|
|
111
|
+
return {"entities": state.entities, "count": len(state.entities)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def main():
|
|
115
|
+
config1 = RunConfig(run_id="doc-run-1")
|
|
116
|
+
runner1 = StromaRunner(registry, manager, config1)
|
|
117
|
+
result1 = await runner1.run(
|
|
118
|
+
[extract, summarize_failing],
|
|
119
|
+
Document(text="Stroma adds reliability to agent pipelines"),
|
|
120
|
+
)
|
|
121
|
+
print(result1.status) # PARTIAL — extract checkpointed, summarize exhausted retries
|
|
122
|
+
|
|
123
|
+
config2 = RunConfig(run_id="doc-run-1", resume_from="summarize") # (2)!
|
|
124
|
+
runner2 = StromaRunner(registry, manager, config2)
|
|
125
|
+
result2 = await runner2.run(
|
|
126
|
+
[extract, summarize_fixed],
|
|
127
|
+
Document(text="Stroma adds reliability to agent pipelines"),
|
|
128
|
+
)
|
|
129
|
+
print(result2.status) # RESUMED — extract skipped, loaded from checkpoint
|
|
130
|
+
print(result2.final_state) # entities=[...] count=6
|
|
131
|
+
|
|
132
|
+
diffs = result1.trace.diff(result2.trace) # (3)!
|
|
133
|
+
for d in diffs:
|
|
134
|
+
print(d)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
asyncio.run(main())
|
|
138
|
+
# In a Jupyter notebook, replace the line above with: await main()
|
|
81
139
|
```
|
|
82
140
|
|
|
141
|
+
1. `TimeoutError` is classified as `RECOVERABLE`. Stroma retries with jittered backoff. After exhausting retries, the run fails — but `extract`'s output is already checkpointed.
|
|
142
|
+
2. Same `run_id`, `resume_from="summarize"`. The runner loads `extract`'s checkpoint and skips re-running it entirely.
|
|
143
|
+
3. `diff()` compares both traces — node IDs, attempts, inputs, outputs, failure states — so you can see exactly what changed between the failed run and the successful one.
|
|
144
|
+
|
|
83
145
|
## What You Get
|
|
84
146
|
|
|
85
147
|
- **Contracts** — Pydantic-based input/output validation at every node boundary
|
|
@@ -87,16 +149,36 @@ uv add stroma[deepagents] # DeepAgents adapter
|
|
|
87
149
|
- **Retry policies** — configurable retries with jittered backoff, per failure class or per node
|
|
88
150
|
- **Checkpointing** — async-first save and resume across crashes (in-memory or Redis)
|
|
89
151
|
- **Cost estimation** — model-aware USD cost tracking via `KNOWN_MODELS` and token/dollar/latency budgets
|
|
90
|
-
- **
|
|
152
|
+
- **Per-node timeouts** — configurable `node_timeouts` with `asyncio.wait_for`; timeouts are classified as recoverable and retried automatically
|
|
153
|
+
- **Parallel execution** — fan out work to concurrent nodes with `parallel()`, per-child contract validation, merged output, and full retry support
|
|
91
154
|
- **Node hooks** — async `on_node_start`, `on_node_success`, and `on_node_failure` callbacks
|
|
92
155
|
- **Shared context** — pass a mutable `context` dict through `RunConfig` to every node
|
|
93
156
|
- **Execution tracing** — full record of every node attempt, with diffing and JSON export
|
|
94
157
|
- **Per-run logging** — structured `LoggerAdapter` with `run_id` in every log line
|
|
95
158
|
- **Fluent builder API** — configure runners with chained `.with_budget()`, `.with_hooks()`, `.with_redis()`, etc.
|
|
96
159
|
- **LangGraph adapter** — apply contracts to existing LangGraph graphs
|
|
160
|
+
- **CrewAI adapter** — contract validation for CrewAI Flow methods
|
|
97
161
|
- **DeepAgents adapter** — contract validation and cost tracking for deepagents graphs
|
|
162
|
+
- **Universal reliability middleware** — `execute_step()` and `StromaStep` apply contracts, retries, cost tracking, and checkpointing to any async callable, independent of any framework
|
|
98
163
|
- **Framework-agnostic** — works with any async Python code, no framework lock-in
|
|
99
164
|
|
|
165
|
+
## Install
|
|
166
|
+
|
|
167
|
+
Requires **Python 3.12+**.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv add stroma
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Optional extras:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
uv add stroma[redis] # Redis-backed checkpointing
|
|
177
|
+
uv add stroma[langgraph] # LangGraph adapter
|
|
178
|
+
uv add stroma[crewai] # CrewAI adapter
|
|
179
|
+
uv add stroma[deepagents] # DeepAgents adapter
|
|
180
|
+
```
|
|
181
|
+
|
|
100
182
|
## Quick Examples
|
|
101
183
|
|
|
102
184
|
### Cost estimation
|
|
@@ -165,6 +247,17 @@ config = RunConfig(
|
|
|
165
247
|
)
|
|
166
248
|
```
|
|
167
249
|
|
|
250
|
+
### Per-node timeouts
|
|
251
|
+
|
|
252
|
+
Guard against hanging LLM calls with per-node timeouts. Timeouts raise `TimeoutError`, which is classified as recoverable and retried automatically:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
runner = StromaRunner.quick().with_node_timeouts({
|
|
256
|
+
"llm_call": 30_000, # 30 seconds
|
|
257
|
+
"embedding": 10_000, # 10 seconds
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
168
261
|
### Async checkpointing
|
|
169
262
|
|
|
170
263
|
The default store is now async. For distributed pipelines, use the async Redis store:
|
stroma-0.3.2/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Stroma
|
|
2
|
+
|
|
3
|
+
**dbt didn't replace your data warehouse. Stroma doesn't replace your agent framework.**
|
|
4
|
+
|
|
5
|
+
dbt gave you typed models, tested transformations, and documented lineage — a software engineering layer that worked regardless of which warehouse you were running. Stroma does the same thing for agent execution graphs: typed node contracts, formal failure classification, and cost-aware execution — portable across whatever orchestration framework you're building on.
|
|
6
|
+
|
|
7
|
+
The framework handles the graph. Stroma handles the guarantees.
|
|
8
|
+
|
|
9
|
+
## The problem it solves
|
|
10
|
+
|
|
11
|
+
LLM pipelines fail in ways that traditional software doesn't. A node returns malformed data and the error surfaces three steps later. A transient timeout kills a 20-minute run and you start over from scratch. Costs spiral past budget with no enforcement mechanism. Failures are silent until they're catastrophic.
|
|
12
|
+
|
|
13
|
+
Stroma gives you the building blocks to handle this — without locking you into a framework.
|
|
14
|
+
|
|
15
|
+
## See it in action
|
|
16
|
+
|
|
17
|
+
The scenario below is the kind that breaks raw LangGraph pipelines: a multi-step run that crashes midway, resumes from checkpoint, and gives you a diff of what changed between the failed and successful run.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from stroma import (
|
|
23
|
+
AsyncInMemoryStore,
|
|
24
|
+
CheckpointManager,
|
|
25
|
+
ContractRegistry,
|
|
26
|
+
NodeContract,
|
|
27
|
+
RunConfig,
|
|
28
|
+
StromaRunner,
|
|
29
|
+
stroma_node,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Document(BaseModel):
|
|
34
|
+
text: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Extracted(BaseModel):
|
|
38
|
+
entities: list[str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Summary(BaseModel):
|
|
42
|
+
entities: list[str]
|
|
43
|
+
count: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
registry = ContractRegistry()
|
|
47
|
+
store = AsyncInMemoryStore()
|
|
48
|
+
manager = CheckpointManager(store)
|
|
49
|
+
|
|
50
|
+
c1 = NodeContract(node_id="extract", input_schema=Document, output_schema=Extracted)
|
|
51
|
+
c2 = NodeContract(node_id="summarize", input_schema=Extracted, output_schema=Summary)
|
|
52
|
+
registry.register(c1)
|
|
53
|
+
registry.register(c2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@stroma_node("extract", c1)
|
|
57
|
+
async def extract(state: Document) -> dict:
|
|
58
|
+
return {"entities": state.text.split()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@stroma_node("summarize", c2)
|
|
62
|
+
async def summarize_failing(state: Extracted) -> dict:
|
|
63
|
+
raise TimeoutError("downstream API unavailable") # (1)!
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@stroma_node("summarize", c2)
|
|
67
|
+
async def summarize_fixed(state: Extracted) -> dict:
|
|
68
|
+
return {"entities": state.entities, "count": len(state.entities)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def main():
|
|
72
|
+
config1 = RunConfig(run_id="doc-run-1")
|
|
73
|
+
runner1 = StromaRunner(registry, manager, config1)
|
|
74
|
+
result1 = await runner1.run(
|
|
75
|
+
[extract, summarize_failing],
|
|
76
|
+
Document(text="Stroma adds reliability to agent pipelines"),
|
|
77
|
+
)
|
|
78
|
+
print(result1.status) # PARTIAL — extract checkpointed, summarize exhausted retries
|
|
79
|
+
|
|
80
|
+
config2 = RunConfig(run_id="doc-run-1", resume_from="summarize") # (2)!
|
|
81
|
+
runner2 = StromaRunner(registry, manager, config2)
|
|
82
|
+
result2 = await runner2.run(
|
|
83
|
+
[extract, summarize_fixed],
|
|
84
|
+
Document(text="Stroma adds reliability to agent pipelines"),
|
|
85
|
+
)
|
|
86
|
+
print(result2.status) # RESUMED — extract skipped, loaded from checkpoint
|
|
87
|
+
print(result2.final_state) # entities=[...] count=6
|
|
88
|
+
|
|
89
|
+
diffs = result1.trace.diff(result2.trace) # (3)!
|
|
90
|
+
for d in diffs:
|
|
91
|
+
print(d)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
# In a Jupyter notebook, replace the line above with: await main()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
1. `TimeoutError` is classified as `RECOVERABLE`. Stroma retries with jittered backoff. After exhausting retries, the run fails — but `extract`'s output is already checkpointed.
|
|
99
|
+
2. Same `run_id`, `resume_from="summarize"`. The runner loads `extract`'s checkpoint and skips re-running it entirely.
|
|
100
|
+
3. `diff()` compares both traces — node IDs, attempts, inputs, outputs, failure states — so you can see exactly what changed between the failed run and the successful one.
|
|
101
|
+
|
|
102
|
+
## What You Get
|
|
103
|
+
|
|
104
|
+
- **Contracts** — Pydantic-based input/output validation at every node boundary
|
|
105
|
+
- **Failure classification** — automatic categorization of errors as recoverable, terminal, or ambiguous
|
|
106
|
+
- **Retry policies** — configurable retries with jittered backoff, per failure class or per node
|
|
107
|
+
- **Checkpointing** — async-first save and resume across crashes (in-memory or Redis)
|
|
108
|
+
- **Cost estimation** — model-aware USD cost tracking via `KNOWN_MODELS` and token/dollar/latency budgets
|
|
109
|
+
- **Per-node timeouts** — configurable `node_timeouts` with `asyncio.wait_for`; timeouts are classified as recoverable and retried automatically
|
|
110
|
+
- **Parallel execution** — fan out work to concurrent nodes with `parallel()`, per-child contract validation, merged output, and full retry support
|
|
111
|
+
- **Node hooks** — async `on_node_start`, `on_node_success`, and `on_node_failure` callbacks
|
|
112
|
+
- **Shared context** — pass a mutable `context` dict through `RunConfig` to every node
|
|
113
|
+
- **Execution tracing** — full record of every node attempt, with diffing and JSON export
|
|
114
|
+
- **Per-run logging** — structured `LoggerAdapter` with `run_id` in every log line
|
|
115
|
+
- **Fluent builder API** — configure runners with chained `.with_budget()`, `.with_hooks()`, `.with_redis()`, etc.
|
|
116
|
+
- **LangGraph adapter** — apply contracts to existing LangGraph graphs
|
|
117
|
+
- **CrewAI adapter** — contract validation for CrewAI Flow methods
|
|
118
|
+
- **DeepAgents adapter** — contract validation and cost tracking for deepagents graphs
|
|
119
|
+
- **Universal reliability middleware** — `execute_step()` and `StromaStep` apply contracts, retries, cost tracking, and checkpointing to any async callable, independent of any framework
|
|
120
|
+
- **Framework-agnostic** — works with any async Python code, no framework lock-in
|
|
121
|
+
|
|
122
|
+
## Install
|
|
123
|
+
|
|
124
|
+
Requires **Python 3.12+**.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
uv add stroma
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Optional extras:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv add stroma[redis] # Redis-backed checkpointing
|
|
134
|
+
uv add stroma[langgraph] # LangGraph adapter
|
|
135
|
+
uv add stroma[crewai] # CrewAI adapter
|
|
136
|
+
uv add stroma[deepagents] # DeepAgents adapter
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Quick Examples
|
|
140
|
+
|
|
141
|
+
### Cost estimation
|
|
142
|
+
|
|
143
|
+
Nodes can return token counts and a model name. Stroma computes USD cost automatically from built-in pricing data:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
@runner.node("summarize", input=DocInput, output=Summary)
|
|
147
|
+
async def summarize(state: DocInput) -> tuple:
|
|
148
|
+
# call your LLM here...
|
|
149
|
+
return ({"text": response}, input_tokens, output_tokens, "gpt-4o")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Parallel execution
|
|
153
|
+
|
|
154
|
+
Run independent nodes concurrently and merge their outputs:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from stroma import parallel
|
|
158
|
+
|
|
159
|
+
result = await runner.run(
|
|
160
|
+
[parallel(fetch_metadata, fetch_embeddings), merge_node],
|
|
161
|
+
initial_state,
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Node hooks
|
|
166
|
+
|
|
167
|
+
Attach lifecycle callbacks to observe node execution:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from stroma import NodeHooks, RunConfig
|
|
171
|
+
|
|
172
|
+
async def on_start(run_id, node_id, input_dict):
|
|
173
|
+
print(f"Starting {node_id}")
|
|
174
|
+
|
|
175
|
+
config = RunConfig(hooks=NodeHooks(on_node_start=on_start))
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Shared context
|
|
179
|
+
|
|
180
|
+
Pass runtime configuration to nodes that accept a second argument:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@runner.node("enrich", input=Input, output=Output)
|
|
184
|
+
async def enrich(state: Input, ctx: dict) -> dict:
|
|
185
|
+
api_key = ctx["api_key"]
|
|
186
|
+
# ...
|
|
187
|
+
|
|
188
|
+
config = RunConfig(context={"api_key": "sk-..."})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Per-node retry policies
|
|
192
|
+
|
|
193
|
+
Override the global retry policy for specific nodes:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from stroma import FailureClass, FailurePolicy
|
|
197
|
+
|
|
198
|
+
config = RunConfig(
|
|
199
|
+
node_policies={
|
|
200
|
+
"flaky_node": {
|
|
201
|
+
FailureClass.RECOVERABLE: FailurePolicy(max_retries=5, backoff_seconds=2.0),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Per-node timeouts
|
|
208
|
+
|
|
209
|
+
Guard against hanging LLM calls with per-node timeouts. Timeouts raise `TimeoutError`, which is classified as recoverable and retried automatically:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
runner = StromaRunner.quick().with_node_timeouts({
|
|
213
|
+
"llm_call": 30_000, # 30 seconds
|
|
214
|
+
"embedding": 10_000, # 10 seconds
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Async checkpointing
|
|
219
|
+
|
|
220
|
+
The default store is now async. For distributed pipelines, use the async Redis store:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from stroma import RedisStore, CheckpointManager
|
|
224
|
+
|
|
225
|
+
store = RedisStore("redis://localhost:6379", ttl_seconds=7200)
|
|
226
|
+
manager = CheckpointManager(store)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The original synchronous Redis store is still available as `SyncRedisStore`.
|
|
230
|
+
|
|
231
|
+
## Documentation
|
|
232
|
+
|
|
233
|
+
Full documentation including a tutorial and API reference is available at the [docs site](https://jengroff.github.io/stroma).
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
uv sync --extra dev
|
|
239
|
+
uv run pytest tests/ -v --cov=stroma --cov-fail-under=85
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT
|
|
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stroma"
|
|
7
|
-
version = "0.2
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "The framework handles the graph. Stroma handles the guarantees."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
dependencies = [
|
|
12
|
+
"crewai>=1.13.0",
|
|
12
13
|
"pydantic>=2.0",
|
|
13
14
|
]
|
|
14
15
|
license = "MIT"
|
|
@@ -33,6 +34,7 @@ Issues = "https://github.com/jengroff/stroma/issues"
|
|
|
33
34
|
[project.optional-dependencies]
|
|
34
35
|
redis = ["redis>=5.0"]
|
|
35
36
|
langgraph = ["langgraph>=0.2"]
|
|
37
|
+
crewai = ["crewai>=0.70"]
|
|
36
38
|
deepagents = ["deepagents>=0.4.0", "langgraph>=0.2"]
|
|
37
39
|
dev = ["pytest", "pytest-asyncio>=0.23", "pytest-cov", "ruff", "ty"]
|
|
38
40
|
docs = ["mkdocs>=1.6.1", "mkdocs-material", "mkdocstrings[python]"]
|
|
@@ -7,15 +7,25 @@ from stroma.checkpoint import (
|
|
|
7
7
|
RedisStore,
|
|
8
8
|
SyncRedisStore,
|
|
9
9
|
)
|
|
10
|
-
from stroma.contracts import
|
|
10
|
+
from stroma.contracts import (
|
|
11
|
+
BoundaryValidator,
|
|
12
|
+
ContractRegistry,
|
|
13
|
+
ContractViolation,
|
|
14
|
+
NodeContract,
|
|
15
|
+
StepContract,
|
|
16
|
+
StepViolation,
|
|
17
|
+
)
|
|
11
18
|
from stroma.cost import (
|
|
12
19
|
KNOWN_MODELS,
|
|
13
20
|
BudgetExceeded,
|
|
14
21
|
CostTracker,
|
|
15
22
|
ExecutionBudget,
|
|
23
|
+
FallbackPolicy,
|
|
16
24
|
ModelHint,
|
|
17
25
|
NodeUsage,
|
|
26
|
+
StepUsage,
|
|
18
27
|
estimate_cost_usd,
|
|
28
|
+
resolve_model,
|
|
19
29
|
)
|
|
20
30
|
from stroma.failures import (
|
|
21
31
|
Classifier,
|
|
@@ -23,13 +33,15 @@ from stroma.failures import (
|
|
|
23
33
|
FailurePolicy,
|
|
24
34
|
NodeContext,
|
|
25
35
|
RetryBudget,
|
|
36
|
+
StepContext,
|
|
26
37
|
classify,
|
|
27
38
|
default_policy_map,
|
|
28
39
|
)
|
|
29
|
-
from stroma.
|
|
40
|
+
from stroma.middleware import ReliabilityContext, StromaStep, execute_step
|
|
41
|
+
from stroma.runner import NodeHooks, RunConfig, StepHooks, StromaRunner, parallel, stroma_node, stroma_step
|
|
30
42
|
from stroma.trace import ExecutionResult, ExecutionTrace, RunStatus, TraceEvent
|
|
31
43
|
|
|
32
|
-
__version__ = "0.2
|
|
44
|
+
__version__ = "0.3.2"
|
|
33
45
|
|
|
34
46
|
__all__ = [
|
|
35
47
|
"KNOWN_MODELS",
|
|
@@ -48,6 +60,7 @@ __all__ = [
|
|
|
48
60
|
"ExecutionTrace",
|
|
49
61
|
"FailureClass",
|
|
50
62
|
"FailurePolicy",
|
|
63
|
+
"FallbackPolicy",
|
|
51
64
|
"InMemoryStore",
|
|
52
65
|
"ModelHint",
|
|
53
66
|
"NodeContext",
|
|
@@ -55,15 +68,25 @@ __all__ = [
|
|
|
55
68
|
"NodeHooks",
|
|
56
69
|
"NodeUsage",
|
|
57
70
|
"RedisStore",
|
|
71
|
+
"ReliabilityContext",
|
|
58
72
|
"RetryBudget",
|
|
59
73
|
"RunConfig",
|
|
60
74
|
"RunStatus",
|
|
75
|
+
"StepContext",
|
|
76
|
+
"StepContract",
|
|
77
|
+
"StepHooks",
|
|
78
|
+
"StepUsage",
|
|
79
|
+
"StepViolation",
|
|
61
80
|
"StromaRunner",
|
|
81
|
+
"StromaStep",
|
|
62
82
|
"SyncRedisStore",
|
|
63
83
|
"TraceEvent",
|
|
64
84
|
"classify",
|
|
65
85
|
"default_policy_map",
|
|
66
86
|
"estimate_cost_usd",
|
|
87
|
+
"execute_step",
|
|
67
88
|
"parallel",
|
|
89
|
+
"resolve_model",
|
|
68
90
|
"stroma_node",
|
|
91
|
+
"stroma_step",
|
|
69
92
|
]
|
|
@@ -7,3 +7,7 @@ with contextlib.suppress(ImportError):
|
|
|
7
7
|
with contextlib.suppress(ImportError):
|
|
8
8
|
from stroma.adapters.deepagents import DeepAgentsAdapter as DeepAgentsAdapter
|
|
9
9
|
from stroma.adapters.deepagents import stroma_deepagents_node as stroma_deepagents_node
|
|
10
|
+
|
|
11
|
+
with contextlib.suppress(ImportError):
|
|
12
|
+
from stroma.adapters.crewai import CrewAIAdapter as CrewAIAdapter
|
|
13
|
+
from stroma.adapters.crewai import stroma_crewai_step as stroma_crewai_step
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from stroma.contracts import NodeContract
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from stroma.middleware import ReliabilityContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class FrameworkAdapter(Protocol):
|
|
14
|
+
"""Protocol for graph-based framework adapters (LangGraph, CrewAI Flows).
|
|
15
|
+
|
|
16
|
+
Implementations provide two capabilities: wrapping an entire framework
|
|
17
|
+
graph with contract validation, and producing a decorator that attaches
|
|
18
|
+
stroma metadata to individual node functions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def wrap(self, graph: Any) -> Any:
|
|
22
|
+
"""Attach contract validation to a framework graph or flow instance."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def node_decorator(self, node_id: str, contract: NodeContract) -> Callable[..., Any]:
|
|
26
|
+
"""Return a decorator that attaches stroma metadata to a framework node function."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class StepInterceptor(Protocol):
|
|
32
|
+
"""Protocol for adapters that wrap individual step functions with reliability middleware.
|
|
33
|
+
|
|
34
|
+
Unlike `FrameworkAdapter` (which discovers and wraps nodes on a graph
|
|
35
|
+
object), a `StepInterceptor` wraps one function at a time. This is the
|
|
36
|
+
right shape for paradigms where there is no single graph object to wrap
|
|
37
|
+
— e.g. composing steps in a notebook, a custom orchestrator, or a
|
|
38
|
+
framework that doesn't expose an inspectable graph.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def wrap_step(
|
|
42
|
+
self, step_id: str, func: Callable[..., Any], contract: NodeContract | None = None
|
|
43
|
+
) -> Callable[..., Any]:
|
|
44
|
+
"""Wrap *func* with reliability instrumentation for the given *step_id*."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class LoopAdapter(Protocol):
|
|
50
|
+
"""Protocol for agentic-loop paradigms (Claude Agent SDK, OpenAI Agents SDK).
|
|
51
|
+
|
|
52
|
+
In an agentic loop the model decides what to do next via tool calls.
|
|
53
|
+
The adapter intercepts tool call boundaries to apply contract validation,
|
|
54
|
+
cost tracking, and failure handling.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def on_tool_call(self, tool_name: str, args: dict[str, Any], ctx: "ReliabilityContext") -> dict[str, Any]:
|
|
58
|
+
"""Intercept an outbound tool call before execution."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def on_tool_result(self, tool_name: str, result: Any, ctx: "ReliabilityContext") -> Any:
|
|
62
|
+
"""Intercept a tool result after execution."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class TurnAdapter(Protocol):
|
|
68
|
+
"""Protocol for conversation-driven paradigms (AutoGen).
|
|
69
|
+
|
|
70
|
+
In a conversation-driven system, agents exchange messages in turns.
|
|
71
|
+
The adapter intercepts turn boundaries to apply reliability primitives.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def on_turn_start(self, turn_id: str, message: Any, ctx: "ReliabilityContext") -> Any:
|
|
75
|
+
"""Intercept the start of a conversation turn."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def on_turn_end(self, turn_id: str, result: Any, ctx: "ReliabilityContext") -> Any:
|
|
79
|
+
"""Intercept the end of a conversation turn."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_state_dict(state: Any) -> dict[str, Any]:
|
|
84
|
+
"""Convert a framework state object to a plain dict.
|
|
85
|
+
|
|
86
|
+
Supports dicts, Pydantic models, objects with a `.dict()` method,
|
|
87
|
+
and plain objects with `__dict__`. Raises `TypeError` if the state
|
|
88
|
+
cannot be converted.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(state, dict):
|
|
91
|
+
return dict(state)
|
|
92
|
+
if isinstance(state, BaseModel):
|
|
93
|
+
return state.model_dump()
|
|
94
|
+
if hasattr(state, "dict"):
|
|
95
|
+
result = state.dict()
|
|
96
|
+
if isinstance(result, dict):
|
|
97
|
+
return result
|
|
98
|
+
if hasattr(state, "__dict__"):
|
|
99
|
+
return {k: v for k, v in vars(state).items() if not k.startswith("_")}
|
|
100
|
+
raise TypeError("Unable to extract state dict from state object")
|