From 232ccd1d04c9f4d917338afc3fadd1d11a5c405a Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Fri, 29 May 2026 18:55:53 -0700 Subject: [PATCH] Implement proposal 0042 (reserved keys) Extends the observability spec.4 reserved exact-key-name set from 21 to 24 entries with `branch_name`, `detached`, and `detached_from_invocation_id`. 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 at the boundary, the same hazard 0041 closed for its 20 names. Also relocates `observation.metadata.detached: true` from the detached-side dispatch observation onto the parent-side dispatching observation in the main trace (link observation for detached subgraphs; parent fan-out node observation for detached fan-outs), matching the .4.2 row 0042 added and the corresponding fixture 033 assertions. Bumps the spec pin from v0.31.0 to v0.34.0, absorbing 0042 plus the two textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented). Updates `conformance.toml` accordingly: 0040 flipped not-yet to implemented (shipped in PR #96); 0042 added as implemented; 0020 and 0038 added as not-yet. Defers the 10 new Gemini conformance fixtures in both the cross-capability parser and the LLM-provider harness to match the not-yet status. --- CHANGELOG.md | 11 ++++ conformance.toml | 19 +++++-- openarmature-spec | 2 +- pyproject.toml | 2 +- src/openarmature/AGENTS.md | 4 +- src/openarmature/__init__.py | 2 +- .../observability/langfuse/observer.py | 23 +++++++- src/openarmature/observability/metadata.py | 12 +++-- tests/conformance/test_fixture_parsing.py | 35 ++++++++++++ tests/conformance/test_llm_provider.py | 13 +++++ tests/test_smoke.py | 2 +- tests/unit/test_observability_metadata.py | 53 +++++++++++++++++++ 12 files changed, 162 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4699db1..9bbdc42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to `openarmature-python` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The package follows [Semantic Versioning](https://semver.org/); pre-1.0 minor bumps may carry behavioral changes per [spec governance](https://github.com/LunarCommand/openarmature-spec/blob/main/GOVERNANCE.md). +## [Unreleased] + +### Changed + +- **Reserved-key extension** (proposal 0042, observability §3.4). 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. +- **`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. + +### Notes + +- **Pinned spec version bumped from v0.31.0 to v0.34.0.** Absorbs proposals 0042 (reserved-key extension; observation.metadata.detached + branch_name + trace.metadata.detached_from_invocation_id rows), 0038 (Google Gemini wire-format mapping — not yet implemented in python), and 0020 (sessions capability — not yet implemented in python). + ## [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. diff --git a/conformance.toml b/conformance.toml index 850cce00..9d8f0eef 100644 --- a/conformance.toml +++ b/conformance.toml @@ -32,7 +32,7 @@ [manifest] implementation = "openarmature-python" -spec_pin = "v0.31.0" +spec_pin = "v0.34.0" # Status values: # implemented — shipped behavior matches the proposal's contract @@ -179,8 +179,7 @@ since = "0.10.0" status = "implemented" since = "0.10.0" -# Spec v0.28.0-v0.31.0 (proposals 0037, 0039, 0040, 0041). 0038 -# (Gemini) is mid-accept on spec side and not in v0.31.0 yet. +# Spec v0.28.0-v0.31.0 (proposals 0037, 0039, 0040, 0041). [proposals."0037"] status = "not-yet" @@ -189,8 +188,20 @@ status = "implemented" since = "0.11.0" [proposals."0040"] -status = "not-yet" +status = "implemented" +since = "0.11.0" [proposals."0041"] status = "implemented" since = "0.11.0" + +# Spec v0.32.0-v0.34.0 (proposals 0038, 0020, 0042). +[proposals."0038"] +status = "not-yet" + +[proposals."0020"] +status = "not-yet" + +[proposals."0042"] +status = "implemented" +since = "0.11.0" diff --git a/openarmature-spec b/openarmature-spec index 064a39ae..37e519c5 160000 --- a/openarmature-spec +++ b/openarmature-spec @@ -1 +1 @@ -Subproject commit 064a39ae77394fb965541823a1ed40b50b806b00 +Subproject commit 37e519c5a04630db65aea2be0a88fba0314b8972 diff --git a/pyproject.toml b/pyproject.toml index 925529fe..4d2c054e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec" openarmature = "openarmature.cli:main" [tool.openarmature] -spec_version = "0.31.0" +spec_version = "0.34.0" [dependency-groups] dev = [ diff --git a/src/openarmature/AGENTS.md b/src/openarmature/AGENTS.md index 052b9328..9df72451 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.10.0 (spec v0.31.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.10.0 (spec v0.34.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.31.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.34.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 62e2f48f..53b055ce 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -25,4 +25,4 @@ """ __version__ = "0.10.0" -__spec_version__ = "0.31.0" +__spec_version__ = "0.34.0" diff --git a/src/openarmature/observability/langfuse/observer.py b/src/openarmature/observability/langfuse/observer.py index 5d408292..ea9ccedf 100644 --- a/src/openarmature/observability/langfuse/observer.py +++ b/src/openarmature/observability/langfuse/observer.py @@ -726,6 +726,10 @@ def _open_detached_subgraph_trace( # "string array, one entry per detached child" shape so # later detached siblings under the same parent can append. # + # `detached: True` per §8.4.2 (proposal 0042) — the + # parent-side dispatching observation marks itself when it + # fires a detached child. + # # Note: `subgraph_name` is intentionally NOT on this link # observation. Per §5.3 + §8.5, in detached mode the wrapper # role migrates to the detached trace's dispatch observation; @@ -733,6 +737,7 @@ def _open_detached_subgraph_trace( # (no wrapper role) and so does not carry `subgraph_name`. link_metadata: dict[str, Any] = { "detached_child_trace_ids": [detached_trace_id], + "detached": True, } if correlation_id is not None: link_metadata["correlation_id"] = correlation_id @@ -783,9 +788,13 @@ def _open_detached_subgraph_trace( # happens to be named ``X``. wrapper_obs_name = identity or prefix[-1] self.client.trace(id=detached_trace_id, name=wrapper_obs_name, metadata=detached_metadata) + # §8.4.2 (proposal 0042): `detached: true` lives on the + # PARENT-side dispatching observation (the link observation + # above), not on the dispatch observation IN the detached + # trace. The detached-side observation is the migrated + # SubgraphNode wrapper and carries `subgraph_name` only. dispatch_metadata: dict[str, Any] = { "subgraph_name": identity, - "detached": True, } if correlation_id is not None: dispatch_metadata["correlation_id"] = correlation_id @@ -827,8 +836,14 @@ def _open_detached_fan_out_instance_trace( ids_list.append(detached_trace_id) fan_out_open = self._find_fan_out_node_observation(inv_state, prefix) if fan_out_open is not None: + # `detached: True` per §8.4.2 (proposal 0042) — the + # parent-side fan-out node observation marks itself when + # its instances are detached. Re-sent on every instance + # update; the Langfuse client merges metadata, so this is + # idempotent. link_metadata: dict[str, Any] = { "detached_child_trace_ids": list(ids_list), + "detached": True, } if correlation_id is not None: link_metadata["correlation_id"] = correlation_id @@ -847,11 +862,15 @@ def _open_detached_fan_out_instance_trace( name=prefix[-1], metadata=detached_metadata, ) + # §8.4.2 (proposal 0042): `detached: true` lives on the + # PARENT-side fan-out node observation (link_metadata above), + # not on the per-instance dispatch observation IN the detached + # trace. The detached-side per-instance observation carries + # only `fan_out_parent_node_name` + `fan_out_index`. parent_node_name = inv_state.fan_out_parent_node_name.get(prefix, prefix[-1]) dispatch_metadata: dict[str, Any] = { "fan_out_parent_node_name": parent_node_name, "fan_out_index": event.fan_out_index, - "detached": True, } if correlation_id is not None: dispatch_metadata["correlation_id"] = correlation_id diff --git a/src/openarmature/observability/metadata.py b/src/openarmature/observability/metadata.py index 0ce6ac75..fc3f2d1f 100644 --- a/src/openarmature/observability/metadata.py +++ b/src/openarmature/observability/metadata.py @@ -66,10 +66,11 @@ # boundary so observers never see a colliding key. _RESERVED_PREFIXES: tuple[str, ...] = ("openarmature.", "gen_ai.") -# Reserved exact key NAMES per §3.4 (proposal 0041): the top-level -# metadata keys an OA-emitted §8 backend mapping writes alongside -# caller keys (the §8.4 Langfuse set, plus invocation_id). A caller -# key matching one exactly would silently overwrite an OA field in a +# Reserved exact key NAMES per §3.4 (proposals 0041, 0042): the +# top-level metadata keys an OA-emitted §8 backend mapping writes +# alongside caller keys (the §8.4 Langfuse set, plus invocation_id, +# branch_name, detached, detached_from_invocation_id). A caller key +# matching one exactly would silently overwrite an OA field in a # backend's flat top-level metadata, so it is rejected at the boundary # the same way as the prefix reservation. Backend-set-independent: # rejected regardless of which observers are attached. @@ -96,6 +97,9 @@ "response_id", "prompt", "invocation_id", + "branch_name", + "detached", + "detached_from_invocation_id", } ) diff --git a/tests/conformance/test_fixture_parsing.py b/tests/conformance/test_fixture_parsing.py index 5fbab19e..bbe3c3d8 100644 --- a/tests/conformance/test_fixture_parsing.py +++ b/tests/conformance/test_fixture_parsing.py @@ -156,6 +156,41 @@ def _id(case: tuple[str, Path]) -> str: "llm-provider/042-anthropic-thinking-block-round-trip": ( "Anthropic provider not implemented (0037 not-yet in conformance.toml)" ), + # Proposal 0038 (Google Gemini wire-format mapping) shipped in spec + # v0.32.0 but python marks it not-yet in conformance.toml — the + # Gemini provider isn't implemented in this release. Defer the + # cross-capability parse tests for the 044-053 fixtures; the + # `mapping: gemini` discriminator is harness-extension territory. + "llm-provider/044-gemini-basic-message-round-trip": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/045-gemini-function-call-flow": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/046-gemini-image-content-blocks": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/047-gemini-tool-choice-modes": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/048-gemini-runtime-config-mapping": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/049-gemini-error-mapping": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/050-gemini-structured-output-native": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/051-gemini-structured-output-fallback": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/052-gemini-thought-signature-round-trip": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), + "llm-provider/053-cross-provider-signature-strip": ( + "Gemini provider not implemented (0038 not-yet in conformance.toml)" + ), # Proposal 0040 (open-span metadata update) — task #22 implements # the §6 augmentation-event mechanism + un-defers 029/030 + 034. # Fixture 034 lands in the Langfuse-specific harness directly diff --git a/tests/conformance/test_llm_provider.py b/tests/conformance/test_llm_provider.py index 46feab1d..59ad4cc9 100644 --- a/tests/conformance/test_llm_provider.py +++ b/tests/conformance/test_llm_provider.py @@ -81,6 +81,19 @@ "041-anthropic-structured-output-fallback": "Anthropic provider not implemented (0037 not-yet)", "042-anthropic-thinking-block-round-trip": "Anthropic provider not implemented (0037 not-yet)", "043-openai-strips-thinking-blocks": "Anthropic provider not implemented (0037 not-yet)", + # Proposal 0038 (Google Gemini wire-format mapping) shipped in spec + # v0.32.0 but python marks it not-yet — the Gemini provider isn't + # implemented in this release. + "044-gemini-basic-message-round-trip": "Gemini provider not implemented (0038 not-yet)", + "045-gemini-function-call-flow": "Gemini provider not implemented (0038 not-yet)", + "046-gemini-image-content-blocks": "Gemini provider not implemented (0038 not-yet)", + "047-gemini-tool-choice-modes": "Gemini provider not implemented (0038 not-yet)", + "048-gemini-runtime-config-mapping": "Gemini provider not implemented (0038 not-yet)", + "049-gemini-error-mapping": "Gemini provider not implemented (0038 not-yet)", + "050-gemini-structured-output-native": "Gemini provider not implemented (0038 not-yet)", + "051-gemini-structured-output-fallback": "Gemini provider not implemented (0038 not-yet)", + "052-gemini-thought-signature-round-trip": "Gemini provider not implemented (0038 not-yet)", + "053-cross-provider-signature-strip": "Gemini provider not implemented (0038 not-yet)", } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1c41fde0..5ba2c5d6 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,7 +9,7 @@ def test_package_versions() -> None: assert openarmature.__version__ == "0.10.0" - assert openarmature.__spec_version__ == "0.31.0" + assert openarmature.__spec_version__ == "0.34.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 bf510583..47ecb5e2 100644 --- a/tests/unit/test_observability_metadata.py +++ b/tests/unit/test_observability_metadata.py @@ -371,6 +371,59 @@ async def test_invoke_rejects_reserved_exact_key_at_boundary() -> None: await graph.invoke(_SimpleState(), metadata={"step": 3}) +# --------------------------------------------------------------------------- +# Reserved exact key names extension (proposal 0042) +# --------------------------------------------------------------------------- + + +def test_validate_rejects_reserved_branch_name() -> None: + with pytest.raises(ValueError, match="is reserved"): + validate_invocation_metadata({"branch_name": "fraud_check"}) + + +def test_validate_rejects_reserved_detached() -> None: + with pytest.raises(ValueError, match="is reserved"): + validate_invocation_metadata({"detached": True}) + + +def test_validate_rejects_reserved_detached_from_invocation_id() -> None: + with pytest.raises(ValueError, match="is reserved"): + validate_invocation_metadata({"detached_from_invocation_id": "parent-1"}) + + +def test_set_invocation_metadata_rejects_reserved_branch_name() -> None: + with pytest.raises(ValueError, match="is reserved"): + set_invocation_metadata(branch_name="policy_audit") + + +def test_set_invocation_metadata_rejects_reserved_detached() -> None: + with pytest.raises(ValueError, match="is reserved"): + set_invocation_metadata(detached=True) + + +def test_set_invocation_metadata_rejects_reserved_detached_from_invocation_id() -> None: + with pytest.raises(ValueError, match="is reserved"): + set_invocation_metadata(detached_from_invocation_id="parent-1") + + +async def test_invoke_rejects_reserved_branch_name_at_boundary() -> None: + graph = _build_graph() + with pytest.raises(ValueError, match="is reserved"): + await graph.invoke(_SimpleState(), metadata={"branch_name": "x"}) + + +async def test_invoke_rejects_reserved_detached_at_boundary() -> None: + graph = _build_graph() + with pytest.raises(ValueError, match="is reserved"): + await graph.invoke(_SimpleState(), metadata={"detached": False}) + + +async def test_invoke_rejects_reserved_detached_from_invocation_id_at_boundary() -> None: + graph = _build_graph() + with pytest.raises(ValueError, match="is reserved"): + await graph.invoke(_SimpleState(), metadata={"detached_from_invocation_id": "p"}) + + # --------------------------------------------------------------------------- # Caller-supplied invocation_id (proposal 0039) # ---------------------------------------------------------------------------