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) # ---------------------------------------------------------------------------