Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

### OpenArmature is a workflow framework for LLM pipelines and tool-calling agents.

Typed state, compile-time topology checks, and observability and crash-safe checkpoints are baked into the engine. The graph layer itself has no concept of LLMs or tools, so the same primitives drive deterministic ETL pipelines and tool-calling agents alike.
Typed state, compile-time topology checks, observability, and crash-safe checkpoints are baked into the engine. The graph layer itself has no concept of LLMs or tools, so the same primitives drive deterministic ETL pipelines and tool-calling agents alike.

This Python package is the reference implementation. The behavioral contract is specified in [openarmature-spec](https://github.com/LunarCommand/openarmature-spec) and verified by conformance fixtures.

Expand Down Expand Up @@ -187,8 +187,8 @@ A few things to notice:
## Next steps

- **Quickstart**: build your first graph end-to-end. [openarmature.ai/getting-started](https://openarmature.ai/getting-started/)
- **Concepts**: typed state, reducers, composition, fan-out, checkpointing, observability. [openarmature.ai/concepts](https://openarmature.ai/concepts/)
- **Concepts**: typed state, reducers, graphs, composition, fan-out, parallel branches, LLMs, prompts, observability, checkpointing. [openarmature.ai/concepts](https://openarmature.ai/concepts/)
- **Model Providers**: implement the Provider Protocol for a custom LLM backend. [openarmature.ai/model-providers/authoring](https://openarmature.ai/model-providers/authoring/)
- **API reference**: auto-generated from docstrings. [openarmature.ai/reference](https://openarmature.ai/reference/)
- **Examples**: runnable demos. [openarmature-python/examples/](https://github.com/LunarCommand/openarmature-python/tree/main/examples)
- **Examples**: ten runnable demos with walk-throughs. [openarmature.ai/examples](https://openarmature.ai/examples/) (source at [./examples/](./examples/))
- **Spec**: behavioral contract this implementation conforms to. [LunarCommand/openarmature-spec](https://github.com/LunarCommand/openarmature-spec)
14 changes: 14 additions & 0 deletions docs/concepts/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,17 @@ the shape or it doesn't; the type checker verifies at use sites. If
you have Java instincts ("where's the `implements` keyword?"), reach
for TypeScript or Go interface instincts instead; that's the same
family.

## Related composition primitives

Subgraphs run once per outer-graph entry into them. Two related
primitives run subgraphs multiple times or in parallel; both use
the same projection machinery at their boundaries.

- [Fan-out](fan-out.md): dispatch N copies of *one* compiled subgraph
against an input collection. Use when you have a list of similar
items to process independently.
- [Parallel branches](parallel-branches.md): dispatch M *heterogeneous*
subgraphs concurrently against the same parent state, each with its
own state schema and (optional) middleware. Use when several
independent analyses share a single input.
41 changes: 17 additions & 24 deletions docs/concepts/fan-out.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,29 @@ A fan-out can dispatch instances driven by a list in state
**`items_field` mode**: one instance per item in a parent list field:

```python
from openarmature.graph import FanOutConfig, FanOutNode

scrape_all = FanOutNode(
name="scrape_all",
config=FanOutConfig(
subgraph=scrape_subgraph, # CompiledGraph[ScrapeState]
items_field="urls", # parent list field, one instance per item
item_field="url", # subgraph field that receives each item
collect_field="content", # subgraph field whose value is collected
target_field="contents", # parent list field that receives the collection
concurrency=4,
error_policy="fail_fast", # or "collect"
on_empty="raise", # or "noop"
),
builder.add_fan_out_node(
"scrape_all",
subgraph=scrape_subgraph, # CompiledGraph[ScrapeState]
items_field="urls", # parent list field, one instance per item
item_field="url", # subgraph field that receives each item
collect_field="content", # subgraph field whose value is collected
target_field="contents", # parent list field that receives the collection
concurrency=4,
error_policy="fail_fast", # or "collect"
on_empty="raise", # or "noop"
)
builder.add_node("scrape_all", scrape_all)
```

**`count` mode**: fixed-or-dynamic instance count, no list field:

```python
fan_out = FanOutNode(
name="sample",
config=FanOutConfig(
subgraph=sample_subgraph,
count=8, # int or callable: state -> int
collect_field="reading",
target_field="readings",
concurrency=4,
),
builder.add_fan_out_node(
"sample",
subgraph=sample_subgraph,
count=8, # int or callable: state -> int
collect_field="reading",
target_field="readings",
concurrency=4,
)
```

Expand Down
11 changes: 11 additions & 0 deletions docs/concepts/graphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ The methods you'll use:
- **`.add_subgraph_node(name, compiled, projection=None)`**: register
a compiled graph as a node inside this graph (see
[Composition](composition.md)).
- **`.add_fan_out_node(name, subgraph=..., ...)`**: dispatch N copies
of one subgraph in parallel (see [Fan-out](fan-out.md)).
- **`.add_parallel_branches_node(name, branches=...)`**: dispatch M
heterogeneous subgraphs concurrently (see
[Parallel branches](parallel-branches.md)).
- **`.with_checkpointer(checkpointer)`**: wire a `Checkpointer`; the
engine saves a record after every `completed` event (see
[Checkpointing](checkpointing.md)).
- **`.with_state_migration(from_version, to_version, migrate)`**:
register one edge of the state-migration chain used when resuming
an older saved invocation (see [Checkpointing](checkpointing.md)).
- **`.set_entry(name)`**: declare where execution begins.
- **`.compile()`**: validate and return `CompiledGraph`.

Expand Down
7 changes: 5 additions & 2 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ the framework, or jump to whichever concept you need.
heterogeneous subgraphs concurrently with per-branch state schemas
and middleware.
- [LLMs](llms.md): how LLM calls fit into nodes, structured output,
routing on parsed fields, errors at the LLM boundary.
multimodal content blocks, tool definitions, routing on parsed
fields, errors at the LLM boundary.
- [Prompts](prompts.md): versioned templates, composite backends,
prompt-group observability propagation.
- [Observability](observability.md): node-boundary hooks, OTel mapping,
log correlation.
- [Checkpointing](checkpointing.md): save state at each node boundary,
resume from a prior point.
resume from a prior point, schema migration across versions.

If you're brand-new, [Quickstart](../getting-started/index.md) is the
faster entry; under a minute to a running graph. Come back here when
Expand Down
60 changes: 58 additions & 2 deletions docs/concepts/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,58 @@ on every object. Pydantic-derived schemas may need `model_config =
ConfigDict(extra="forbid")` on the class to get the
`additionalProperties: false` in the generated JSON Schema.

## Tool calling

Beyond producing typed text, an LLM call can request work from local
Python functions and resume with their results. The wire shape is a
turn-based loop driven entirely from the same `complete()` call: the
model emits `tool_calls`, the caller dispatches them to local
functions, appends `ToolMessage` responses, and re-calls. The graph
engine has no special concept of tools; the loop fits as a
conditional-edge cycle.

```python
from openarmature.llm import Tool

lookup_mission = Tool(
name="lookup_mission",
description="Look up factual records for a named lunar mission.",
parameters={
"type": "object",
"properties": {
"name": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
)

response = await provider.complete(messages, tools=[lookup_mission, ...])
```

When the model decides to use one or more tools, the response carries
`finish_reason="tool_calls"` and `response.message.tool_calls` is a
list of `ToolCall(id, name, arguments)` records. `arguments` is a
parsed dict whose shape matches the corresponding tool's `parameters`
schema. The single edge case where `arguments` is `None` is
`finish_reason="error"` for unparseable model output.

The caller dispatches each call to its local function, appends one
`ToolMessage(content=..., tool_call_id=...)` per call to the message
list, and re-calls. The `tool_call_id` field MUST match the
`ToolCall.id` the model emitted so the model can pair its requests
with the responses. The next turn either emits more `tool_calls` or
returns a normal assistant content message signaling completion.

Wiring the loop as a graph cycle: a `call_llm` node, a
`dispatch_tools` node that resolves calls and appends
`ToolMessage`s, a conditional edge from `call_llm` that routes back
to `call_llm` when `tool_calls` are present and forward to a
termination node when they aren't. A turn cap on the routing function
prevents runaway loops on a model that stays in tool-calling forever.
See [`09 - Tool use`](../examples/09-tool-use.md) for the runnable
shape.

## Content blocks (multimodal user messages)

User messages carry content in one of two shapes: a plain text string,
Expand Down Expand Up @@ -434,6 +486,10 @@ classifier won't do this for them.
- [API reference: `openarmature.llm`](../reference/llm.md) for the
full surface: message types, `Response`, `RuntimeConfig`, every
error class, validation helpers.
- [Examples: `00-hello-world`](https://github.com/LunarCommand/openarmature-python/tree/main/examples/00-hello-world)
for a runnable graph exercising both `response_schema` forms in one
- [Examples: 00 - Hello, world](../examples/00-hello-world.md) for a
runnable graph exercising both `response_schema` forms in one
pipeline.
- [Examples: 09 - Tool use](../examples/09-tool-use.md) for the
agent-loop pattern with two local tools.
- [Examples: 07 - Multimodal prompt](../examples/07-multimodal-prompt.md)
for content blocks alongside versioned prompts.
4 changes: 2 additions & 2 deletions docs/concepts/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ A walk-through:

- **`attempt_index`**: 0-based retry attempt counter. `0` for nodes
not wrapped by retry middleware; `1+` for retries. Retry middleware
may wrap transitively — a retry on a [parallel-branches
may wrap transitively. A retry on a [parallel-branches
branch](parallel-branches.md) or fan-out `instance_middleware`
re-runs the whole subgraph; events from inner nodes carry the
wrapping retry's attempt counter.
Expand All @@ -148,7 +148,7 @@ A walk-through:
- **`branch_name`**: populated on events from nodes inside a
[parallel-branches branch](parallel-branches.md), carrying the
branch's name as declared on the dispatcher. `None` outside.
Independent of `fan_out_index` both may be present simultaneously
Independent of `fan_out_index`; both may be present simultaneously
when a parallel-branches branch contains a fan-out (or a fan-out
instance contains a parallel-branches node). The combination
`(namespace, branch_name, fan_out_index, attempt_index, phase)`
Expand Down
28 changes: 14 additions & 14 deletions docs/concepts/parallel-branches.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ insertion order.

Sibling to [fan-out](fan-out.md) (same `for each thing, do work in
parallel` shape), but the *thing* is different per branch: a research
subgraph, a categorize subgraph, a sentiment subgraph each with its
own state schema, its own middleware, its own observer events
subgraph, a categorize subgraph, a sentiment subgraph (each with its
own state schema, its own middleware, its own observer events),
running in parallel and joining their results into one parent state.

## When to reach for parallel branches
Expand Down Expand Up @@ -56,14 +56,14 @@ builder.add_parallel_branches_node(
Each branch's `subgraph` is a compiled graph; `inputs` and `outputs`
mirror the explicit projection shape from
[composition](composition.md#explicitmapping-declarative). The
branches dict's key is the branch name used as the branch identity
branches dict's key is the branch name, used as the branch identity
on observer events (see [observability](observability.md)) and in
the per-branch error records that `error_policy: "collect"`
produces.

## Per-branch state, inputs and outputs

Each branch runs its own subgraph against its own state heterogeneous
Each branch runs its own subgraph against its own state; heterogeneous
schemas are explicit. Subgraph fields named in `inputs` are seeded
from the parent's corresponding field at branch entry; other subgraph
fields take their schema defaults. At branch exit, only the parent
Expand All @@ -72,7 +72,7 @@ branch's final state is discarded.

When two branches contribute to the same parent field, the parent's
reducer for that field applies both values in **branch insertion
order** first the branch declared first in the `branches` dict,
order**: first the branch declared first in the `branches` dict,
then the next, and so on. This is deterministic regardless of which
branch's inner work finishes first.

Expand All @@ -83,7 +83,7 @@ branch's inner work finishes first.
`ParallelBranchesBranchFailed` (a `NodeException` subtype) carrying
the failing `branch_name` and the original cause as `__cause__`.
`recoverable_state` is the parent's snapshot at the moment the
dispatcher entered — **no buffered branch contributions are
dispatcher entered. **No buffered branch contributions are
applied**, including those of branches that successfully completed
before the failure. Buffer-and-apply semantics: contributions are
held until every branch finishes, then either all apply (success)
Expand All @@ -100,7 +100,7 @@ branch's inner work finishes first.

## Branch middleware

Each `BranchSpec` accepts a `middleware` tuple middlewares that
Each `BranchSpec` accepts a `middleware` tuple of middlewares that
wrap that branch's whole subgraph invocation as a unit. Retry
middleware on a branch retries the **whole branch**: a fresh
subgraph invocation each time, fresh inner-node execution. The
Expand All @@ -109,7 +109,7 @@ inner nodes (per graph-engine §6 v0.16.1), so observer events
inside the branch correctly show `attempt_index` ticking across
retries.

Branch middleware is independent across branches branch A may
Branch middleware is independent across branches: branch A may
have `[retry, timing]`; branch B may have `[]`; branch C may have
some custom breaker. Each branch's chain composes in isolation.

Expand All @@ -118,15 +118,15 @@ some custom breaker. Each branch's chain composes in isolation.
Parallel branches compose with the rest of the engine the way
subgraphs and fan-outs do:

- A branch's subgraph can itself contain a fan-out node inner-node
- A branch's subgraph can itself contain a fan-out node; inner-node
events inside that fan-out carry **both** `branch_name` (this
branch) and `fan_out_index` (the instance within this branch).
The two fields are independent.
- The parallel-branches node itself can be invoked from inside a
fan-out instance inner events then carry the outer fan-out's
fan-out instance, and inner events then carry the outer fan-out's
`fan_out_index` and the inner branch's `branch_name`.
- Per-graph and per-node middleware on the parallel-branches node
wrap the dispatcher as a single unit one `started` event before
wrap the dispatcher as a single unit: one `started` event before
dispatch begins, one `completed` event after all branches finish
and fan-in lands. The parent's retry middleware retries the **whole
parallel-branches node**, not individual branches.
Expand All @@ -143,9 +143,9 @@ Per-branch progress is not individually persisted in v1.
- **Not the same as N copies of one subgraph.** If you want "run
this subgraph for each item in a list," reach for
[fan-out](fan-out.md).
- **Not a router.** A router is a conditional-edge pattern — pick
one branch based on state. Parallel branches runs *all* branches
concurrently.
- **Not a router.** A router is a conditional-edge pattern that
picks one branch based on state. Parallel branches runs *all*
branches concurrently.
- **Not a coordinator.** Branches don't communicate with each other
during execution; if branch B's work depends on branch A's
output, you want a linear pipeline (A → B), not parallel branches.
11 changes: 6 additions & 5 deletions docs/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ assert final.log == ["hello", "world"]

## Next

- [Concepts](../concepts/index.md): deeper on state, reducers,
projections, fan-out, subgraphs, observability.
- [Examples](https://github.com/LunarCommand/openarmature-python/tree/main/examples):
five runnable demos, each driving a local OpenAI-compatible LLM
endpoint to do real work.
- [Concepts](../concepts/index.md): deeper on state, reducers, graphs,
composition, fan-out, parallel branches, LLMs, prompts,
observability, checkpointing.
- [Examples](../examples/index.md): ten runnable demos with
walk-throughs, each driving an OpenAI-compatible LLM endpoint to
do real work.
- [API reference](../reference/index.md): auto-generated from docstrings.