Skip to content

Commit 233bb59

Browse files
docs: rework README to pydantic-ai-style landing
Replace the minimal install/quick-example shape with a landing README modeled on pydantic-ai's structure. New sections, in order: - Header with badges (CI, PyPI, spec version, Python versions, license) — the spec badge reads tool.openarmature.spec_version from pyproject.toml on main via Shields.io's dynamic TOML endpoint, so the badge auto-updates when the spec pin moves. - Documentation pointer to openarmature.ai. - Hero copy: workflow framework framing; the graph engine is content-agnostic so the same primitives drive ETL and agents. - Install: uv add + pip install, core + [otel] extras. - Why OpenArmature: nine bullets in framework-author voice covering frozen state, schema-validated merges, per-field reducer policy, subgraph projection seams, compile-time topology (six categories), content-agnostic engine, determinism contract, synchronous checkpoints, TracerProvider isolation. - Hello World: three-node classification pipeline showing three reducer policies on one state class, conditional routing as a pure state function, and an observer (no LLM setup needed). - Next steps: Quickstart / Concepts / Model Providers / API reference / Examples / Spec links to openarmature.ai pages. Drafted on the discuss-readme-rework coord thread; spec-side sign-off on file 03 of that thread.
1 parent 8a6d168 commit 233bb59

1 file changed

Lines changed: 169 additions & 35 deletions

File tree

README.md

Lines changed: 169 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1-
# openarmature-python
1+
# openarmature
22

