Skip to content

Latest commit

 

History

History
270 lines (198 loc) · 67.7 KB

File metadata and controls

270 lines (198 loc) · 67.7 KB

Changelog

All notable changes to openarmature-python are documented in this file.

The format follows Keep a Changelog. The package follows Semantic Versioning; pre-1.0 minor bumps may carry behavioral changes per spec governance.

[Unreleased]

Added

  • vLLM production deployment notes. docs/model-providers/vllm.md grows a "Production deployment" section covering the VLLM_HTTP_TIMEOUT_KEEP_ALIVE gotcha (vLLM's stock 5s uvicorn keep-alive lapses pooled OA-side httpx connections and surfaces as ProviderUnavailable; widen to roughly 300s), a systemd unit skeleton, and the three throughput knobs that interact with OA's shared connection pool (--max-model-len, --max-num-seqs, --gpu-memory-utilization). The existing "Tool calling" section grows a --tool-call-parser family table verified against vLLM's docs (Llama 3.x / Llama 4 / Mistral / Hermes / Qwen3 / DeepSeek V3 / GPT-OSS), plus explicit "not supported here" callouts for Anthropic / Gemini (proprietary cloud) and mainstream Gemma (no vLLM parser).
  • 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.
  • 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.

Changed (breaking)

  • OpenAIProvider.ready() default probe flipped to chat_completions. A new constructor kwarg readiness_probe: Literal["models", "chat_completions", "both"] selects which wire path ready() exercises; the default is now the chat-completions path (POST /v1/chat/completions with max_tokens=1), which actually exercises the inference path. The previous catalog-only behavior is still available as readiness_probe="models", and readiness_probe="both" runs catalog then chat for the strongest signal. Motivation: OpenAI-compatible proxies (Bifrost and similar) can return 200 on GET /v1/models while rejecting POST /v1/chat/completions, leaving the catalog probe green while every real call fails. The new default surfaces that class of failure at preflight rather than at first inference. Non-200 chat-probe responses route through classify_http_error, so the canonical error categories (provider_authentication, provider_unavailable, provider_invalid_model, etc.) surface consistently. Callers that depended on the catalog-only behavior (cost-sensitive cloud setups where every ready() would now bill prompt tokens) can opt back in by passing readiness_probe="models".

[0.11.0] — 2026-06-01

Observability + prompt-management release. The pinned spec advances from v0.27.1 to v0.38.0, absorbing eight accepted proposals (0039-0046). Two headlines: (1) the Langfuse observer grows native trace.input / trace.output sourcing with caller hooks (0043) and the per-async-context augmentation boundary becomes lineage-aware for nested fan-out / parallel-branches topologies (0045); (2) prompt-management gains a Chat-prompt variant alongside the existing Text-prompt (0046) and LangfusePromptBackend lands for both Langfuse text and chat prompts. Caller-supplied invocation_id (0039), mid-invocation open-span metadata update (0040), three reserved-key surfaces (0041 + 0042), and the parallel-branches OTel dispatch span (0044) round out the cycle.

Added

  • Multi-message chat-prompt rendering (proposal 0046, prompt-management §3.1 / §6, spec v0.38.0). The Prompt type splits into a discriminated union over TextPrompt (existing single-string template) and the new ChatPrompt carrying an ordered list of ChatSegment entries — ContentSegment (role-tagged content; text-template OR content-blocks-template) and PlaceholderSegment (caller-supplied message-list injection). Content-block templates mirror llm-provider §3.1 (TextBlockTemplate, ImageURLBlockTemplate, ImageInlineBlockTemplate). PromptManager.render accepts a new placeholders: Mapping[str, Sequence[Message]] | None kwarg; chat prompts render segment-by-segment with strict-undefined per segment and per block. The Langfuse backend now maps Langfuse ChatPromptClient to ChatPrompt. Conformance fixtures 017-031 activate against the extended harness. Single-string Text-prompt rendering is unchanged at the call surface — existing prompt.template callers continue to work via the TextPrompt variant.

  • Inline image base64 validated at render time. A chat-prompt content-blocks template with an ImageInlineBlockTemplate whose rendered base64_data fails base64.b64decode(..., validate=True) now raises prompt_render_error at the prompt-manager boundary rather than letting the malformed payload reach the LLM provider, where the error would be provider-specific.

  • Nested-lineage augmentation containment scope (proposal 0045, observability §3.4, spec v0.37.0). The per-async-context augmentation boundary rewrites as three lineage-aware rules: the augmenter's call-stack ancestor chain MUST update (every strict dispatch ancestor on the path — each outer fan-out instance dispatch span, each outer parallel-branches branch dispatch span, each outer serial subgraph wrapper); siblings at any depth MUST NOT; shared parents (fan-out NODE, parallel-branches NODE, invocation span) MUST NOT. Engine-side: tracks per-depth lineage chains (fan_out_index_chain / branch_name_chain) parallel to namespace_prefix, available on NodeEvent and MetadataAugmentationEvent. Observer-side: OTelObserver._collect_augmentation_targets and LangfuseObserver._handle_metadata_augmentation rewrite against the three-step boundary decision tree. Single-level behavior (fixtures 029 / 030 / 034) is unchanged.

  • LangfuseObserver Trace input/output sourcing (proposal 0043, observability §8.4.1). New observer construction knobs populate trace.input and trace.output per the three-lever decision tree:

    • disable_state_payload: bool = True — privacy knob symmetric to disable_llm_payload. When ON (default), Trace fields receive the minimal stub {entry_node, correlation_id} / {final_node, status}; when OFF, the raw state object is serialized.
    • trace_input_from_state / trace_output_from_state — optional caller hooks returning the domain-shaped value to use for trace.input / trace.output. Returning None falls through to the next applicable lever.
    • status is the closed Literal["completed", "failed"] enum from spec §8.4.1.
  • Two new observer event types delivered through the existing graph.observer.Observer queue:

    • InvocationStartedEvent(initial_state, invocation_id, correlation_id, entry_node) — emitted once at invocation entry before any node fires.
    • InvocationCompletedEvent(final_state, status, final_node, invocation_id, correlation_id) — emitted once at invocation exit on both the success path (status="completed") and failure path (status="failed").

    The Observer.__call__ signature widens to NodeEvent | MetadataAugmentationEvent | InvocationStartedEvent | InvocationCompletedEvent. The new ObserverEvent type alias (re-exported from openarmature.graph) gives observer authors a one-name handle on the union; existing observers that ignore non-NodeEvent variants early-return after an isinstance(event, NodeEvent) check.

  • LangfuseTrace.input / LangfuseTrace.output dataclass fields on the in-memory recorder, populated by the new observer paths.

  • Parallel-branches OTel dispatch span synthesis (proposal 0044, observability §5.7). Mirroring the fan-out per-instance dispatch synthesis (proposal 0013), the OTel observer now synthesizes a per-branch dispatch span between the parallel-branches NODE span and each branch's inner-node spans. New ParallelBranchesEventConfig payload on NodeEvent (branch_names, branch_count, error_policy, parent_node_name); engine populates it on the parallel-branches NODE's started / completed events. New OTel span attributes:

    • openarmature.parallel_branches.branch_count + openarmature.parallel_branches.error_policy on the parallel-branches NODE span.
    • openarmature.node.branch_name + openarmature.parallel_branches.parent_node_name on each per-branch dispatch span.
    • openarmature.node.branch_name on every inner-node span beneath a per-branch dispatch span.
  • Caller-supplied invocation_id (proposal 0039, observability §5.1, spec v0.32.0). invoke(invocation_id=...) now accepts a caller-supplied non-empty URL-safe string in place of the framework-minted UUIDv4. Mirrors the correlation_id shape: caller-supplied wins; framework mints a UUIDv4 only when absent. Resume mints a fresh invocation_id per attempt (the previous attempt's id remains on the saved record). The Langfuse mapping derives trace.id via the SDK's create_trace_id(seed=invocation_id) for non-UUID values (raw id preserved under trace.metadata.invocation_id); UUID values continue to map via dashes-stripped hex.

  • Mid-invocation open-span metadata update (proposal 0040, observability §3.4, spec v0.31.0). set_invocation_metadata(**entries) called mid-invocation now updates currently-open spans in the augmenting async context in place, via the backend SDK's attribute / metadata update path (Span.set_attribute for OTel, observation update(metadata=...) for Langfuse). Tightens 0034's per-async-context delivery from SHOULD to MUST; preserves the ancestor / sibling boundary (spans in ancestor or sibling contexts MUST NOT be updated). Per spec §3.4 v0.31.0; proposal 0045 (v0.37.0) extends the boundary rule to be lineage-aware for nested dispatch.

  • LangfusePromptBackend (text and chat variants). A PromptBackend impl backed by the Langfuse SDK's prompt-registry surface. Gated behind the existing [langfuse] extra so the base package stays SDK-free. Maps Langfuse TextPromptClient to TextPrompt; ChatPromptClient to ChatPrompt (added by proposal 0046 — see entry above). Fails closed (PromptNotFound) when a Langfuse chat entry has an unsupported shape rather than silently dropping. The fetched Prompt carries the SDK Prompt object under observability_entities['langfuse_prompt'] so the existing Generation → Prompt link in the Langfuse observer fires automatically.

Changed (breaking, pre-1.0)

  • Prompt is now a discriminated-union type alias over TextPrompt | ChatPrompt (proposal 0046). The previous Prompt(...) class instantiation MUST update to TextPrompt(...); type annotations using Prompt as a return / parameter type continue to work (the alias is the union). The Langfuse backend no longer raises on Langfuse chat prompts — it returns ChatPrompt instead of PromptNotFound. Per spec §6 narrowing, Text prompts render to exactly one UserMessage; multi-message / multimodal prompts MUST use the Chat variant.
  • OTel span attribute openarmature.branch_name is renamed to openarmature.node.branch_name to align with the spec §5.7 attribute namespace. Prior python releases emitted openarmature.branch_name as a workaround because the spec hadn't defined an OTel attribute carrying branch_name yet; proposal 0044 (v0.36.0) formalizes the namespace. Downstream dashboards, queries, or alerts filtering on the old attribute name MUST update. Pre-1.0 break; the prior name was python-implementation-only and was never spec-normative.

Changed

  • Reserved-key extension (proposal 0042, observability §3.4, spec v0.34.0). Three additional bare key names — branch_name, detached, detached_from_invocation_id — are reserved against caller-supplied invocation_metadata and set_invocation_metadata collision; the framework rejects them at the invoke() boundary and at the mid-invocation augmentation helper with ValueError. The reserved-name set grows from 21 to 24. These three are top-level Langfuse metadata keys the observer mapping already writes; without reservation a caller key matching one would silently shadow the OA-emitted field. Maintenance extension of the 0041 mechanism.
  • Langfuse-emitted top-level metadata key collision reservation (proposal 0041, observability §3.4, spec v0.31.0). The reserved caller-metadata key set extends to cover every OA-emitted top-level key the §8.4 Langfuse mapping writes alongside caller keys (20 names — correlation_id, entry_node, spec_version, namespace, step, attempt_index, fan_out_index, subgraph_name, etc.). Whole-key exact match; rejected at the invoke() boundary independently of which backend is attached. Prevents a caller key from silently overwriting an OA-emitted field in Langfuse's flat top-level metadata. Pre-existing openarmature.* / gen_ai.* prefix reservation unchanged.
  • observation.metadata.detached: true moves to the parent-side dispatching observation (proposal 0042, observability §8.4.2). The Langfuse mapping previously emitted detached: true on the dispatch observation inside the detached child trace; the §8.4.2 row added by 0042 places it on the parent-side dispatching observation that fires the detached child (the link observation in the main trace for detached subgraphs; the parent fan-out node observation for detached fan-outs). The detached-side observation no longer carries the flag.
  • LangfuseClient.update_trace Protocol grows input / output keyword parameters so observer-supplied values land on the Trace's headline fields.

Fixed

  • install_log_bridge no-ops when an OTel logging handler is already attached to the root logger by an SDK that auto-installs one (e.g., HyperDX). The previous attach-always behavior produced duplicate LoggingHandler instances and double-emitted log records when used alongside such an SDK. The installer now detects an existing LoggingHandler whose provider matches the current LoggerProvider and skips the re-attach.
  • InvocationCompletedEvent.final_state on the failure path now surfaces the partial state at failure point. Spec §8.4.1 Resume semantics requires the failure-path trace.output hook to receive "the partial final state captured at the failure point"; the original PR #99 implementation defaulted to starting_state, so the hook saw pre-execution state when it should have seen post-execution-up-to-failure state. The engine now tracks the latest post-merge state via a latest_state_box on _InvocationContext, updated after every successful step and read on the failure path. Success-path behavior unchanged.
  • latest_state_box is per-context, not shared across subgraph descents. Unlike the sibling final_node_box (which shares by reference because the spec wants the innermost failing node's name — the real culprit), latest_state_box must isolate per level so the outermost Langfuse trace receives outer-state-typed values. Without the isolation, a subgraph-internal step's inner-typed state would leak up to the outer trace.output hook, breaking the hook's typed contract. Each subgraph / fan-out instance / parallel-branches branch gets its own fresh box. Pinned by four regression tests covering flat, subgraph, fan-out, and parallel-branches failure paths.

Notes

  • Pinned spec version bumped from v0.27.1 to v0.38.0 over the v0.11.0 cycle. Eight proposals absorbed: 0039 (caller-supplied invocation_id, v0.32.0), 0040 (mid-invocation open-span metadata update, v0.31.0), 0041 (Langfuse top-level metadata key collision reservation, v0.31.0), 0042 (reserved-key extension to 24 names, v0.34.0), 0043 (Langfuse trace input/output sourcing, v0.35.0), 0044 (parallel-branches OTel dispatch span, v0.36.0), 0045 (nested-lineage augmentation containment scope, v0.37.0), and 0046 (multi-message chat-prompt rendering, v0.38.0). The pinned spec also carries the textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented).
  • LangfuseSDKAdapter now applies trace.input / trace.output to the live Langfuse Trace. Input lands on the first real observation under the trace via set_trace_io; output uses a synthetic short-lived openarmature.trace_io observation as the carrier. The InMemoryLangfuseClient used by tests applies the fields directly.
  • Conformance fixture observability/conformance/037-langfuse-trace-input-output activated for all five cases (default stub / disable_state_payload=False / hooks non-null / hooks null-fallthrough / resume re-fire). The langfuse harness grew per-case checkpointer: in_memory wiring, a compact flaky: test seam, and a two-phase resume-flow assertion path.
  • The Langfuse v4 SDK marks set_current_trace_io / Span.set_trace_io deprecated ("removal in a future major version"). Empirical verification against Langfuse Cloud (v4.7.1, 2026-05-29) confirms it remains the only path that populates the Traces list view's headline Input / Output columns; propagate_attributes(metadata=...) does not substitute for it in the current UI. We will revisit when Langfuse publishes a concrete migration guide for v5.

[0.10.0] — 2026-05-27

Langfuse observability release. The pinned spec advances from v0.22.1 to v0.27.1, absorbing six accepted proposals (0031-0036). The headline is a native Langfuse backend mapping (a sibling to the OTel mapping) driven by a downstream production project integrating OpenArmature with Langfuse; this release also adds caller-supplied invocation metadata, two fan-out collection reducers, and a batch of provider / observability hardening surfaced by that same downstream integration.

Added

  • LangfuseObserver — native Langfuse backend mapping (proposal 0031, observability §8). An observer that consumes the §6 event stream as a sibling to the OTel observer (both can be attached to one graph; each honors its own opt-out). Maps invocation → Langfuse Trace, node / subgraph / fan-out → Span observation, LLM provider call → Generation observation. Sets the Trace id equal to the OA invocation_id so cross-system lookup is a direct hit; routes correlation_id to trace.metadata and every observation.metadata. Full subgraph dispatch, per-instance fan-out, and detached-trace-mode parenting (§8.3 / §8.5). Decoupled from the SDK via the LangfuseClient Protocol.
  • InMemoryLangfuseClient — an in-process recorder satisfying LangfuseClient, used by the conformance harness and useful for unit tests; captures Traces / Observations verbatim for assertion.
  • LangfuseSDKAdapter — bridges the real langfuse>=4.6 SDK to the LangfuseClient Protocol (UUID4 → OTel-hex trace-id conversion, propagate_attributes on every observation, usage translation). Gated behind the new [langfuse] extra (pip install openarmature[langfuse]); the observer itself needs no SDK install because the Protocol decouples it.
  • Public force_flush(timeout_ms=30_000) on OTelObserver and LangfuseObserver (downstream ask). Wraps the underlying provider / client flush so fast-teardown harnesses (serverless functions, CLI one-shots, FastAPI TestClient teardown) can drain the export buffer without reaching into the private _provider attribute. Distinct from CompiledGraph.drain(), which covers the engine's observer-event queue; force_flush() covers the outbound span-export buffer.
  • Caller-supplied invocation metadata (proposal 0034, observability §3.4 + §5.6 + §8.4). invoke(metadata={...}) accepts a per-invocation mapping of str → AttributeValue (OTel scalars or homogeneous arrays). The framework propagates every entry to all observability backends: the OTel observer emits each as an openarmature.user.<key> cross-cutting span attribute on every span; the Langfuse observer merges each as a top-level key into trace.metadata and every observation.metadata. openarmature.observability.set_invocation_metadata(**entries) augments the in-scope mapping mid-invocation (additive; respects fan-out / parallel-branches per-instance COW scoping); current_invocation_metadata() reads it. Boundary validation rejects keys under the reserved openarmature.* / gen_ai.* prefixes and non-OTel-compatible value types with a synchronous ValueError.
  • concat_flatten and merge_all reducers (proposal 0036, graph-engine §2). The fan-out collection analogs of append / merge: a fan-out subgraph emitting list[X] per instance lands list[list[X]] at the parent target_field (use concat_flatten to flatten one level); emitting dict[str, X] lands list[dict] (use merge_all to fold with last-write-wins per key). Both are strict — they raise ReducerError (graph-engine §4) when an update element isn't the expected list / mapping shape. Exported from openarmature.graph; the required-built-in set grows from three to five.
  • Three new RuntimeConfig declared fields (proposal 0032, llm-provider §6): frequency_penalty, presence_penalty, and stop_sequences. Surfaced on the OpenAI wire body per §8.1 (with stop_sequences renaming to OpenAI's stop key) and as gen_ai.request.* span attributes. Per the §6 null-skip rule, each declared field with value None is omitted from the wire body.
  • Prompt-management surface refinements (proposal 0033). Prompt.sampling (a SamplingConfig subclass of RuntimeConfig), Prompt.observability_entities, the LabelResolver three-step resolution chain (explicit > resolver > "production"), and filesystem layout / sampling-source ergonomics for the prompt-management capability.
  • Self-hosted vLLM cookbook at docs/model-providers/vllm.md — base-URL contract, the structured-output fallback flag, the genai_system="vllm" override, readiness-probe limitations + warm-up pattern, and tool calling.
  • conformance.toml manifest + CI guard. A machine-readable record of which spec proposals are implemented and since which version, validated against the pinned spec submodule by scripts/check_conformance_manifest.py on every PR. Consumed by the spec docs site to render per-implementation status.

Changed

  • OpenAIProvider rejects a /v1 suffix on base_url (downstream-surfaced bug). httpx joins base URLs by appending, so base_url="https://host/v1" plus the provider's /v1/chat/completions request produced a doubled /v1/v1/... wire path that silently 404/405'd on most backends while the readiness probe stayed green. The provider now raises ValueError at construction when base_url's path ends in /v1 (with or without a trailing slash, and through query strings / fragments). Other non-empty paths (proxy prefixes) are left intact. No existing users were affected; this is the first production integration.
  • metadata.subgraph_name / openarmature.subgraph.name carries the compiled-subgraph identity (proposal 0035 resolution), not the wrapper node name. SubgraphNode and FanOutConfig gain an optional subgraph_identity; the engine threads it through NodeEvent.subgraph_identities to the observers. Falls back to the empty string when no identity is tracked (observability §5.3). Distinct from the observation's name / namespace, which remain the wrapper node name.

Fixed

  • entry_node / trace name when the outer entry is a SubgraphNode. Subgraph wrappers don't emit their own events, so the first event the observer saw came from inside the subgraph; the Langfuse observer recorded the inner node as the trace's entry_node. Now resolves to event.namespace[0] (the outer entry).
  • Detached-mode link observation no longer carries subgraph_name. In detached-trace mode the wrapper role migrates to the detached trace; the parent trace's link observation is the SubgraphNode span (no wrapper role) and must not carry subgraph_name.

Notes

  • Pinned spec version bumped from v0.22.1 to v0.27.1 over the v0.10.0 cycle. Six proposals absorbed: 0031 (observability Langfuse mapping, v0.23.0), 0032 (RuntimeConfig declared-field expansion, v0.24.0), 0033 (prompt-management surface refinements, v0.25.0), 0034 (caller-supplied invocation metadata, v0.26.0), 0035 (Langfuse graph-topology conformance fixtures, v0.26.1 + v0.27.1 fixture corrections), and 0036 (fan-out collection reducers concat_flatten / merge_all, v0.27.0). All conformance fixtures pass against the v0.27.1 pin, including the un-deferred Langfuse subgraph / fan-out / detached-trace fixtures and the two new reducer fixtures.
  • langfuse>=4.6,<5 is the supported SDK range for LangfuseSDKAdapter, validated end-to-end against Langfuse Cloud. The v4 SDK's flush() is synchronous but exposes no timeout parameter, so LangfuseObserver.force_flush(timeout_ms=...) accepts the argument for Protocol symmetry but the underlying flush honors the SDK's own deadlines (best-effort).

[0.9.0] — 2026-05-25

Added

  • openarmature.patterns programmatic API. Two-function surface (list() -> list[str], get(name: str) -> str) exposing the same patterns content shipped in the bundled AGENTS.md. Each pattern is returned as a standalone markdown document: no heading demotion (patterns keep their original # Title), and relative ../concepts/...md / ../examples/...md / intra-pattern links are rewritten to absolute openarmature.ai URLs at build time so cross-references resolve outside the source tree. Useful for agents in sandboxed environments that can import openarmature but can't freely read arbitrary package paths. Content lives at src/openarmature/_patterns/<slug>.md, generated alongside the bundled AGENTS.md and drift-checked by tests/test_agents_md_drift.py. Unknown names raise KeyError with a message listing the known names.
  • openarmature CLI registered as a [project.scripts] entry point with two subcommands:
    • openarmature init appends a discovery pointer block (the python -c "..." one-liner + openarmature docs recipe) into the current project's AGENTS.md and CLAUDE.md so agent sessions opening the project find the bundled OpenArmature docs. Creates files when absent, appends when they exist, and skips re-runs via a <!-- openarmature-init --> comment marker. Flags: --force (re-append despite the marker), --dry-run (print what would be written), --cwd PATH (operate against a path other than the current directory).
    • openarmature docs prints the absolute path to the bundled AGENTS.md. Equivalent to the README discovery one-liner but ergonomic to type and remember.
    • The same surface is reachable as python -m openarmature ... via src/openarmature/__main__.py, so environments where the [project.scripts] entry doesn't land cleanly (some pip install --target layouts, path-shadowed venvs) still work as long as the package is importable.
  • Bundled agent documentation at openarmature/AGENTS.md. The wheel now ships a generated AGENTS.md file at the installed package root, agent-discoverable via python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')". Sections include a TL;DR, capability summaries pulled from the pinned spec submodule's §1 (Purpose) + §2 (Concepts), the patterns docs, hand-written non-obvious-shapes recipes, and a one-line example index. Generator lives at scripts/build_agents_md.py; the committed file is CI-drift-checked by tests/test_agents_md_drift.py. The submodule pin discipline (build refuses unless the submodule HEAD is AT a v* tag via git tag --points-at HEAD) prevents draft (untagged) spec text — or text from a commit between two release tags — from leaking into a release bundle. Adopting projects can point their own AGENTS.md / CLAUDE.md at this path so agent sessions in their codebase find it automatically (or use openarmature init to do the wiring automatically).
  • FanOutInstanceProgress.result_is_error field (proposal 0027, accepted in spec v0.21.0). Explicit boolean discriminator on each per-instance entry in CheckpointRecord.fan_out_progressTrue for collect-mode error contributions (roll forward into errors_field), False for success contributions (roll forward into target_field). The engine reads the explicit field on resume rather than inferring routing from result's shape; the previous structural heuristic (_looks_like_error_record) is removed. Backward-compat path on load: pre-0027 records that omit the key default to False.
  • Strict CheckpointRecordInvalid on fan-out count drift (proposal 0029, accepted in spec v0.22.0). When the resumed run's resolved instance count differs from the saved fan_out_progress entry's instance_count, the engine raises CheckpointRecordInvalid before any fan-out instance work runs on the resumed path. Replaces the pre-0029 pad/truncate behavior which silently dropped completed contributions on shrink (breaking §10.11.1's exactly-once guarantee) and dispatched unsaved work on grow.
  • tool_choice parameter on Provider.complete() (proposal 0025, accepted in spec v0.20.0). Optional discriminated-union value constraining the model's tool-calling behavior — one of "auto", "required", "none", or a ForceTool(name=...) record. Validation runs pre-send: "required" and ForceTool both demand non-empty tools, and ForceTool.name must appear in the supplied list; violations raise ProviderInvalidRequest (§7's existing category — no new error category). When tool_choice is None (the default) the wire field is omitted and the provider's own default applies, preserving pre-0025 behavior exactly. The OpenAIProvider maps the spec shape onto OpenAI's wire shape per §8.1.1 (the ForceTool.type="tool" renames to wire type="function").
  • ForceTool and ToolChoice public types at openarmature.llm.ForceTool / openarmature.llm.ToolChoice. ForceTool is a frozen Pydantic model with type: Literal["tool"] = "tool" and name: str; ToolChoice = Literal["auto", "required", "none"] | ForceTool is the type alias used in Provider.complete()'s signature.
  • validate_tool_choice public validator at openarmature.llm.validate_tool_choice. Standalone validator covering the three §5 pre-send rules; useful for third-party Provider implementations that want to reuse the canonical validation logic.
  • Bounded drain timeout on CompiledGraph.drain() (proposal 0010, accepted in spec v0.19.0). drain() accepts an optional timeout: float | None = None parameter (non-negative seconds). When supplied, drain returns no later than the deadline; any observer events still queued or in-flight are reported as undelivered. Workers are cancelled cleanly so the compiled graph remains usable for subsequent invocations — partial delivery state from one drain does NOT leak into the next. Solves the "slow / hung / misbehaving observer blocks process exit" footgun for short-lived processes (CLIs, scripts, serverless functions). Observers SHOULD be cancellation-safe (idempotent writes, try/finally cleanup); the spec doesn't mandate it but the docs recommend it.
  • DrainSummary frozen dataclass at openarmature.graph.DrainSummary. Returned from every drain() call (with or without timeout). Fields: undelivered_count: int, timeout_reached: bool. The shape is consistent across timed and untimed drains — callers receive the same dataclass whether the timeout was supplied or not. Per the v0.19.0 contract the two declared fields are the spec-mandated minimum; richer diagnostic detail (per-observer counts, sampled event metadata) is reserved for follow-on PRs.
  • Per-instance fan-out resume contract (proposal 0009, accepted in spec v0.18.0). The engine now writes a checkpoint record at every completed event inside a fan-out instance (in addition to the existing outermost-graph + subgraph-internal + fan-out node completion saves). On resume the engine consults the saved record's fan_out_progress field and treats each instance as completed (skip, contribution rolls forward), in_flight (re-run from subgraph entry), or not_started (dispatch normally). The append reducer's no-double-merge guarantee holds across resume because completed is a one-shot accumulator state.
  • FanOutProgress and FanOutInstanceProgress public dataclasses on openarmature.checkpoint. The CheckpointRecord.fan_out_progress field is now tuple[FanOutProgress, ...] (default empty tuple), with per-instance state, result, and completed_inner_positions observability. Was a None placeholder under proposal 0008.
  • FanOutInternalSaveBatching config on InMemoryCheckpointer. Backends MAY opt into batching scoped to fan-out instance internal saves to bound the write volume of high-instance-count fan-outs. Outermost-graph, subgraph-internal, and the fan-out node's own completion save remain synchronous regardless. Default off. Buffered-but-unflushed saves are lost on crash by design; on resume, instances whose completed state was only buffered revert and re-run. Surfaces a new optional save_fan_out_internal / save_fan_out_in_flight_failure Checkpointer Protocol seam; backends that don't implement either fall back to the standard save.
  • 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 this is the right pattern / when it isn't / cross-references structure.

Changed

  • CheckpointRecord.schema_version sourcing clarified per proposal 0028 (spec v0.21.1). Every save site within an invocation now reads schema_version from the declared graph state class — the class passed to GraphBuilder(...) — threaded as context.state_cls. Previously the outer dispatch save read from the declared class while fan-out instance internal saves read from type(state) at save time; the inconsistency only surfaced when a user passed a State subclass that shadowed schema_version, but the divergence made §10.12 migration lookups unreliable across save sites. Now uniform across outer / subgraph-internal / fan-out instance internal saves.
  • Provider.complete() signature extended with an optional tool_choice: ToolChoice | None = None parameter (per proposal 0025 v0.20.0). Backward-compatible: callers that omit the new argument see no wire-shape change. Third-party Provider implementations MUST add the parameter to remain Protocol-conformant under strict type checking (and to accept calls that pass tool_choice without raising TypeError); they MAY ignore it in their wire-body emission, which is how "provider doesn't honor tool_choice" looks at the impl level. The OpenAIProvider wire mapping is implemented per §8.1.1.
  • CompiledGraph.drain() return type changed from None to DrainSummary (pre-1.0; per proposal 0010 v0.19.0 contract). Callers that ignored the return are unaffected — await graph.drain() discards the returned dataclass exactly as before. Callers that explicitly typed the return as None will need to update their annotation.
  • Fan-out resume behavior flipped from atomic restart (0008's v1 contract) to per-instance resume. A crash mid-fan-out used to re-run the entire fan-out on resume; now only the instances that did not complete-and-record their contribution re-run. The economics matter for large fan-outs of expensive work (LLM calls, long extractions): an 80% complete fan-out crash now restores 80% of its results rather than discarding them.
  • SQLiteCheckpointer schema picks up a new fan_out_progress_blob column (added via ALTER TABLE for backward compatibility with pre-0009 databases). Pre-0009 rows back-fill as NULL on load and round-trip as the empty-tuple default. Both pickle and json serialization modes round-trip the new field.

Notes

  • Pinned spec version bumped from v0.17.0 to v0.22.1 over the v0.9.0 cycle. Ten spec versions absorbed: v0.17.1 (proposal 0019, multi-provider wire-format extension — purely textual reframe of llm-provider §8 as a catalog of wire-format mappings; OpenAI-compatible body nested under §8.1), v0.18.0 (proposal 0009, per-instance fan-out resume — pipeline-utilities §10.3 / §10.7 revised, §10.11 added; the append reducer no-double-merge invariant is the load-bearing correctness story), v0.18.1 (fixture-only patch correcting an off-by-one literal in fixture 052's expected results), v0.19.0 (proposal 0010, bounded drain timeout — graph-engine §6 amended with the timeout parameter and DrainSummary return contract), v0.20.0 (proposal 0025, llm-provider tool_choice — §5 / §7 / §8.1.1 amended), v0.20.1 (proposal 0026, llm-provider §8.X wire-format mapping subsection template — purely textual §8 framing paragraph; the existing OpenAI §8.1 mapping is the template's reference shape so no python module-level work was needed), v0.21.0 (proposal 0027, explicit result_is_error discriminator on fan_out_progress per-instance entries — see Added above), v0.21.1 (proposal 0028, canonical source for schema_version — declared graph state class wins over runtime subclass shadowing; see Changed above), v0.22.0 (proposal 0029, strict CheckpointRecordInvalid on fan-out count drift — see Added above), and v0.22.1 (proposal 0030, drain snapshot semantic + timeout-input validation — purely textual; python already implemented both behaviors per the 0010 impl PR, so no module-level work needed). All existing conformance fixtures continue to pass.

[0.8.0] — 2026-05-23

LLM-provider span payload and GenAI semconv release. Pinned spec jumps from v0.16.1 to v0.17.0 (proposal 0024 / observability §5.5 expansion). The trigger was a friction report from a downstream agent integrating OA with Langfuse over OTLP: LLM spans rendered "naked" (model + tokens only), prompt linkage silently dropped at the dispatch-worker task boundary, and every backend needed a per-service attribute-mapping shim. This release clears all eight items in that report.

Added

  • openarmature.llm.input.messages / openarmature.llm.output.content / openarmature.llm.request.extras span attributes (spec §5.5.1). When the OTel observer is constructed with disable_llm_payload=False, LLM spans carry the messages sent, the assistant response content, and the RuntimeConfig extras bag — JSON-encoded with sorted keys, no insignificant whitespace, UTF-8. Default-off (the flag is disable_llm_payload: bool = True) because the payload may contain PII the user hasn't audited; opt in deliberately. Subject to the §5.5.5 truncation contract.
  • GenAI semantic-conventions attributes (spec §5.5.2 + §5.5.3). LLM spans now carry gen_ai.system, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.response.finish_reasons (single-element string array), gen_ai.response.id, and per-set gen_ai.request.{temperature,max_tokens,top_p,seed} (only set fields — absence is meaningful per §5.5.2). The existing openarmature.llm.* attribute set is preserved alongside; both namespaces emit. Default-on (disable_genai_semconv: bool = False); opt out when an external auto-instrumentation library (OpenInference, opentelemetry-instrumentation-openai, etc.) is the canonical source of GenAI attributes for your stack.
  • OTelObserver(resource=...) constructor argument. Optional opentelemetry.sdk.resources.Resource passed to the private TracerProvider. Lets callers set service.name / service.version directly rather than via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES environment variables (which had to be set BEFORE constructing the observer to take effect — a footgun the explicit kwarg avoids).
  • Multi-processor support on OTelObserver. The span_processor constructor argument now accepts a SpanProcessor | Sequence[SpanProcessor]. Multi-destination export (e.g., HyperDX + Langfuse on one observer) becomes a one-line constructor call instead of a per-service CompoundSpanProcessor workaround.
  • OTelObserver(attribute_enrichers=...) hook. Sequence of Callable[[Span, NodeEvent | None], None] invoked just before the observer ends each span. Lets users add backend-specific attributes (custom langfuse.* keys, vendor span kinds, etc.) without subclassing or mutating span._attributes post-on_end. The event is None on synthetic close sites (subgraph dispatch, detached root, fan-out instance, invocation span, shutdown drain); enrichers that need per-event context short-circuit on None. Exceptions are caught and warned, never propagated to the dispatch worker.
  • OTelObserver(payload_max_bytes=...) truncation cap. Per-attribute byte cap for the §5.5.1 payload attributes. Default 65,536 (64 KiB) per attribute; minimum 256 bytes (rejected at construction). The truncation algorithm (spec §5.5.5) emits the largest UTF-8 code-point-aligned prefix that fits within cap - len(marker) bytes followed by the marker …[truncated, M bytes total]. Inline image bytes are unconditionally redacted at the provider before any cap applies (see Image redaction below).
  • OpenAIProvider(genai_system="openai") constructor argument. Default "openai"; override for non-OpenAI endpoints that speak the OpenAI Chat Completions wire format (vLLM, LM Studio, llama.cpp, sglang). Surfaces as the gen_ai.system span attribute. No base-URL sniffing happens — the same host:port could be any of several servers, and a wrong inference is worse than the explicit opt-in.
  • openarmature.observability.LLM_NAMESPACE and openarmature.observability.LlmEventPayload public exports. The ("openarmature.llm.complete",) sentinel namespace used by the LLM-provider hook and the payload shape backend observers consume. Third-party Provider implementations can dispatch their own LLM events via current_dispatch()(NodeEvent(..., namespace=LLM_NAMESPACE, pre_state=LlmEventPayload(...))); custom observers can recognize the same sentinel and read attributes off the payload. Previously private (_LLM_NAMESPACE, _LlmEventState); the old underscore-prefixed names are no longer exported.
  • Response.response_id and Response.response_model typed fields. Mirror the wire response's id and model fields when the provider returns them. Surface as gen_ai.response.id and gen_ai.response.model per spec §5.5.3; also useful for downstream cross-referencing with provider-side billing or audit logs without reaching into Response.raw.

Changed

  • Prompt-context attribute propagation now survives the dispatch-worker task boundary. Previously the OTel observer read current_prompt_result() / current_prompt_group() from inside _handle_llm_event, which runs in the engine's delivery-worker task. asyncio.create_task(deliver_loop(queue)) snapshots the current Context at task creation, before any node body runs — so the ContextVars set by with_active_prompt(...) were never visible to the worker. openarmature.prompt.* attributes silently went missing on the LLM span. Fixed by capturing both ContextVars at dispatch time inside the OpenAIProvider.complete() call (which runs in the node task, where with_active_prompt IS active) and threading the snapshots through the LlmEventPayload. The observer reads from the payload, not the ContextVar.
  • Inline image bytes are redacted at the provider, not the observer. Image content blocks with ImageSourceInline are serialized with source replaced by {type: "inline_redacted", byte_count: N} per §5.5.5 before the payload reaches the observability dispatch queue. Defense-in-depth: bytes never leave the provider in event form, so custom observers subscribing to the LLM event (enabled by LlmEventPayload being public) cannot accidentally leak raw image bytes regardless of their implementation. media_type and detail are preserved at the image-block level per llm-provider §3.1.2. URL-form images pass through unchanged.
  • OTelObserver.shutdown() docstring documents the BatchSpanProcessor flush gotcha. Under fast or unusual teardown orderings (e.g., FastAPI TestClient teardown that closes the event loop before the batch processor's export thread finishes), spans can appear dropped. Documented workarounds: call provider.force_flush(timeout_millis=…) explicitly before shutdown(), or use SimpleSpanProcessor in tests.

Notes

  • Pinned spec version bumped to v0.17.0. Per the additive-only governance rule (proposal 0024 adds; never renames), implementations passing v0.16.1 conformance fixtures continue to pass under v0.17.0; the new fixtures (012-021) add cases without modifying existing ones.

[0.7.0] — 2026-05-23

Docs-and-examples release. Pinned spec stays at v0.16.1; no proposals implemented this cycle. The focus was bringing the docs site, README, and examples up to par with the v0.6.0 implementation and filling reference-doc gaps that mkdocstrings was silently dropping.

Added

  • openarmature.graph.NextCall and openarmature.graph.default_classifier exports. Promoted from the openarmature.graph.middleware submodule. NextCall is the Protocol describing the next_ callable a middleware receives; default_classifier is the retry classifier's default predicate (matches category against TRANSIENT_CATEGORIES). Users writing custom middleware can type their next_ parameter and extend the default classifier without reaching into the submodule.
  • Middleware concept page. New docs/concepts/middleware.md covering the protocol shape, four registration sites (per-node, per-graph, per-branch, per-fan-out-instance), composition order, subgraph boundary, error semantics, and the built-in RetryMiddleware and TimingMiddleware.
  • Complete reference docs. Added docstrings to 35 previously-undocumented public members across graph, prompts, and checkpoint. mkdocstrings silently omits entries without a docstring, which meant the most fundamental builder methods (add_node, add_edge, set_entry, compile) and the entire Checkpointer backend method surface were invisible in the rendered reference. Every name in each subpackage's __all__ now renders.
  • Examples 05–09. New examples covering fan-out with retry, parallel branches, multimodal prompts, checkpointing with state migration, and tool use. Per-example docs pages with mermaid diagrams under docs/examples/. Examples 00–04 were scrubbed and standardized for consistency with the new set.
  • RELEASING.md. Documents the rc-first release flow (TestPyPI then PyPI), the tag-name dispatch rules, the pre-release checklist, rc iteration, and rollback via PyPI yank.
  • Docs site UX, nav, and reference cleanup. Sweep of nav structure, internal links, and reference page organization to match the v0.6.0 surface.

Changed

  • FanOutNode.run and ParallelBranchesNode.run raise NotImplementedError instead of RuntimeError. Both methods exist only to satisfy the Node protocol; the engine dispatches these node types through run_with_context. NotImplementedError is the right signal and stays backwards-compatible since it subclasses RuntimeError (existing except RuntimeError catches still work).

Notes

  • Pinned spec version unchanged at v0.16.1. No proposals landed this cycle; the release is docs- and examples-focused. The next functional release will resume with new spec proposals.

[0.6.0] — 2026-05-16

Consolidated release for the five-PR batch: structured output (proposal 0016), image content blocks (proposal 0015), prompt management (proposal 0017), state migration for checkpoints (proposal 0014), and parallel branches (proposal 0011). Pinned spec jumps from v0.10.0 to v0.16.1.

Added

  • Parallel branches (proposal 0011, introduced in spec v0.11.0; attempt-index propagation clarified in spec v0.16.1). New GraphBuilder.add_parallel_branches_node(name, *, branches, error_policy, errors_field, middleware) surface dispatches M heterogeneous compiled subgraphs concurrently per pipeline-utilities §11. BranchSpec (subgraph + inputs/outputs projection + branch middleware) and ParallelBranchesNode types exported from openarmature.graph. Branch insertion order determines fan-in merge order regardless of completion timing (§11.8). Two error policies: "fail_fast" raises ParallelBranchesBranchFailed (a NodeException subtype) with branch_name, original cause as __cause__, and recoverable_state carrying the parent's pre-dispatch snapshot — no buffered branch contributions are visible (§11.5 buffer-and-apply). "collect" records per-branch failures in an optional errors_field (each record carries branch_name + category + implementation-defined extras) and continues. Two new error categories: ParallelBranchesNoBranches (compile time, empty branches map) and ParallelBranchesBranchFailed (runtime, fail_fast branch raise).
  • NodeEvent.branch_name: str | None (proposal 0011 / graph-engine §6). Populated on events from nodes inside a parallel-branches branch, absent outside. Independent of fan_out_index — both may be present simultaneously when a branch contains a fan-out (or a fan-out instance contains a parallel-branches node). The combined (namespace, branch_name, fan_out_index, attempt_index, phase) tuple is the event-source uniqueness key.
  • openarmature.branch_name OTel span attribute. Mirrors the existing openarmature.node.fan_out_index. Emitted on synthesized inner-node spans when branch_name is populated on the event. The two attributes coexist on inner nodes of a fan-out-inside-a-branch composition.
  • Attempt-index ContextVar propagation through transitive retry (graph-engine §6 v0.16.1). Retry middleware now sets the attempt_index ContextVar before each next call; the engine reads current_attempt_index() when emitting events. This makes retry semantics symmetric across direct (per-node middleware) and transitive (instance / branch / fan-out instance_middleware) wrapping — events from inner nodes of a subgraph the retry re-invokes carry the wrapping retry's counter, not a freshly-zeroed inner counter. Innermost-wins precedence falls out of Python's ContextVar set/reset token stack. Pre-existing node-level retry behavior is unchanged.
  • State migration for checkpointed graphs (proposal 0014, introduced in spec v0.15.0; refined by proposal 0018 in spec v0.16.0). Saved checkpoints whose schema_version doesn't match the current state class now route through a registered migration chain instead of failing on resume. Surface: State.schema_version: ClassVar[str] = "" (declare a non-empty value to opt in), GraphBuilder.with_state_migration(from_version, to_version, migrate) and with_state_migrations(*migrations) for registration, StateMigration and MigrationRegistry types exported from openarmature.checkpoint. Chain resolution is BFS over the registered edges; the shortest path wins. Three new error categories: CheckpointStateMigrationChainAmbiguous (proposal 0018: duplicate (from, to) pair at registration time, or multiple distinct shortest paths between the saved and current versions at resume time), CheckpointStateMigrationMissing (no chain bridges the versions), and CheckpointStateMigrationFailed (a migration function raised). All non-transient. Post-migration deserialization failures still route to CheckpointRecordInvalid per §10.12.4. The same chain applies to each entry in parent_states in lockstep with the outer state per §10.12.2. Routing precedence per §10.10 (v0.16.0): chain-ambiguous → missing → failed → record-invalid.
  • Checkpointer.supports_state_migration Protocol attribute. Marks whether a backend can expose the structural intermediate form (a plain dict, JSON tree) the migration registry consumes. SQLiteCheckpointer(serialization="json") opts in; SQLiteCheckpointer(serialization="pickle") and InMemoryCheckpointer opt out. On version mismatch against a non-migration-eligible backend the engine raises CheckpointRecordInvalid per spec §10.12.1.
  • openarmature.checkpoint.migrate OTel span (proposal 0014 §6 cross-ref). Versioned resumes whose migration chain runs emit a zero-duration openarmature.checkpoint.migrate span on the OTel observer, parented under the invocation root span. Attributes: openarmature.checkpoint.migrate.from_version, openarmature.checkpoint.migrate.to_version (the final target), openarmature.checkpoint.migrate.chain_length. The §10.12.3 fast path (versions match, registry not consulted) emits no span. Engine-side: a synthetic checkpoint_migrated observer phase carries a _MigrationSummary payload from _migrate_record through to the OTel observer; the new phase is gated off default subscriptions (observers opt in explicitly via phases={..., "checkpoint_migrated"}).
  • Prompt-management capability (proposal 0017, introduced in spec v0.15.0). New openarmature.prompts subpackage. PromptManager composes one or more PromptBackends, exposes fetch / render / get, applies the §8 fallback semantics (prompt_store_unavailable continues to the next backend; prompt_not_found stops the chain), and renders templates with Jinja2's StrictUndefined per §7. Prompt / PromptResult / PromptGroup are Pydantic models matching spec §3 / §4 / §9. Three error categories (PromptNotFound, PromptRenderError, PromptStoreUnavailable) with PROMPT_TRANSIENT_CATEGORIES exported for retry-middleware classifiers. FilesystemPromptBackend is the minimum local-filesystem reference backend (layout: <root>/<label>/<name>.j2; version derived from the first 16 hex chars of template_hash). New runtime dependency: jinja2>=3.1.
  • openarmature.prompts.context — observability propagation per spec §11. with_active_prompt(result) and with_active_prompt_group(group) context managers + current_prompt_result() / current_prompt_group() inspectors. When the OTel observer is active and an LLM call fires inside with_active_prompt, the openarmature.llm.complete span carries the normative openarmature.prompt.* attributes (name, version, label, template_hash, rendered_hash, group_name). Nesting is innermost-wins.
  • Image content blocks for user messages (proposal 0015, introduced in spec v0.13.0). UserMessage.content now accepts str | list[ContentBlock]. The block surface introduces TextBlock, ImageBlock, ImageSourceURL, ImageSourceInline, and the ContentBlock / ImageSource discriminated unions over the block / source type field. ImageBlock carries a media_type (required for inline sources; ignored for URL sources; typed as str | None so callers MAY pass any image/* type the bound model supports) and an optional detail hint ("auto" / "low" / "high"; None default omits the field from the wire so providers apply their own default). System, assistant, and tool messages stay text-string-only; image inputs are user-only in v1.
  • OpenAIProvider content-array wire mapping. When UserMessage.content is a content-block sequence, the wire body uses OpenAI's content array per §8.1.1. TextBlock → {type: "text", text}. ImageBlock with a URL source maps to {type: "image_url", image_url: {url, detail?}}. ImageBlock with an inline source constructs an RFC 2397 data:<media_type>;base64,<base64_data> URI and goes through the same image_url entry shape. Inline bytes pass through unchanged — no inspection, transcoding, or re-encoding.
  • New error category ProviderUnsupportedContentBlock (non-transient). Raised when the bound model rejects a content block type / media variant. Distinct from ProviderInvalidRequest (which covers spec-shape malformation): this category surfaces a capability mismatch, letting callers route differently (e.g., fall back to a multimodal-capable provider) without overloading the malformed-request category. Carries block_type ("image" / "audio" / "video") and reason (provider's human-readable message) when those are recoverable from the rejection. OpenAIProvider detects content rejection via HTTP 400 bodies — heuristic on error.code (known set: image_content_not_supported, unsupported_image_media_type, audio_content_not_supported, etc.), error.type (image_parse_error), and error.message ("does not support" + image/audio/video).
  • Structured output (proposal 0016, introduced in spec v0.14.0). Provider.complete() now accepts an optional response_schema parameter — either a JSON Schema dict or a Pydantic BaseModel subclass. When supplied, the provider constrains the model's output to the schema and populates Response.parsed with the validated value (dict for dict-schema input, a BaseModel instance for class input). New StructuredOutputInvalid error category (non-transient by default) raises on JSON parse failure or schema validation failure; carries the requested schema, the raw response content, and a failure description.
  • OpenAIProvider native response_format wire path. When response_schema is supplied, the chat-completions request body carries response_format: { type: "json_schema", json_schema: { name, schema, strict } }. The strict flag is determined by a deep recursive walk over the schema (object-property required-coverage rule across anyOf / oneOf / allOf and $ref targets, with cycle protection); unresolvable refs fall through to strict: false. The name field uses schema.title when present, otherwise a deterministic sha256-prefix hash.
  • OpenAIProvider prompt-augmentation fallback. Constructor flag force_prompt_augmentation_fallback: bool (default False) and read-only inspect property uses_prompt_augmentation_fallback: bool. When the flag is on, structured-output calls build a fresh message list with a system directive containing the serialized schema, omit response_format from the wire, and validate the response post-receive. The caller's original messages list is never mutated. Use for OpenAI-compatible servers (older vLLM, some LM Studio releases, llama.cpp variants) that reject or silently ignore response_format.
  • Provider-agnostic schema helpers. openarmature.llm.validate_response_schema(schema) (raises ProviderInvalidRequest when the schema is not a dict with a top-level type: "object") and openarmature.llm.strict_mode_supported(schema) (the deep-tree strict-mode constraint check) are exported for reuse by future Anthropic/Gemini providers.
  • Capability-agnostic conformance harness helpers. tests/conformance/harness/wire.py adds match_wire_body (recursive deep-equal with "*" wildcard support), assert_response_format_absent, assert_system_references_schema, and assert_error_carries for the expected_wire_request[_checks] and expected.raises.carries.{...} fixture shapes. Used by the 0016 fixtures; available for the upcoming 0014 / 0015 / 0017 fixture sets.
  • Runtime dependency: jsonschema>=4.0. Used by the dict-schema validation path. The Pydantic-class path uses Pydantic's native validator and does not need jsonschema.

Changed

  • Pinned spec version: 0.10.0 → 0.16.1. Adopts the skip-ahead governance principle: the submodule jumps across v0.11.0–v0.16.1 (proposals 0009, 0011, 0014, 0015, 0016, 0017, 0018) in one bump. All five proposals (0011, 0014, 0015, 0016, 0017) are implemented in the batch's release; the v0.16.1 clarification of attempt-index propagation through transitive retry middleware lands with the proposal 0011 implementation.
  • CheckpointRecord.schema_version semantic shift (proposal 0014). Previously a backend-internal record-shape version (CHECKPOINT_SCHEMA_VERSION = "1" constant), now the user-facing state-schema version per spec §10.2. The framework reads type(state).schema_version at save time. Pre-PR-4 records carrying "1" are reinterpreted as user-facing v1 identifiers; users with such records either declare schema_version="1" on their state class or discard the pre-PR-4 records. SQLiteCheckpointer no longer rejects records with non-default schema_version at the backend boundary; version-mismatch routing is now an engine concern at resume time. The CHECKPOINT_SCHEMA_VERSION module constant is removed; future record-shape evolution can add backend-private metadata fields if needed.
  • NodeEvent.pre_state typed Any (was State). Required by the new checkpoint_migrated phase which carries a _MigrationSummary payload rather than a State instance. Observer authors who type-narrowed pre_state to State should treat it as Any and narrow per-phase (e.g., if event.phase == "completed": ...). The checkpoint_saved phase already carried a State-flavored shape (not necessarily a typed State subclass instance), so this widens the declared type to match runtime reality rather than introducing a new constraint.

Notes

  • Pre-1.0 MINOR. Two behavioral changes ship in this release:

    • Retry-MW attempt-index propagation. Events from inner nodes of a subgraph wrapped by retry middleware (branch middleware, fan-out instance_middleware, or any retry on a wrapping subgraph) now carry the wrapping retry's attempt counter on each re-invocation rather than starting at 0. Per-node retry behavior is unchanged. Matches spec v0.16.1's clarification of the graph-engine §6 contract.
    • CheckpointRecord.schema_version semantic shift. Previously a backend-internal record-shape version (the removed CHECKPOINT_SCHEMA_VERSION = "1" constant), now the user-facing state-schema version per spec §10.2. Pre-v0.6.0 records carrying "1" are reinterpreted as user-facing v1 identifiers; declare schema_version="1" on the corresponding state class or discard the records.

    Existing callers who don't wrap subgraphs in retry middleware and don't declare a state-schema version see no behavior change.

[0.5.0] — 2026-05-10

First release on real PyPI. Catches the implementation up from spec v0.5.x to v0.10.0 across six phases — the spec accepted eight proposals while the python lib was at v0.3.1, and v0.5.0 lands all of them in one curated drop.

Added

  • Typed conformance harness (Phase 0). Single parametrised test target driving all 68 spec fixtures under discriminated-union YAML parsers. Replaces the earlier hand-rolled per-fixture wiring.
  • Observer pair model (Phase 1, spec v0.6.0 / proposal 0005 §6). Observer Protocol (async callable), SubscribedObserver with phase subscription set ({"started", "completed", "checkpoint_saved"}), RemoveHandle.remove(), and a serial delivery queue per spec §6 ordering. Observer exceptions don't propagate; reported via warnings.warn.
  • Middleware (Phase 2, proposal 0004). Middleware Protocol with the canonical (state, next) → partial_update shape, compose_chain runtime, and five stdlib middlewares: RetryMiddleware, TimingMiddleware, ErrorRecoveryMiddleware, ShortCircuitMiddleware, TraceRecorderMiddleware. Per-graph and per-node middleware composition.
  • Fan-out runtime (Phase 3, proposal 0005 pipeline-utilities side). FanOutNode for parallel fan-out over an items_field or a count (int or callable resolver). Configurable concurrency, error policy (fail_fast / collect), inputs / extra_outputs projection, optional errors_field collection. Composes with retry middleware on the fan-out node and on per-instance subgraphs.
  • LLM provider (Phase 4, proposal 0006). New openarmature.llm package: Provider Protocol with ready() / complete(messages, tools=None, config=None); OpenAIProvider (HTTPX-based, OpenAI-compatible wire); typed Message / ToolCall / Tool / Response / RuntimeConfig; seven error categories (ProviderAuthentication, ProviderUnavailable, ProviderInvalidRequest, ProviderInvalidResponse, ProviderInvalidModel, ProviderModelNotLoaded, ProviderRateLimit with retry_after). Tool-call ids preserved verbatim through the wire.
  • Checkpointing (Phase 5, proposal 0008). Checkpointer Protocol (save / load / list / delete) with CheckpointRecord and NodePosition shapes; InMemoryCheckpointer reference impl; CheckpointNotFound / CheckpointRecordInvalid / CheckpointSaveFailed error categories; checkpoint_saved observer phase; resume-from-checkpoint semantics for fan-out and subgraph compositions.
  • Observability / OTel (Phase 6, proposal 0007). OTelObserver mapping observer events → OpenTelemetry spans with private TracerProvider (no global pollution); §4.4 detached subgraph + detached fan-out trace mode; §5.5 LLM-provider span emission with disable_llm_spans opt-out; §5.6 cross-cutting openarmature.correlation_id on every span; §10.8 checkpoint_saved zero-duration span. install_log_bridge wires the stdlib root logger through OTel's Logs Bridge (deprecation-aware via opentelemetry-instrumentation-logging) so log records emitted within an invocation carry the active span's trace_id/span_id plus openarmature.correlation_id. prepare_sync synchronous observer hook so logs emitted on the FIRST line of a node body (before any await) pick up the right span. Fan-out per-instance dispatch span synthesis (§5.4) with parent_node_name cached and applied per-instance.
  • current_correlation_id() public API. Read the per-invocation cross-backend join key from anywhere within the invocation's async call tree.
  • Subgraph configuration plural form. Builder accepts subgraphs: alongside subgraph: for fixture compatibility.

Changed

  • Pinned spec version: 0.5.x → 0.10.0. Lands proposals 0004 (middleware), 0005 (fan-out + observer pair model), 0006 (llm-provider), 0007 (observability/OTel), 0008 (checkpointing), 0011 (prepare_sync hook), 0012 (completed event after edge eval), 0013 (fan_out_config on NodeEvent).
  • Edge-resolution failures share the preceding node's event pair (spec v0.9.0 / proposal 0012). routing_error and edge_exception populate error on the preceding node's completed event with post_state=None instead of producing a separate pair. All five §4 runtime error categories now land via the same uniform mechanism.
  • Observer protocol contract. Async-only callable; phase-filtered delivery via SubscribedObserver.phases; serial single-task delivery worker; observer errors isolated via warnings.warn.

Fixed

  • Log bridge filter placement. Phase 6.0's _CorrelationIdFilter lived on the root logger; Python's logging propagation walks ancestor handlers but not ancestor filters, so child-logger records (the normal logging.getLogger("module") pattern) were missed. Replaced with a process-global LogRecord factory that fires uniformly at record construction.
  • OTelObserver concurrency-safe state scoping. Per-invocation span state now keyed by invocation_id so concurrent invocations sharing one observer instance don't collide on the in-flight span maps.
  • Spec submodule pin sync. Internal spec_version matched the submodule HEAD across phase boundaries; tracked via tests/test_smoke.py.

Notes

  • First real PyPI publish. Pre-release verification continues to flow through TestPyPI per docs/RELEASING.md. The pypi GitHub Environment requires a manual approval click before any real-PyPI upload — keep it on.
  • Pre-1.0 SemVer. Behavioral changes may land in MINOR bumps. Several Phase 1+ contracts changed shape vs. v0.4.0 — most user-visible: the observer pair model in Phase 1, the edge-resolution failure mechanism in Phase 6.1.
  • Cross-language posture. This release tracks spec v0.10.0; the OpenArmature TypeScript implementation will land separately under the same spec.