You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add three patterns from graduated agent shapes (#111)
* Add three patterns from graduated agent shapes
Graduate three substantive entries from docs/agent/non-obvious-
shapes.md into full patterns under docs/patterns/, each with a
runnable snippet and "when this is right / when it isn't" guidance:
- state-migration-on-resume: schema_version conventions, the
Callable[[Any], Any] migrate signature, JSON-backed checkpointer
requirement, chained migrations and the
CheckpointStateMigrationMissing failure mode.
- caller-supplied-trace-identifiers: invoke(metadata=...) and
set_invocation_metadata propagation through OTel attributes and
Langfuse trace.metadata, with the boundary validation rules.
- observer-state-reconciliation: per-invocation dict keyed on
(namespace, branch_name, attempt_index, fan_out_index) for
custom observers that need to thread state between paired
events.
Update the patterns index + mkdocs nav, remove the three graduated
entries from non-obvious-shapes (the patterns catalog handles
discoverability), regenerate the bundled AGENTS.md and the
programmatic patterns API (openarmature.patterns now lists 7 entries
instead of 4), and bump the test_patterns_api expectation.
* Correct two pattern-doc inaccuracies per PR review
Two CoPilot findings on PR #111:
1. caller-supplied-trace-identifiers referenced a non-existent
public constant ``RESERVED_LANGFUSE_METADATA_KEYS``. The actual
symbol is ``_RESERVED_KEY_NAMES`` (module-private). Replace the
false-symbol claim with a description of where validation runs
(the boundary in ``observability.metadata``) and point at the
spec's observability §3.4 for the canonical reserved-key list.
2. state-migration-on-resume's resume snippet used
``starting_state=None``, but ``CompiledGraph.invoke()`` takes
positional ``initial_state: StateT`` (required, no such kwarg).
Update the comment to pass ``PipelineState()`` positionally; the
engine overwrites it from the loaded checkpoint record.
Regenerate AGENTS.md and ``_patterns/`` so the two regenerated
mirrors carry the same fixes.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
8
8
9
9
### Added
10
10
11
+
-**Three new patterns docs.**`docs/patterns/state-migration-on-resume.md`, `docs/patterns/caller-supplied-trace-identifiers.md`, and `docs/patterns/observer-state-reconciliation.md` graduate the corresponding entries from `docs/agent/non-obvious-shapes.md` into full pattern recipes with code snippets and "when this is right / when it isn't" guidance. The programmatic patterns API (`openarmature.patterns.list()` / `get(name)`) grows from 4 to 7 entries.
11
12
-**HyperDX OTel integration test path and "Production swap" docs in example 03.**`examples/03-observer-hooks/main.py`'s module docstring grows a "Production swap" section showing how to substitute the demo's `SimpleSpanProcessor` + `ConsoleSpanExporter` for `BatchSpanProcessor` + `OTLPSpanExporter` pointed at HyperDX (or any other OTLP-HTTP collector). A new opt-in integration test (`tests/integration/test_otel_hyperdx_export.py`, gated by `HYPERDX_API_KEY` + `HYPERDX_OTLP_ENDPOINT` env vars and `@pytest.mark.integration`) drives the same production export path end-to-end against a live endpoint. `opentelemetry-exporter-otlp-proto-http` lands as a dev-only dep; not promoted to a public extras group yet.
Copy file name to clipboardExpand all lines: docs/agent/non-obvious-shapes.md
-91Lines changed: 0 additions & 91 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -125,37 +125,6 @@ Different classes, same OTel-Logs export path. If both are attached against the
125
125
126
126
Catching `Exception` works but is too broad; catching one hierarchy misses the other two. If you want to branch on category strings (e.g., for retry logic), catch the relevant base — `RuntimeGraphError` covers all five spec runtime categories, `LlmProviderError` covers all nine provider categories, `CheckpointError` covers all six checkpoint categories. The `TRANSIENT_CATEGORIES` frozenset in `openarmature.llm` enumerates which provider categories are retriable.
127
127
128
-
### Reconcile `started` → `completed` pairs via a per-invocation dict keyed on `(namespace, branch_name, attempt_index, fan_out_index)`
129
-
130
-
Observers receive `started` and `completed` events as a pair per node attempt, but the engine doesn't carry a `step_id`-like correlation field across the pair (it doesn't need one for its own logic — the events arrive serially per spec §6). Observer code that needs to thread per-call state — start timestamps, request payloads, custom IDs — between the two events has to reconcile manually.
131
-
132
-
The pair identity is `(namespace, branch_name, attempt_index, fan_out_index)`: that tuple is unique within an invocation (per graph-engine §6 uniqueness invariants — `branch_name` and `fan_out_index` are independent slots, so a node inside a parallel-branches branch needs `branch_name` in the key to avoid colliding with the same-named node in a sibling branch). Carry per-invocation state in a `dict[invocation_id, dict[tuple, value]]` and look up on `completed`:
# Sweep when the dict empties (last completed for this invocation).
153
-
ifnotself._pending.get(invocation_id):
154
-
self._pending.pop(invocation_id, None)
155
-
```
156
-
157
-
The `_pending[invocation_id]` sub-dict naturally tracks in-flight pairs and drains as completions arrive. Sweep the outer entry when the sub-dict empties so long-running services don't accumulate per-invocation entries. If you also subscribe to drain events, that's another sweep opportunity. The same pattern works for any per-call state the observer needs to thread across the pair.
158
-
159
128
### Filter `openarmature.*`-namespaced events when your observer only cares about user nodes
160
129
161
130
OA emits observer events under sentinel node-names for its own internal dispatch: `openarmature.llm.complete` for LLM provider calls (proposal 0024), `openarmature.checkpoint.migrate` for state-migration runs (proposal 0014), `openarmature.checkpoint.save` for checkpoint saves (proposal 0010). These events let the OTel / Langfuse observers emit LLM-provider spans, checkpoint-migrate spans, etc. — but a custom observer that only cares about user-defined node activity sees them as noise:
`event.namespace[0]` is the safest discriminator (the leaf `event.node_name` would also work for LLM events but won't match the checkpoint sentinels since those repurpose `node_name` differently). Don't try to filter on `current_invocation_id() is None` — OA-internal events are dispatched within the same invocation context as user-node events, so `invocation_id` is set for both; the namespace-prefix check is the stable contract.
172
141
173
-
### A `with_state_migration` recipe — register migrations alongside the state class, run on resume
174
-
175
-
`GraphBuilder.with_state_migration(s)` registers callables that transform an old-schema state record into the current schema. The engine calls them automatically on `invoke(resume_invocation=...)` when the loaded record's `schema_version` doesn't match `state_cls.schema_version`. The migration callable's signature is `(state_dict: dict, from_version: str, to_version: str) -> dict`; it receives the raw deserialized record and returns the new shape.
Important detail: the migration runs once on resume, before any node body fires; the engine dispatches a synthetic `checkpoint_migrated` observer event (per spec §6 cross-ref) so observers can emit a migration span. The migrated state is what `_step_body` sees on resume — you do NOT need to handle both v1 and v2 shapes in node bodies.
201
-
202
-
When chaining multiple migrations (v1 → v2 → v3), register each step separately via repeated `with_state_migration` calls; the engine walks the chain in version order. If the chain has gaps (registered v1→v2 and v3→v4 but a record is at v2 with `to_version="4"`), the engine raises `CheckpointStateMigrationMissing` at resume time — fail-loud rather than silently skipping.
203
-
204
142
### Fan-out subgraphs that emit `list[X]` per instance produce `list[list[X]]` at `target_field`
205
143
206
144
When a fan-out's per-instance state collects a `list[X]` as its `collect_field` (e.g., each instance produces 0..N records), the engine's contribution step is `[s[cfg.collect_field] for s in successes]` — every instance's value becomes one element of the outer list. With `list[X]` per-instance, the parent receives `list[list[X]]`, and the default `append` reducer on the parent's `Annotated[list[X], append]` field preserves the nesting verbatim. Pydantic then fails to validate each `list[X]` element against `X`:
@@ -242,32 +180,3 @@ class PipelineState(State):
242
180
Single-record-per-instance fan-outs (`collect_field: str`, parent field `Annotated[list[X], append]`) don't hit this — the engine still wraps each instance's value as one element, but `append` flattens it correctly since each element is already an `X`. The two non-flat shapes emerge only when the per-instance value is itself a container: a `list[X]` per instance lands `list[list[X]]` (use `concat_flatten`), and a `dict[str, X]` per instance lands `list[dict]` (use `merge_all`).
243
181
244
182
If a parent field is populated by BOTH direct node writes AND fan-out collection, that's an architectural ambiguity worth fixing upstream — split into two fields, or pick one path.
Per spec observability §3.4 / proposal 0034, callers attach arbitrary key/value entries at `invoke()` time and the framework propagates them to every observability backend:
The OTel observer emits each entry as an `openarmature.user.<key>` cross-cutting span attribute on every span (invocation, node, subgraph wrapper, fan-out instance, LLM provider). The Langfuse observer merges each entry as a top-level key into `trace.metadata` AND every observation's metadata. Backends that consume OTel attributes (Honeycomb, Datadog APM, HyperDX, Grafana Tempo) pick the entries up for free; backends with typed metadata fields (Langfuse) get them via the per-backend propagation rule.
258
-
259
-
Boundary validation runs synchronously: keys MUST NOT start with `openarmature.` or `gen_ai.` (reserved namespaces); values MUST be OTel-attribute-compatible scalars (`str` / `int` / `float` / `bool`) or homogeneous arrays of those. Violations raise `ValueError` before any work begins.
260
-
261
-
Mid-invocation augmentation via the public helper:
262
-
263
-
```python
264
-
from openarmature.observability import set_invocation_metadata
# subsequent spans (this node's completed, next node's started,
269
-
# any LLM call inside, etc.) carry productId
270
-
return {"score": await compute_score(state)}
271
-
```
272
-
273
-
The augmentation respects fan-out / parallel-branches per-instance scoping — each instance's augmentation lives in its own Context copy and doesn't leak to siblings. Sequential nodes in the same engine task see prior nodes' augmentations forward. The helper validates the same rules as the `invoke()` boundary.
0 commit comments