Skip to content

Commit f12a115

Browse files
Add Patterns docs section with 4 seed patterns
Add docs/patterns/, sibling to Concepts, seeded with four recipes from downstream usage: parameterized entry point, tool-dispatch- as-node, session-as-checkpoint-resume, and bypass-if-output-exists. Each page follows a problem / approach / snippet / when-right-and- when-not / cross-references structure. Patterns are user-level how-to recipes composing existing primitives, not framework contracts; new patterns can be added without spec coordination. Updates mkdocs.yml nav (Patterns between Concepts and Examples) and the llmstxt plugin sections. CHANGELOG notes the addition under Unreleased.
1 parent d923c3e commit f12a115

7 files changed

Lines changed: 482 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Patterns docs section** at `docs/patterns/`, sibling to Concepts. Seeded with four recipes drawn from downstream usage and proposal 0008's alternatives section: parameterized entry point, tool-dispatch-as-node, session-as-checkpoint-resume, and bypass-if-output-exists. Patterns are user-level how-to recipes composing existing primitives, not framework contracts; new patterns can be added without spec coordination. Each page follows a problem / approach / snippet / when-right-when-not / cross-references structure.
12+
913
### Notes
1014

1115
- **Pinned spec version bumped to v0.17.1.** Proposal 0019 (multi-provider wire-format extension) reframes llm-provider §8 as a catalog of wire-format mappings, with the existing OpenAI-compatible body nested under §8.1. Purely textual on the spec side — no behavioral change, no fixture changes. Code and doc references to §8.X updated to match the new structure (§8.1 → §8.1.1, §8.2 → §8.1.2, §8.3 → §8.1.3, §8.5.1 → §8.1.5.1, §8.1.1 → §8.1.1.1). All existing conformance fixtures continue to pass.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Bypass-if-output-exists
2+
3+
**Problem.** How do I skip a node whose external output already
4+
exists?
5+
6+
## Approach
7+
8+
A small custom [middleware](../concepts/middleware.md) wraps the
9+
node. Before calling `next_(state)`, the middleware checks "does
10+
my output already exist?" (a filesystem file, a database row, a
11+
content-addressable store entry). If yes, it returns the cached
12+
output as the partial update directly. If no, it calls `next_`
13+
and returns the result.
14+
15+
The node sees its normal `(state) → partial_update` contract.
16+
The middleware is the only thing that knows about idempotency;
17+
all callers of the node compose with it cleanly.
18+
19+
## Snippet
20+
21+
```python
22+
import os
23+
from collections.abc import Mapping
24+
from typing import Any
25+
from openarmature.graph import GraphBuilder, NextCall, State
26+
27+
28+
class BypassIfRendered:
29+
"""Skip the node if its rendered output already exists on disk."""
30+
31+
def __init__(self, output_field: str, key_field: str, root: str):
32+
self.output_field = output_field
33+
self.key_field = key_field
34+
self.root = root
35+
36+
async def __call__(
37+
self, state: Any, next_: NextCall
38+
) -> Mapping[str, Any]:
39+
key = getattr(state, self.key_field)
40+
path = f"{self.root}/{key}.bin"
41+
if os.path.exists(path):
42+
with open(path, "rb") as f:
43+
return {self.output_field: f.read()}
44+
partial = await next_(state)
45+
# ... persist partial[self.output_field] to path here, or
46+
# have the node itself write the file ...
47+
return partial
48+
49+
50+
class RenderState(State):
51+
scene_id: str
52+
rendered_frame: bytes = b""
53+
54+
55+
builder = (
56+
GraphBuilder(RenderState)
57+
.add_node(
58+
"render",
59+
render_frame_fn,
60+
middleware=[
61+
BypassIfRendered(
62+
output_field="rendered_frame",
63+
key_field="scene_id",
64+
root="./renders",
65+
)
66+
],
67+
)
68+
# ... rest of graph ...
69+
)
70+
```
71+
72+
The middleware composes with the framework's
73+
[four registration sites](../concepts/middleware.md): attach it
74+
per-node (as above), per-graph, per-branch, or
75+
per-fan-out-instance, depending on the scope of the bypass.
76+
77+
## When this is the right pattern
78+
79+
- The node's work is expensive and idempotent given the same key
80+
(rendering a frame, calling an external API with content-
81+
addressable output, downloading a file).
82+
- The "does it exist" check is cheap (a filesystem `stat`, a
83+
Redis `EXISTS`, a database key lookup).
84+
- You're OK with the node being skipped silently — the partial
85+
update returned by the middleware is indistinguishable from a
86+
successful node run.
87+
88+
## When it isn't
89+
90+
- The check itself is expensive enough that you'd rather just run
91+
the node. The cost model inverts; the pattern is wrong.
92+
- You need to *force* re-execution on demand (cache invalidation).
93+
Add a `force_rerun: bool` field on state that the middleware
94+
consults — but if you're doing that often, the bypass logic
95+
belongs in the node itself, gated on a state field, not in
96+
middleware.
97+
- The cached output's freshness depends on inputs the middleware
98+
can't see (downstream state, time-of-day, etc.). Use a
99+
dedicated caching layer instead of reimplementing cache
100+
invalidation in the middleware.
101+
102+
## Cross-references
103+
104+
- [Middleware](../concepts/middleware.md) — middleware shape, the
105+
four registration sites, composition.
106+
- Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/)
107+
108+
This pattern is explicitly called out in proposal 0008's
109+
*Alternatives considered* section as a userland recipe rather than
110+
spec'd behavior — this page is its canonical home.