3-
Python reference implementation of [OpenArmature](https://github.com/LunarCommand/openarmature-spec) — a workflow framework for LLM pipelines and tool-calling agents.
3+
[![CI](https://github.com/LunarCommand/openarmature-python/actions/workflows/ci.yml/badge.svg)](https://github.com/LunarCommand/openarmature-python/actions/workflows/ci.yml)
4+
[![PyPI](https://img.shields.io/pypi/v/openarmature.svg)](https://pypi.org/project/openarmature/)
5+
[![spec](https://img.shields.io/badge/dynamic/toml?url=https://raw.githubusercontent.com/LunarCommand/openarmature-python/main/pyproject.toml&query=%24.tool.openarmature.spec_version&label=spec&color=9D4EDD)](https://github.com/LunarCommand/openarmature-spec)
6+
[![Python versions](https://img.shields.io/pypi/pyversions/openarmature.svg)](https://pypi.org/project/openarmature/)
7+
[![License](https://img.shields.io/pypi/l/openarmature.svg)](https://github.com/LunarCommand/openarmature-python/blob/main/LICENSE)
48

5-
**Status:** alpha. Implemented against spec v0.10.0.
9+
**Documentation:** [openarmature.ai](https://openarmature.ai)
10+
11+
OpenArmature is a workflow framework for LLM pipelines and tool-calling
12+
agents — typed state, compile-time topology checks, and observability
13+
and crash-safe checkpoints baked into the engine. The graph layer
14+
itself has no concept of LLMs or tools, so the same primitives drive
15+
deterministic ETL pipelines and tool-calling agents alike.
16+
17+
This Python package is the reference implementation; the behavioral
18+
contract is specified in [openarmature-spec](https://github.com/LunarCommand/openarmature-spec)
19+
and verified by conformance fixtures.
620

721
## Install
822

@@ -14,57 +28,177 @@ pip install openarmature
1428
pip install 'openarmature[otel]'
1529
```
1630

17-
For editable local development:
18-
19-
```bash
20-
uv add --editable /path/to/openarmature-python
21-
```
22-
23-
## Quick example
31+
## Why OpenArmature
32+
33+
**State you can't accidentally mutate.**
34+
State schemas are frozen Pydantic models. Nodes return partial
35+
updates; the engine merges. The snapshot a node holds can't change
36+
mid-execution, and assignment into state raises rather than silently
37+
writing.
38+
39+
**Schema validation at every merge.**
40+
Fields outside the declared schema fail at the merge boundary instead
41+
of silently dropping. A node returning `{"plann": "..."}` (typo)
42+
raises `StateValidationError` immediately, not three nodes downstream
43+
when the field is read and doesn't exist.
44+
45+
**Merge policy on the schema, not the call site.**
46+
Each state field declares its reducer (`last_write_wins`, `append`,
47+
`merge`, or a user-defined callable) as part of the schema. Two nodes
48+
writing the same field compose via the field's policy — once,
49+
declaratively, instead of duplicated across call sites.
50+
51+
**Subgraphs compose with explicit data seams.**
52+
Subgraphs run against their own state schema with `inputs` (additive
53+
— opt in to share parent fields) and `outputs` (replacement — name
54+
exactly what comes back) mappings. Parent fields don't leak in by
55+
accident; subgraph fields don't slip out unless declared.
56+
57+
**Bad graphs don't compile.**
58+
Dangling edges, unreachable nodes, conflicting reducers, no declared
59+
entry, mappings to undeclared fields, multiple outgoing edges from
60+
one node — six categories of structural error all fail at
61+
`.compile()`, not at runtime mid-execution. The graph either
62+
constructs cleanly or it doesn't reach `invoke()`.
63+
64+
**The graph engine has no concept of LLMs or tools.**
65+
Validation, retry, recovery, structured output — those are
66+
node-internal or middleware concerns. The same engine runs
67+
deterministic ETL pipelines and tool-calling agents; the topology
68+
layer doesn't pick a side.
69+
70+
**Determinism is a contract.**
71+
Same input, same node implementations, same edge functions → same
72+
final state and same observed node-execution order. The spec mandates
73+
it; conformance fixtures verify it across every implementation.
74+
Replay an audit run and get byte-identical state.
75+
76+
**Checkpoint saves are synchronous-by-contract.**
77+
The engine awaits each save before advancing — a crash immediately
78+
after a `completed` event cannot have lost the corresponding write.
79+
Resume mints a fresh `invocation_id` (audit trail) while preserving
80+
`correlation_id` (cross-system join key), so a recovered run is
81+
traceable as a new attempt without losing the thread to the original
82+
request.
83+
84+
**Observability that doesn't double-export.**
85+
The OpenTelemetry mapping mandates a private `TracerProvider`
86+
preventing the trap where global-provider auto-instrumentation
87+
libraries (OpenInference, Langfuse v3, etc.) emit duplicate spans
88+
alongside the framework's. Your spans flow exactly where you point
89+
them; no surprise fan-out to vendor backends you didn't configure.
90+
91+
## Hello World
92+
93+
A three-node classification pipeline. Three different reducer
94+
policies on one state class, conditional routing as a pure function
95+
of state, and an observer that sees every node boundary — without
96+
any LLM setup. Requires Python ≥ 3.12.
2497

2598
```python
2699
import asyncio
27100
from typing import Annotated
28101

102+
from openarmature.graph import (
103+
END,
104+
GraphBuilder,
105+
NodeEvent,
106+
State,
107+
append,
108+
merge,
109+
)
29110
from pydantic import Field
30111

31-
from openarmature.graph import END, GraphBuilder, State, append
32112

113+
class PipelineState(State):
114+
query: str # last_write_wins (default)
115+
classification: str = "" # last_write_wins
116+
sources: Annotated[list[str], append] = Field( # appends across writes
117+
default_factory=list
118+
)
119+
metadata: Annotated[dict[str, str], merge] = Field( # merges across writes
120+
default_factory=dict
121+
)
33122

34-
class S(State):
35-
log: Annotated[list[str], append] = Field(default_factory=list)
36123

124+
async def classify(state: PipelineState) -> dict:
125+
decision = "research" if "?" in state.query else "summarize"
126+
return {
127+
"classification": decision,
128+
"metadata": {"classified_by": "rule"},
129+
}
37130

38-
async def hello(_state: S) -> dict[str, list[str]]:
39-
return {"log": ["hello"]}
40131

132+
async def research(state: PipelineState) -> dict:
133+
return {
134+
"sources": ["wikipedia", "arxiv"],
135+
"metadata": {"tool": "search"},
136+
}
41137

42-
async def world(_state: S) -> dict[str, list[str]]:
43-
return {"log": ["world"]}
44138

139+
async def summarize(state: PipelineState) -> dict:
140+
return {
141+
"sources": ["cache"],
142+
"metadata": {"tool": "summarizer"},
143+
}
45144

46-
graph = (
47-
GraphBuilder(S)
48-
.add_node("hello", hello)
49-
.add_node("world", world)
50-
.add_edge("hello", "world")
51-
.add_edge("world", END)
52-
.set_entry("hello")
53-
.compile()
54-
)
55145

56-
final = asyncio.run(graph.invoke(S()))
57-
print(final.log) # ['hello', 'world']
58-
```
146+
def route(state: PipelineState) -> str:
147+
return state.classification
59148

60-
See `tests/conformance/` for fixtures covering conditional routing, subgraph composition, and the canonical compile- and runtime-error categories.
61149

62-
## Spec
150+
async def trace(event: NodeEvent) -> None:
151+
if event.phase == "completed" and event.error is None:
152+
print(f"{event.node_name}: sources={event.post_state.sources}")
63153

64-
The spec lives in [`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec) and is pinned here as a git submodule. Conformance fixtures from the spec are exercised by `tests/conformance/`.
65154

66-
The pinned spec version is recorded in `tool.openarmature.spec_version` (in `pyproject.toml`) and exposed as `openarmature.__spec_version__`.
155+
graph = (
156+
GraphBuilder(PipelineState)
157+
.add_node("classify", classify)
158+
.add_node("research", research)
159+
.add_node("summarize", summarize)
160+
.add_conditional_edge("classify", route)
161+
.add_edge("research", END)
162+
.add_edge("summarize", END)
163+
.set_entry("classify")
164+
.compile()
165+
)
166+
graph.attach_observer(trace)
67167

68-
## License
168+
final = asyncio.run(graph.invoke(PipelineState(query="what is RAG?")))
169+
# classify: sources=[]
170+
# research: sources=['wikipedia', 'arxiv']
171+
```
69172

70-
Apache-2.0.
173+
A few things to notice in ~40 lines:
174+
175+
- **Three reducer policies on one state schema.** `query` and
176+
`classification` get the default `last_write_wins`. `sources` is
177+
`Annotated[list[str], append]` — successive writes concatenate.
178+
`metadata` is `Annotated[dict[str, str], merge]` — successive
179+
writes shallow-merge. The merge policy lives on the schema, once.
180+
- **Conditional routing as a state function.** `route` reads
181+
`state.classification` and returns a node name. The graph engine
182+
doesn't care that this happens to be deterministic; it would
183+
accept an LLM-driven router with the same shape.
184+
- **Observer sees both phases.** `trace` filters to `completed` events
185+
for brevity; the engine also delivers `started` events.
186+
- **The graph either compiles or it doesn't.** Remove `.set_entry()`
187+
and `.compile()` raises `NoDeclaredEntry` before `invoke()` runs.
188+
189+
## Next steps
190+
191+
- **Quickstart** — build your first graph end-to-end:
192+
[openarmature.ai/getting-started](https://openarmature.ai/getting-started/)
193+
- **Concepts** — typed state, reducers, composition, fan-out,
194+
checkpointing, observability:
195+
[openarmature.ai/concepts](https://openarmature.ai/concepts/)
196+
- **Model Providers** — implement the Provider Protocol for a
197+
custom LLM backend:
198+
[openarmature.ai/model-providers/authoring](https://openarmature.ai/model-providers/authoring/)
199+
- **API reference** — auto-generated from docstrings:
200+
[openarmature.ai/reference](https://openarmature.ai/reference/)
201+
- **Examples** — runnable demos:
202+
[openarmature-python/examples/](https://github.com/LunarCommand/openarmature-python/tree/main/examples)
203+
- **Spec** — behavioral contract this implementation conforms to:
204+
[LunarCommand/openarmature-spec](https://github.com/LunarCommand/openarmature-spec)

0 commit comments

Comments
 (0)