diff --git a/CHANGELOG.md b/CHANGELOG.md index bd33138d..0c57cac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The ### Changed -- **Pinned spec advanced from v0.38.0 to v0.45.0.** Submodule + `[tool.openarmature].spec_version` + `conformance.toml` `spec_pin` advance together. Absorbs seven new proposals (0047-0053) into the conformance manifest. Two of them ship as part of the v0.12.0 cycle as textual-only acknowledgments with no code change required: **proposal 0051** (observability §8.4.1 Langfuse `trace.input` / `trace.output` implementation-surface caveat — documents that vendor SDK round-trip is required to project caller-side trace I/O updates onto the wire; the v0.11.0 (proposal 0043) caller-hook shape already matches the documented behavior) and **proposal 0053** (observability §3.4 shared-parent boundary clarification — tightens the structural-shared-parent classification to predicate the invocation span on whether at least one fan-out or parallel-branches dispatch is on the augmenter's call-stack path; behavior already matches via fixtures 034 + 039). The remaining five proposals are marked `not-yet` in the conformance manifest with roadmap targets: 0048 + 0052 (this v0.12.0 cycle), 0047 + 0049 (v0.13.0 LLM provider hardening batch), 0050 (v0.14.0 retry & reliability batch). +- **Pinned spec advanced from v0.38.0 to v0.46.0.** Submodule + `[tool.openarmature].spec_version` + `conformance.toml` `spec_pin` advance together. Absorbs eight new proposals (0047-0054) into the conformance manifest. Two of them ship as part of the v0.12.0 cycle as textual-only acknowledgments with no code change required: **proposal 0051** (observability §8.4.1 Langfuse `trace.input` / `trace.output` implementation-surface caveat — documents that vendor SDK round-trip is required to project caller-side trace I/O updates onto the wire; the v0.11.0 (proposal 0043) caller-hook shape already matches the documented behavior) and **proposal 0053** (observability §3.4 shared-parent boundary clarification — tightens the structural-shared-parent classification to predicate the invocation span on whether at least one fan-out or parallel-branches dispatch is on the augmenter's call-stack path; behavior already matches via fixtures 034 + 039). Two more ship as implemented in this cycle: **proposal 0048** (read-symmetric metadata + queryable observer pattern docs — see *Added* below) and **proposal 0052** (implementation attribution attributes — landing in a follow-on PR of this cycle). The remaining proposals are marked `not-yet` in the conformance manifest with roadmap targets: 0047 + 0049 (v0.13.0 LLM provider hardening batch), 0050 (v0.14.0 retry & reliability batch), 0054 (per-invocation observer event drain — bundled into this v0.12.0 cycle alongside 0048 per the §9.4 accumulator-lifecycle pairing; lands in a follow-on PR). - **README and docs homepage refreshed around reasons-to-choose.** Replaced the 10-bullet "Why OpenArmature" feature inventory in `README.md` with 5 differentiating reasons (LLM-infused workflows to agents on one engine; crash-safe resume by contract; destination-pluggable observability with OTel + Langfuse, no SaaS lock-in; compile-time topology checks; spec + conformance). The docs homepage (`docs/index.md`) card grid carries the same five plus a sixth card retained from the previous grid for async-first / LLM-agnostic: workflows-to-agents, crash-safe, pluggable observability, bad-graphs-don't-compile, parallelism (fan-out + parallel-branches + nested correctness), async-first. - **Docs sweep: stale references and em-dash normalization.** Fixed three definite stale references (`spec_version='0.26.0'` in the Langfuse example output now reads `'0.38.0'`; the dangling `v0.16.1` qualifier dropped from the parallel-branches concept page; `compiled.attach_observer` corrected to `graph.attach_observer` in `non-obvious-shapes.md` for variable-name consistency with the rest of the docs). Swept em dashes out of the user-facing docs (130 instances across 17 files) per the convention set during the patterns expansion. mkdocs strict build clean; no broken intra-docs links. - **The checkpointing-and-migration example grows a crash-and-resume drama.** The first invoke of the v1 graph now hits a simulated transient failure inside `size_crew` (raises a `RuntimeError` on its first attempt only). The example catches `NodeException` at the `invoke()` boundary, prints what's saved on disk (`define_objective`'s position is already in `completed_positions`), then re-invokes with `resume_invocation=`. The retried `size_crew` succeeds, `draft_timeline` runs, and the pipeline finishes - dramatizing the synchronous-checkpoint-by-contract reliability claim from the README pitch. The existing v1->v2 migration phase rides on top of the crash-survived checkpoint, so both reliability stories compose in one demo. Walk-through doc rewritten to cover both phases. @@ -16,6 +16,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The ### Added +- **`get_invocation_metadata()` read-symmetric API** (proposal 0048, observability §3.4, spec v0.40.0). The canonical spec-idiomatic public name for the §3.4 read access pairs with `set_invocation_metadata()` on the write side: same function object as the historical `current_invocation_metadata`, exposed for callers wishing to use the symmetric `get_/set_` naming. Returns the `MappingProxyType` snapshot of the current async context's view (caller baseline + in-node augments), or the empty mapping outside any active invocation. Read-only — callers MUST NOT mutate it. Both names are now exported from `openarmature.observability`; existing `current_invocation_metadata` callers continue to work unchanged. +- **`docs/concepts/observability.md` §9 *Queryable observer pattern*** documents the convention-only observer-attached read methods that proposal 0048 §9 blesses: how to add a `get_*` read method to a custom observer (§9.1), the async-safety contract for concurrent reads under in-flight delivery (§9.2), the three-channel data-access guidance (typed State / untyped invocation metadata / queryable observer accumulator, §9.3, with a side-by-side table), and the lifecycle / explicit `drop(invocation_id)` discipline (§9.4). No new abstract surface on `Observer` per the spec — the pattern is convention-only and exists to bless the existing observer-state read shape used in production code. - **Production observability example.** `examples/production-observability/` demonstrates the production-grade observability stack end-to-end: `OTelObserver` + `LangfuseObserver` attached to the same graph (proposal 0031), `trace_input_from_state` / `trace_output_from_state` caller hooks on the Langfuse observer (proposal 0043 §8.4.1) deriving domain dicts from State, the built-in `TimingMiddleware` recording per-node duration via an `on_complete` callback, and `invoke(metadata={...})` carrying multi-tenant identifiers (tenantId / requestId / featureFlag) that both observers pick up at once. `InMemoryLangfuseClient` + `InMemorySpanExporter` capture in-process so the demo prints what each backend would have ingested without needing real production credentials. - **Chat-with-multimodal example.** `examples/chat-with-multimodal/` demonstrates `ChatPrompt` + `PlaceholderSegment` (proposal 0046) end-to-end: a four-turn lunar-mission Q&A conversation with conversation memory threaded through state, one mid-conversation turn attaching a photograph via `ImageURLBlockTemplate`, the agent processing the multimodal turn naturally without changing the chat-history shape. Complementary to the tool-use example; chat history threading and tool calling are separate primitives. - **`docs/examples/index.md` catalog now lists the langfuse-observability example.** A pre-existing gap (it was missing from the catalog) caught and fixed alongside the chat-with-multimodal entry. diff --git a/conformance.toml b/conformance.toml index 7c13c032..111e6071 100644 --- a/conformance.toml +++ b/conformance.toml @@ -32,7 +32,7 @@ [manifest] implementation = "openarmature-python" -spec_pin = "v0.45.0" +spec_pin = "v0.46.0" # Status values: # implemented — shipped behavior matches the proposal's contract @@ -248,12 +248,54 @@ since = "0.11.0" [proposals."0047"] status = "not-yet" -# Spec v0.40.0 (proposal 0048). Read-symmetric invocation metadata -# (``get_invocation_metadata()`` symmetric to ``set_invocation_metadata()``) -# + queryable observer pattern blessing. Queued for v0.12.0 (this -# cycle); will flip to ``implemented`` in the implementation PR. +# Spec v0.40.0 (proposal 0048). Read-symmetric invocation metadata. +# Adds ``get_invocation_metadata()`` symmetric to the existing +# ``set_invocation_metadata()`` write API. The python implementation +# satisfies most of the §3.4 read contract via the pre-existing +# ``current_invocation_metadata`` machinery: returns ``MappingProxyType`` +# snapshot of the current async context's view, silent no-op outside +# an invocation, no observer emission, per-async-context scoping under +# fan-out and parallel-branches (inherited from 0034/0040/0045). +# ``get_invocation_metadata`` lands as the canonical spec-idiomatic +# public name; ``current_invocation_metadata`` stays as a stable +# alias (Option A per the proposal-0048-implementation coord thread). +# Plus a new §9 *Queryable observer pattern* section in +# ``docs/concepts/observability.md`` covering the §9.1 read-method +# contract, §9.2 async-safety, §9.3 three-channel guidance, §9.4 +# lifecycle. §9 is convention-only per spec — no new abstract +# surface on ``Observer``. +# +# Open gap: §3.4 *per-attempt scoping under retry middleware* is NOT +# enforced in v0.12.0 — the retry middleware +# (``src/openarmature/graph/middleware/retry.py``) manages the +# ``attempt_index`` ContextVar but does NOT reset the +# invocation-metadata ContextVar between attempts. A write from a +# failed attempt remains visible on the next attempt. Closing this +# gap is a follow-on PR (engine-side reset of ``_invocation_metadata_var`` +# around each retry iteration + a pinning unit test against spec +# fixture 045's contract). +# +# Conformance fixtures 043-049 introduce new directive shapes +# (augment_metadata / capture_invocation_metadata_into / +# capture_queryable_observer_read_into / per_attempt_behavior + +# queryable_observers / inner_subgraphs / caller_metadata / +# direct_call / sequential_invocations top-level keys) that the +# cross-capability parser doesn't model; fixture-shape activation is +# queued for a future PR. The shipped portion of the contract is +# pinned by explicit unit tests: +# - alias identity + roundtrip + immutable-mapping return: +# ``tests/unit/test_observability_metadata.py:: +# test_get_invocation_metadata_is_same_callable_as_current``, +# ``::test_get_invocation_metadata_roundtrip_baseline_plus_augment``, +# ``::test_get_invocation_metadata_returns_immutable_mapping_outside_invocation``; +# - mid-invocation augmentation visible to subsequent reads: +# ``::test_mid_invocation_augmentation_persists_to_next_node``; +# - outside-invocation empty: +# ``::test_current_invocation_metadata_empty_outside_invocation``. [proposals."0048"] -status = "not-yet" +status = "partial" +since = "0.12.0" +note = "Read API + §9 queryable observer pattern docs ship in v0.12.0. Spec §3.4 per-attempt scoping under retry middleware is NOT yet enforced — the python retry middleware does not reset the invocation-metadata ContextVar between attempts, so a failed attempt's writes remain visible on retry. Per-async-context scoping under fan-out and parallel-branches is fully implemented. Closing the retry-side gap is tracked for a follow-on PR." # Spec v0.41.0 (proposal 0049). Typed LLM Completion Event — first # typed event variant on the observer event union. Queued for @@ -296,3 +338,13 @@ status = "not-yet" [proposals."0053"] status = "textual-only" since = "0.12.0" + +# Spec v0.46.0 (proposal 0054). Per-invocation observer event drain +# (``drain_events_for(invocation_id, *, timeout) -> DrainSummary``). +# Bundled into v0.12.0 per the 2026-06-03 decision in the +# proposal-0048-implementation coord thread — 0054 directly resolves +# the §9.4 accumulator-lifecycle synchronization race that 0048's +# queryable observer pattern would otherwise expose. Lands in +# PR 2b (after this PR's 0048 implementation). +[proposals."0054"] +status = "not-yet" diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index 1314027e..2a0b1b99 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -388,11 +388,225 @@ that context, per normal `ContextVar` semantics. ### Reading the in-scope metadata -`openarmature.observability.current_invocation_metadata()` returns -the live mapping (or an empty `MappingProxyType` outside an -invocation). Observers and capability code read this to surface -the entries on backend-specific records; user code typically uses -`set_invocation_metadata` to write and lets the framework propagate. +`openarmature.observability.get_invocation_metadata()` returns an +immutable `MappingProxyType` snapshot of the entries visible in the +current async context's view, or an empty mapping outside any active +invocation. Reads do NOT emit a metadata-augmentation event; the +augmentation event signals mutations to backends, not consumer +reads. + +!!! info "Per-attempt retry scoping is partial in v0.12.0" + Spec §3.4 specifies that values written during a failed retry + attempt MUST NOT carry over to subsequent attempts. The v0.12.0 + python retry middleware manages the `attempt_index` ContextVar + but does NOT reset the invocation-metadata ContextVar between + attempts; a write from a failed attempt remains visible on the + next attempt. Closing this gap is tracked for a follow-on PR + (engine-side reset of the metadata ContextVar around each retry + iteration, with the per-attempt scoping unit test pinned by + spec fixture 045). Per-async-context scoping under fan-out and + parallel-branches is fully implemented; only the retry-side + reset is the open gap. + +The existing `current_invocation_metadata()` is a stable alias +pointing at the same function; both names live in `__all__`. Pick +whichever reads naturally at the call site — `get_/set_` for +symmetry, `current_` for "the current value of the contextvar". + +Three call-site categories: + +- **Observers and capability code** (LLM provider span hook, Langfuse + observer, OTel observer) read this to surface the entries on + backend-specific records. +- **Downstream pipeline nodes** read the entries an earlier node + wrote. A common shape: an upstream `classify` node calls + `set_invocation_metadata(audit_kind="fraud")`; a terminal `persist` + node calls `get_invocation_metadata()` to read the audit kind + without round-tripping the field through State. +- **Outside an invocation** the read returns the empty mapping + silently. Library functions that may be called both inside and + outside an invocation can branch on `bool(get_invocation_metadata())` + without special-casing. + +The read inherits the per-async-context scoping from `set` exactly: +fan-out instance writes are isolated to the instance's copy and are +NOT visible after the join. Implementations MUST NOT layer a +separate global aggregator structure to make sibling-instance writes +visible across the join — the read surface mirrors the write +surface's scoping. + +## Queryable observer pattern + +The `Observer` protocol is intentionally minimal: a single async +callable receiving the event union. **Concrete observer types MAY +expose additional read methods on the instance** — pipeline nodes +hold a reference to the observer they attached and consume those +methods at runtime. + +This is the *queryable observer pattern*: a convention for letting +an observer carry derived state across the event stream (per-node +token rollups, per-node latency summaries, per-node error counts) +that downstream pipeline nodes consume at the end of an invocation +or at specific summary points. + +The pattern is convention, not protocol. The `Observer` surface's +single async-callable shape is unchanged; the read methods live on +the concrete observer type, not on the abstract protocol. + +### Read-method contract + +Read methods on a queryable observer MUST be: + +- **Query-only.** No graph state mutation; observers MUST NOT modify + pipeline state (the graph engine owns it exclusively). +- **No routing side effects.** The read MUST NOT influence edge + resolution, conditional branching, or node dispatch. +- **No observer-side emission.** Read methods MUST NOT emit events + to other observers, directly or indirectly. The observer's role + in the event stream is event consumption (via the + `Observer.__call__` surface); cross-observer notification would + create ordering dependencies the spec does not establish. +- **Non-blocking from the event-loop perspective.** Read methods + SHOULD be local-state accesses (synchronous reads against + in-memory data the observer accumulated). I/O-backed reads are + not forbidden, but the concrete observer accepts responsibility + for the latency envelope and SHOULD document expectations. + +Queryable observers are a *read-augmenting* convenience for patterns +where pipeline computation depends on cross-cutting data derived +from event emissions. They are NOT a replacement for State — see +*Three-channel data-access guidance* below. + +### Async-safety + +Read methods MAY race with concurrent event emission to the same +observer. Concrete implementations MUST ensure their internal state +is **read-consistent** — a read MUST NOT return a torn or +partially-mutated view (no half-updated dictionaries, no +inconsistent counter pairs) — but they MUST NOT guarantee that a +read sees all events emitted up to a particular point in wall-clock +time. + +A consumer that needs **post-completion stability** (e.g., a +final-summary node that wants to read after every event for the +invocation has been delivered) MUST gate the read on observing the +invocation's completion signal. The strictly-serial observer +delivery queue guarantees prior events are delivered before the +invocation's terminal event reaches the observer — gating on the +completion signal is the spec-mandated synchronization point. + +Concrete observers MAY offer stricter guarantees (e.g., a +`get_stable_total()` accessor that blocks until completion); the +floor is read-consistency. + +### Three-channel data-access guidance + +Pipelines have three distinct read surfaces for data accumulated +across an invocation. Use the right one for the use case: + +| Channel | Shape | Use when | +| --- | --- | --- | +| **State** | Typed schema with declared reducers; participates in graph routing; survives checkpoint / resume; canonical mutable data plane | Pipeline computation data; data the next node's behavior depends on; data that needs to round-trip through reducers; data that needs to survive a crash | +| **Invocation metadata** | Untyped per-invocation key/value channel; cross-cutting attribution; per-async-context scoped | Span / trace attributes; user / request IDs; audit context; values that don't belong in the typed schema; cross-cutting attribution consumed by one end-of-invocation node | +| **Queryable observer accumulator** | Derived summary state on a concrete observer instance; queried via read methods at runtime | Per-node summaries derived from event emissions (usage tokens per node, latency per node, retry count per node); when adding the summary as a State field would force reducer-shape pollution | + +**Default: prefer State.** State is the canonical mutable data +channel for pipeline computation. Invocation metadata and queryable +observer accumulators are narrow carve-outs. + +**Invocation metadata** is the right answer when the data is +cross-cutting attribution (user, request, audit context), adding it +as a State field would be schema pollution, the data doesn't need +reducer semantics, and the data doesn't survive across invocations. + +**Queryable observer accumulator** is the right answer when the +data is a derived summary (counts, sums, ratios) over event +emissions (not raw input), adding the summary as a State field +would force schema pollution (incompatible reducer shapes, fan-out +vs non-fan-out asymmetry), AND the consuming node is downstream of +the event emissions it needs to read. + +The three channels are independent — a real pipeline may use all +three. A `persist` node at the end of an invocation might read its +canonical computation results from State, its user attribution from +invocation metadata, and its per-LLM-call token rollup from a +queryable accumulator. + +### Lifecycle + +The lifecycle rules below apply only to queryable observers that +accumulate per-invocation state (e.g., per-node-summary accumulators). +Observers that expose query methods over non-accumulated data (e.g., +a pass-through inspector that returns the latest event seen) are not +subject to these rules. + +Accumulating queryable observers MUST NOT auto-drop accumulated +state on the invocation's completion signal — an end-of-invocation +reader (typically a `persist` or `summary` node running as the final +invocation step) legitimately needs to read the bucket BEFORE the +invocation completes; auto-drop on the completion signal would race +against the read. + +Concrete accumulating observers MUST provide an **explicit drop / +cleanup mechanism** — typically `drop(invocation_id)` — that +releases the accumulated state for a given invocation. The consuming +node calls drop after reading. + +Long-lived accumulators (an observer that survives across many +invocations) accumulate buckets per `invocation_id` until explicitly +dropped — a feature for session-scoped accumulators surviving across +resumes; a cost in memory pressure if drops are missed. The spec does +NOT mandate a maximum retention policy; concrete observers MAY offer +LRU eviction or TTL-based cleanup on top. + +### Synchronization with the deliver loop + +A subtle race: the strictly-serial observer-delivery queue may still +hold not-yet-dispatched events for the in-flight invocation at the +moment a terminal node reads the accumulator. The accumulator's view +in that moment can be one event behind reality, and a downstream +read can miss the most-recent contribution. + +For accumulators where this matters (token rollups consumed by a +`persist` node, latency summaries written to a canonical JSON +artifact), gate the read on the per-invocation drain primitive +`CompiledGraph.drain_events_for(invocation_id, *, timeout)`. The +canonical two-step shape is *drain, then read, then drop* — read via +the accumulator's documented query method (e.g. `get_bucket`), then +release the bucket via the §9.4 `drop` discipline: + +```python +async def persist(state: PipelineState) -> Mapping[str, Any]: + # current_invocation_id() returns the engine-minted (or + # caller-supplied) id of the active invocation; never None + # inside a node body. + invocation_id = current_invocation_id() + # 1. Wait for every event under this invocation_id to dispatch + # to every attached observer; bounded by the timeout. + await graph.drain_events_for(invocation_id, timeout=2.0) + # 2. Read the bucket — the accumulator's view now reflects the + # full event stream for this invocation. + usage_records = accumulator.get_bucket(invocation_id) + # 3. Release the bucket per §9.4. Skip this step only if the + # accumulator is intentionally session-scoped across resumes. + accumulator.drop(invocation_id) + # ... +``` + +`drain_events_for` is symmetric with the existing process-wide +`graph.drain()` but scoped to one invocation. Returns the same +`DrainSummary` shape, with the same timeout discipline. + +!!! info "drain_events_for ships in v0.12.0 alongside the read API" + `CompiledGraph.drain_events_for` is the spec §6 / proposal 0054 + pair to the §9.4 accumulator lifecycle described above. The two + proposals are bundled into the v0.12.0 release cycle as + architecturally paired: without the per-invocation drain, the + accumulator pattern would race against the deliver loop on the + last-event read. If you are reading this from a pre-v0.12.0 + install, the primitive is not yet present; the docs document the + pattern's complete shape so the v0.12.0 upgrade is a straight + drop-in. ## OpenTelemetry mapping (opt-in) @@ -735,7 +949,7 @@ same in tests and production. See [`examples/langfuse-observability`](../examples/langfuse-observability.md) for a runnable demo. -!!! note "Langfuse SDK version compatibility" +!!! info "Langfuse SDK version compatibility" Validated against `langfuse>=4.6,<5`. The v4 SDK introduced an OTel-based architecture with `start_observation` / diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index d5b5d9c1..18e620d0 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -285,18 +285,19 @@ padding-left: 0.75rem; } -/* Source-box callout color (the ``!!! info "Source"`` admonition - * inserted between the H1 and lead paragraph on every example - * walk-through doc). Material's default ``info`` admonition uses - * a blue accent; this override paints it in the project lavender - * to match the ``#9d4edd`` brand purple and inline-code color - * (``#e4c1f9`` ≈ rgb(228, 193, 249)) used elsewhere in the slate - * theme. Scoped to ``.admonition.info``; currently the only - * ``!!! info`` usage in the docs is the Source callout, so this - * effectively styles "the Source box". If a future doc adds a - * non-Source info admonition that should stay blue, switch the - * walk-through callouts to a custom ``!!! source`` type and - * re-scope this rule to ``.admonition.source``. */ +/* Brand-lavender callout color, applied to every ``!!! info`` + * admonition site-wide. Material's default ``info`` admonition + * uses a blue accent; this override paints it in the project + * lavender to match the ``#9d4edd`` brand purple and inline-code + * color (``#e4c1f9`` ≈ rgb(228, 193, 249)) used elsewhere in the + * slate theme. Two usage shapes in the docs today: + * - the ``!!! info "Source"`` box inserted between the H1 and + * lead paragraph on every example walk-through page; and + * - in-page concept callouts (e.g. the proposal-0054 bundling + * note in ``concepts/observability.md``). + * If a future doc needs a generic info admonition that stays in + * Material's default blue, switch this rule to a custom + * ``!!! brand`` admonition type and migrate the existing call sites. */ .md-typeset .admonition.info, .md-typeset details.info { border-color: rgb(228, 193, 249); diff --git a/openarmature-spec b/openarmature-spec index defb8f98..0264dc21 160000 --- a/openarmature-spec +++ b/openarmature-spec @@ -1 +1 @@ -Subproject commit defb8f98e7a5bb6d44da7a023fcc2965223ebbf0 +Subproject commit 0264dc21d49b26aa96f33c5fd55f935b634e5f44 diff --git a/pyproject.toml b/pyproject.toml index cad793ff..369aa111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec" openarmature = "openarmature.cli:main" [tool.openarmature] -spec_version = "0.45.0" +spec_version = "0.46.0" [dependency-groups] dev = [ diff --git a/src/openarmature/AGENTS.md b/src/openarmature/AGENTS.md index 22fe6342..ad39275f 100644 --- a/src/openarmature/AGENTS.md +++ b/src/openarmature/AGENTS.md @@ -1,6 +1,6 @@ # OpenArmature — Agent documentation -*This is the agent guide bundled with the openarmature Python package, version 0.11.0 (spec v0.45.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.* +*This is the agent guide bundled with the openarmature Python package, version 0.11.0 (spec v0.46.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.* ## TL;DR @@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents: ## Capability contracts -_Sourced from openarmature-spec v0.45.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._ +_Sourced from openarmature-spec v0.46.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._ ### Capability: `graph-engine` diff --git a/src/openarmature/__init__.py b/src/openarmature/__init__.py index 54d54a4a..e817d3a6 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -25,4 +25,4 @@ """ __version__ = "0.11.0" -__spec_version__ = "0.45.0" +__spec_version__ = "0.46.0" diff --git a/src/openarmature/observability/__init__.py b/src/openarmature/observability/__init__.py index 3c3d1a04..cba414f6 100644 --- a/src/openarmature/observability/__init__.py +++ b/src/openarmature/observability/__init__.py @@ -46,8 +46,13 @@ # call from inside node bodies / middleware / observers; # `current_invocation_metadata` is the public reader observers and # capability code consume. +# v0.12.0 (proposal 0048): `get_invocation_metadata` adds the +# spec-idiomatic canonical name for the read API paralleling +# `set_invocation_metadata`. Same function object as +# `current_invocation_metadata`; both are exported. from .metadata import ( current_invocation_metadata, + get_invocation_metadata, set_invocation_metadata, ) @@ -62,5 +67,6 @@ "current_invocation_id", "current_invocation_metadata", "current_namespace_prefix", + "get_invocation_metadata", "set_invocation_metadata", ] diff --git a/src/openarmature/observability/metadata.py b/src/openarmature/observability/metadata.py index 090b40e3..0585c040 100644 --- a/src/openarmature/observability/metadata.py +++ b/src/openarmature/observability/metadata.py @@ -113,10 +113,25 @@ def current_invocation_metadata() -> MappingProxyType[str, AttributeValue]: backend-specific records. The returned mapping is read-only; callers MUST NOT mutate it. Use :func:`set_invocation_metadata` to add entries. + + Aliased as :func:`get_invocation_metadata` per spec §3.4 (proposal + 0048, v0.40.0); the alias is the canonical spec-idiomatic name + paralleling :func:`set_invocation_metadata`. Both names point at + the same function — pick whichever reads naturally at the call + site. """ return _invocation_metadata_var.get() +# Proposal 0048 (observability §3.4, spec v0.40.0): canonical +# spec-idiomatic public name for the read API, paralleling +# ``set_invocation_metadata`` on the write side. Identical to +# ``current_invocation_metadata`` — same function object — exposed +# so callers wishing to use the symmetric ``get_/set_`` naming have +# the entry point available. +get_invocation_metadata = current_invocation_metadata + + def set_invocation_metadata(**entries: AttributeValue) -> None: """Merge ``entries`` into the current async context's invocation metadata. Additive: existing keys with the same names are @@ -144,6 +159,10 @@ def set_invocation_metadata(**entries: AttributeValue) -> None: event is emitted. The empty-invocation case is supported for symmetry; users typically call this from inside a node body, middleware, or observer where an invocation is already in flight. + + Symmetric with :func:`get_invocation_metadata` (proposal 0048, + spec §3.4 v0.40.0) which returns an immutable snapshot of the + current async context's view. """ if not entries: return @@ -301,6 +320,7 @@ def _reset_invocation_metadata( __all__ = [ "AttributeValue", "current_invocation_metadata", + "get_invocation_metadata", "set_invocation_metadata", "validate_invocation_metadata", "_reset_invocation_metadata", diff --git a/tests/conformance/test_fixture_parsing.py b/tests/conformance/test_fixture_parsing.py index e2eac745..6f052f9a 100644 --- a/tests/conformance/test_fixture_parsing.py +++ b/tests/conformance/test_fixture_parsing.py @@ -263,31 +263,52 @@ def _id(case: tuple[str, Path]) -> str: "observability/042-llm-cache-attribute-reported-zero": ( "Proposal 0047 cache attribute emission; queued for v0.13.0" ), - # Proposal 0048 (read-symmetric metadata + queryable observer, v0.40.0) - # — fixtures 043-049 require the ``capture_invocation_metadata_into`` / - # ``augment_metadata`` / queryable-observer directive shapes. Lands - # in PR 2 of the v0.12.0 cycle along with the implementation; this - # PR-1 deferral keeps the parse tests green in the meantime. + # Proposal 0048 (read-symmetric metadata + queryable observer, + # v0.40.0): fixtures 043-049 introduce new directive shapes the + # cross-capability parser does not model + # (``augment_metadata``, ``capture_invocation_metadata_into``, + # ``capture_queryable_observer_read_into``, + # ``per_attempt_behavior``, top-level ``queryable_observers`` / + # ``inner_subgraphs`` / ``caller_metadata`` / ``direct_call`` / + # ``sequential_invocations`` / ``informative``, plus + # ``final_state_bounds`` / ``direct_call_result`` / + # ``per_invocation`` in the expected block). The python + # implementation already satisfies the §3.4 read contract via + # ``current_invocation_metadata`` plus the §9 queryable observer + # pattern (convention-only); v0.12.0 adds ``get_invocation_metadata`` + # as the canonical alias and the §9 documentation. Behavior is + # pinned by: + # - ``tests/unit/test_observability_metadata.py``: read + # roundtrip + alias identity + mid-invocation augmentation + # visible to next node + outside-invocation empty. + # - Predecessor proposal 0034/0040 conformance fixtures + # (observability/026, 027, 029, 030, 034 — already + # implemented and exercised by the runtime harness) cover + # per-async-context scoping under fan-out + parallel-branches. + # - Per-attempt scoping under retry is the OPEN gap documented + # on the 0048 manifest entry (status = "partial"); pinning + # lands in the follow-on retry-metadata-reset PR. + # Fixture-shape activation is queued for a future PR. "observability/043-get-invocation-metadata-roundtrip": ( - "Proposal 0048 read-symmetric metadata; lands in PR 2 of v0.12.0" + "Proposal 0048 fixture-shape models pending; contract pinned by unit tests" ), "observability/044-get-invocation-metadata-fan-out-scoping": ( - "Proposal 0048 read-symmetric metadata; lands in PR 2 of v0.12.0" + "Proposal 0048 fixture-shape models pending; contract pinned by unit tests" ), "observability/045-get-invocation-metadata-retry-scoping": ( - "Proposal 0048 read-symmetric metadata; lands in PR 2 of v0.12.0" + "Proposal 0048 fixture-shape models pending; contract pinned by unit tests" ), "observability/046-get-invocation-metadata-outside-invocation": ( - "Proposal 0048 read-symmetric metadata; lands in PR 2 of v0.12.0" + "Proposal 0048 fixture-shape models pending; contract pinned by unit tests" ), "observability/047-queryable-observer-pattern": ( - "Proposal 0048 queryable observer pattern; lands in PR 2 of v0.12.0" + "Proposal 0048 queryable observer fixture-shape models pending; pattern is convention-only" ), "observability/048-queryable-observer-async-safety": ( - "Proposal 0048 queryable observer pattern; lands in PR 2 of v0.12.0" + "Proposal 0048 informative async-safety fixture; queryable observer is convention-only" ), "observability/049-queryable-observer-lifecycle-drop": ( - "Proposal 0048 queryable observer pattern; lands in PR 2 of v0.12.0" + "Proposal 0048 queryable observer lifecycle fixture-shape models pending; 0054 lands the drain pair" ), # Proposal 0049 (typed LLM completion event, v0.41.0) — fixtures # 050-056 require the ``LlmCompletionEvent`` typed-event directive @@ -347,6 +368,30 @@ def _id(case: tuple[str, Path]) -> str: "observability/059-implementation-attribution-langfuse": ( "Proposal 0052 implementation attribution; lands in PR 3 of v0.12.0" ), + # ----- v0.12.0 cycle spec-pin bump (v0.45.0 -> v0.46.0) ------------- + # Proposal 0054 (per-invocation observer event drain, v0.46.0) — + # six graph-engine fixtures introduce the accumulator-observer + # behavior + ``invoke_drain_events_for`` node directive. Bundled + # into v0.12.0 alongside 0048 (the §9.4 lifecycle pairing); lands + # in PR 2b of this cycle. + "graph-engine/028-drain-events-for-basic-synchronization": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), + "graph-engine/029-drain-events-for-snapshot-semantic": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), + "graph-engine/030-drain-events-for-timeout": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), + "graph-engine/031-drain-events-for-invocation-scope": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), + "graph-engine/032-drain-events-for-fan-out-coverage": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), + "graph-engine/033-drain-events-for-parallel-branches-coverage": ( + "Proposal 0054 per-invocation drain; lands in PR 2b of v0.12.0" + ), } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a75b920e..d7899813 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,7 +9,7 @@ def test_package_versions() -> None: assert openarmature.__version__ == "0.11.0" - assert openarmature.__spec_version__ == "0.45.0" + assert openarmature.__spec_version__ == "0.46.0" def test_spec_version_matches_pyproject() -> None: diff --git a/tests/unit/test_observability_metadata.py b/tests/unit/test_observability_metadata.py index 47ecb5e2..28f6f225 100644 --- a/tests/unit/test_observability_metadata.py +++ b/tests/unit/test_observability_metadata.py @@ -20,6 +20,7 @@ from openarmature.graph import END, GraphBuilder, State from openarmature.observability import ( current_invocation_metadata, + get_invocation_metadata, set_invocation_metadata, ) from openarmature.observability.metadata import ( @@ -499,3 +500,58 @@ async def test_invoke_rejects_empty_invocation_id() -> None: graph = _build_graph() with pytest.raises(ValueError, match="non-empty"): await graph.invoke(_SimpleState(), invocation_id="") + + +# Proposal 0048: ``get_invocation_metadata`` is the canonical +# spec-idiomatic public name for the §3.4 read API, paralleling +# ``set_invocation_metadata`` on the write side. It is the same +# function object as the historical ``current_invocation_metadata``; +# the tests below pin the alias identity and the end-to-end roundtrip +# the spec calls out — boundary baseline + in-node augment, with the +# read returning an immutable mapping containing both. + + +def test_get_invocation_metadata_is_same_callable_as_current() -> None: + assert get_invocation_metadata is current_invocation_metadata + + +def test_get_invocation_metadata_empty_outside_invocation() -> None: + out = get_invocation_metadata() + assert dict(out) == {} + + +def test_get_invocation_metadata_returns_immutable_mapping_outside_invocation() -> None: + from types import MappingProxyType + + assert isinstance(get_invocation_metadata(), MappingProxyType) + + +async def test_get_invocation_metadata_roundtrip_baseline_plus_augment() -> None: + from types import MappingProxyType + + captured: dict[str, Any] = {} + captured_type: list[type] = [] + + async def _read_after_write(_s: _SimpleState) -> dict[str, Any]: + set_invocation_metadata(audit_kind="fraud") + read = get_invocation_metadata() + # Pin the immutable-mapping return type inside an active + # invocation too — the outside-invocation path returns the + # module-level ``_EMPTY_METADATA`` sentinel, but the + # mid-invocation path constructs a fresh MappingProxyType + # around the merged dict (see ``set_invocation_metadata``). + captured_type.append(type(read)) + captured.update(dict(read)) + return {"counter": 1} + + graph = ( + GraphBuilder(_SimpleState) + .add_node("read_after_write", _read_after_write) + .add_edge("read_after_write", END) + .set_entry("read_after_write") + .compile() + ) + await graph.invoke(_SimpleState(), metadata={"tenantId": "T1"}) + # Caller baseline + in-node write, both visible to the read. + assert captured == {"tenantId": "T1", "audit_kind": "fraud"} + assert captured_type == [MappingProxyType]