docs/patterns/index.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Patterns
2+
3+
Recipes for things people keep asking the framework to do but
4+
that compose cleanly from existing primitives.
5+
6+
The split between [Concepts](../concepts/index.md) and Patterns is
7+
intentional: Concepts explain *what OpenArmature is* — typed state,
8+
nodes, edges, middleware, checkpointing, observers. Patterns
9+
explain *ways to use it* — opinionated shapes for common
10+
downstream questions like "how do I run an agent loop?" or "how do
11+
I skip work that's already been done?".
12+
13+
## When to read which
14+
15+
- You don't know what a `State` is, or how nodes and edges fit
16+
together → start with [Concepts](../concepts/index.md).
17+
- You know the primitives but you're asking "how do I do X with
18+
them?" → look here.
19+
20+
Patterns are user-level recipes, not framework contracts. New
21+
patterns can be added without spec coordination — they're how-to
22+
docs composing existing primitives.
23+
24+
## The catalog
25+
26+
- [Parameterized entry point](parameterized-entry-point.md)
27+
start the graph at an arbitrary node via state-driven routing.
28+
- [Tool-dispatch-as-node](tool-dispatch-as-node.md) — model an
29+
agent tool-call loop as a graph cycle.
30+
- [Session-as-checkpoint-resume](session-as-checkpoint-resume.md)
31+
carry multi-turn agent state across turns using the existing
32+
checkpointer.
33+
- [Bypass-if-output-exists](bypass-if-output-exists.md)
34+
short-circuit a node whose external output already exists, via
35+
middleware.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Parameterized entry point
2+
3+
**Problem.** How do I start the graph at an arbitrary node?
4+
5+
## Approach
6+
7+
You don't. Make the "entry point" a state-level parameter instead.
8+
A first router node passes through, and a
9+
[conditional edge](../concepts/composition.md) routes to wherever
10+
execution should begin. The graph stays a single graph; what
11+
differs across runs is which branch the conditional edge takes.
12+
13+
Combine with [checkpointing](../concepts/checkpointing.md) if you
14+
want resume-style behavior — skip nodes whose work is already
15+
captured in state.
16+
17+
## Snippet
18+
19+
```python
20+
from openarmature.graph import END, EndSentinel, GraphBuilder, State
21+
22+
23+
class MissionState(State):
24+
starting_stage: str = "plan" # "plan" | "execute" | "report"
25+
plan: str = ""
26+
execution_log: str = ""
27+
report: str = ""
28+
29+
30+
def route_from_starting_stage(s: MissionState) -> str | EndSentinel:
31+
return s.starting_stage
32+
33+
34+
def plan(s: MissionState) -> dict:
35+
return {
36+
"plan": "Apollo-style free-return trajectory.",
37+
"starting_stage": "execute",
38+
}
39+
40+
41+
def execute(s: MissionState) -> dict:
42+
return {"execution_log": "Burn complete. Trajectory nominal."}
43+
44+
45+
def report(s: MissionState) -> dict:
46+
return {"report": "Mission objectives met."}
47+
48+
49+
builder = (
50+
GraphBuilder(MissionState)
51+
.add_node("router", lambda s: {}) # passthrough; routes from state
52+
.add_node("plan", plan)
53+
.add_node("execute", execute)
54+
.add_node("report", report)
55+
.add_conditional_edge("router", route_from_starting_stage)
56+
.add_edge("plan", "execute")
57+
.add_edge("execute", "report")
58+
.add_edge("report", END)
59+
.set_entry("router")
60+
)
61+
graph = builder.compile()
62+
63+
# Start at the beginning:
64+
await graph.invoke(MissionState())
65+
66+
# Or skip straight to execute, with the plan already in state:
67+
await graph.invoke(MissionState(starting_stage="execute", plan="..."))
68+
```
69+
70+
The caller pre-populates `starting_stage` (and any prerequisite
71+
fields the chosen branch needs) and the graph routes accordingly.
72+
73+
## When this is the right pattern
74+
75+
- You have a few canonical entry points and the choice between
76+
them is data, not control flow.
77+
- You want to skip work already done in a prior run — combine with
78+
[checkpointing](../concepts/checkpointing.md) to pick up where
79+
you left off.
80+
- Your "different entry points" share state structure and most of
81+
the downstream graph.
82+
83+
## When it isn't
84+
85+
- "Start at node X" really means "run a different pipeline." Then
86+
it's a different compiled graph. Don't bend one graph into two;
87+
two graphs are easier to test and reason about.
88+
- The number of entry points grows unboundedly. Then you're
89+
reimplementing routing — consider a higher-level dispatch layer
90+
that picks which graph to invoke.
91+
92+
## Cross-references
93+
94+
- [Composition: conditional edges](../concepts/composition.md)
95+
- [Checkpointing](../concepts/checkpointing.md)
96+
- Spec: [graph-engine](https://openarmature.org/capabilities/graph-engine/)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Session-as-checkpoint-resume
2+
3+
**Problem.** How do I keep multi-turn agent state across turns?
4+
5+
## Approach
6+
7+
The framework's [checkpointing](../concepts/checkpointing.md)
8+
provides single-invocation crash resume out of the box. Multi-turn
9+
state is the same primitive used differently: the application
10+
keeps a stable `session_id → invocation_id` mapping, and each
11+
turn calls `invoke(resume_invocation=<prior_invocation_id>)` to
12+
pick up where the previous turn left off.
13+
14+
The checkpointer returns the prior state. The new turn proceeds
15+
from there. Session-context fields that accumulate across turns
16+
(message history, retrieved facts, running totals) use a `merge`
17+
or `append` reducer so each turn's contribution adds to what's
18+
already there rather than replacing it.
19+
20+
Each resume mints a new `invocation_id`; the `session_id` is the
21+
join key the application maintains, typically as the
22+
`correlation_id` on `invoke()` (which is preserved unchanged
23+
across resume).
24+
25+
## Snippet
26+
27+
```python
28+
from typing import Annotated
29+
from openarmature.checkpoint import SQLiteCheckpointer
30+
from openarmature.graph import END, GraphBuilder, State, append, merge
31+
32+
33+
class SessionState(State):
34+
messages: Annotated[list[dict], append] = []
35+
facts: Annotated[dict[str, str], merge] = {}
36+
last_user_input: str = ""
37+
38+
39+
# ... define nodes that read s.messages, append to s.messages,
40+
# and merge into s.facts ...
41+
42+
checkpointer = SQLiteCheckpointer(db_path="./sessions.db")
43+
graph = (
44+
GraphBuilder(SessionState)
45+
.add_node("plan", plan)
46+
.add_node("respond", respond)
47+
.add_edge("plan", "respond")
48+
.add_edge("respond", END)
49+
.set_entry("plan")
50+
.with_checkpointer(checkpointer)
51+
.compile()
52+
)
53+
54+
55+
# The application maintains its own session table mapping
56+
# session_id -> latest invocation_id. OA's checkpointer doesn't
57+
# know about sessions; the join is the application's
58+
# responsibility. The session_id doubles as correlation_id so
59+
# observability traces share the cross-turn join key.
60+
async def handle_turn(session_id: str, user_input: str) -> str:
61+
initial = SessionState(last_user_input=user_input)
62+
prior_invocation_id = sessions_db.get_invocation_id(session_id)
63+
64+
if prior_invocation_id is None:
65+
final = await graph.invoke(initial, correlation_id=session_id)
66+
else:
67+
final = await graph.invoke(
68+
initial, resume_invocation=prior_invocation_id
69+
)
70+
71+
# Record the new invocation_id for next turn's resume.
72+
# Read it from the checkpointer's latest record for this
73+
# correlation_id; exact lookup is application-side bookkeeping.
74+
sessions_db.set_invocation_id(session_id, latest_for(session_id))
75+
76+
return final.messages[-1]["content"]
77+
```
78+
79+
`sessions_db` is your application's session-state store (Postgres,
80+
Redis, a flat file, whatever); the checkpointer holds the OA-side
81+
state and the session table holds the join keys.
82+
83+
## When this is the right pattern
84+
85+
- Your application has long-lived sessions with multiple LLM turns
86+
and you want the prior state to be the starting point of the
87+
next turn.
88+
- You're already running a checkpointer for crash resume — this
89+
pattern is "use it more."
90+
- Cross-turn state has clean reducer semantics: `merge` for
91+
accumulating dicts, `append` for growing lists.
92+
93+
## When it isn't
94+
95+
- A session's "state" is bigger than fits comfortably in a single
96+
graph state shape. Split into multiple graphs and share an
97+
external store keyed by session.
98+
- Turns are completely independent — there's no value in carrying
99+
state across them. Then just run each turn as a fresh invoke.
100+
- The application already has its own state-management layer that
101+
conflicts with OA's frozen-state model. Use OA per-turn without
102+
cross-turn resume.
103+
104+
## Cross-references
105+
106+
- [Checkpointing](../concepts/checkpointing.md) — backend wiring,
107+
`resume_invocation`, schema migration.
108+
- [State and reducers](../concepts/state-and-reducers.md)`merge`
109+
and `append` reducer strategies.
110+
- [`examples/08-checkpointing-and-migration`](../examples/08-checkpointing-and-migration.md)
111+
single-resume baseline.
112+
- Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/)

0 commit comments

Comments
 (0)