Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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"
2 changes: 1 addition & 1 deletion openarmature-spec
Submodule openarmature-spec updated 64 files
+48 −0 CHANGELOG.md
+14 −14 README.md
+1 −0 docs/capabilities/sessions.md
+5 −3 docs/proposals.md
+1 −0 docs/proposals/0038-llm-provider-google-gemini-mapping.md
+1 −0 docs/proposals/0042-observability-reserved-keys-extension.md
+1 −0 mkdocs.yml
+57 −28 proposals/0020-sessions-capability.md
+581 −0 proposals/0038-llm-provider-google-gemini-mapping.md
+218 −0 proposals/0042-observability-reserved-keys-extension.md
+25 −0 spec/llm-provider/conformance/044-gemini-basic-message-round-trip.md
+41 −0 spec/llm-provider/conformance/044-gemini-basic-message-round-trip.yaml
+28 −0 spec/llm-provider/conformance/045-gemini-function-call-flow.md
+66 −0 spec/llm-provider/conformance/045-gemini-function-call-flow.yaml
+23 −0 spec/llm-provider/conformance/046-gemini-image-content-blocks.md
+44 −0 spec/llm-provider/conformance/046-gemini-image-content-blocks.yaml
+20 −0 spec/llm-provider/conformance/047-gemini-tool-choice-modes.md
+104 −0 spec/llm-provider/conformance/047-gemini-tool-choice-modes.yaml
+22 −0 spec/llm-provider/conformance/048-gemini-runtime-config-mapping.md
+37 −0 spec/llm-provider/conformance/048-gemini-runtime-config-mapping.yaml
+20 −0 spec/llm-provider/conformance/049-gemini-error-mapping.md
+41 −0 spec/llm-provider/conformance/049-gemini-error-mapping.yaml
+24 −0 spec/llm-provider/conformance/050-gemini-structured-output-native.md
+50 −0 spec/llm-provider/conformance/050-gemini-structured-output-native.yaml
+25 −0 spec/llm-provider/conformance/051-gemini-structured-output-fallback.md
+49 −0 spec/llm-provider/conformance/051-gemini-structured-output-fallback.yaml
+26 −0 spec/llm-provider/conformance/052-gemini-thought-signature-round-trip.md
+88 −0 spec/llm-provider/conformance/052-gemini-thought-signature-round-trip.yaml
+24 −0 spec/llm-provider/conformance/053-cross-provider-signature-strip.md
+56 −0 spec/llm-provider/conformance/053-cross-provider-signature-strip.yaml
+319 −1 spec/llm-provider/spec.md
+18 −5 spec/observability/conformance/028-caller-metadata-namespace-rejection.md
+80 −0 spec/observability/conformance/028-caller-metadata-namespace-rejection.yaml
+16 −2 spec/observability/conformance/030-caller-metadata-parallel-branches-per-branch.yaml
+14 −0 spec/observability/conformance/033-langfuse-detached-trace-mode.yaml
+26 −6 spec/observability/spec.md
+20 −0 spec/pipeline-utilities/spec.md
+31 −0 spec/sessions/conformance/001-session-basic-resume.md
+48 −0 spec/sessions/conformance/001-session-basic-resume.yaml
+30 −0 spec/sessions/conformance/002-session-no-store-registered.md
+30 −0 spec/sessions/conformance/002-session-no-store-registered.yaml
+27 −0 spec/sessions/conformance/003-session-store-registered-no-id.md
+29 −0 spec/sessions/conformance/003-session-store-registered-no-id.yaml
+30 −0 spec/sessions/conformance/004-session-projected-state.md
+49 −0 spec/sessions/conformance/004-session-projected-state.yaml
+27 −0 spec/sessions/conformance/005-session-auto-save-off.md
+31 −0 spec/sessions/conformance/005-session-auto-save-off.yaml
+31 −0 spec/sessions/conformance/006-session-mid-invoke-save.md
+51 −0 spec/sessions/conformance/006-session-mid-invoke-save.yaml
+30 −0 spec/sessions/conformance/007-session-migration-basic.md
+45 −0 spec/sessions/conformance/007-session-migration-basic.yaml
+31 −0 spec/sessions/conformance/008-session-migration-missing.md
+44 −0 spec/sessions/conformance/008-session-migration-missing.yaml
+30 −0 spec/sessions/conformance/009-session-migration-chain-ambiguous.md
+41 −0 spec/sessions/conformance/009-session-migration-chain-ambiguous.yaml
+30 −0 spec/sessions/conformance/010-session-composition-subgraph.md
+64 −0 spec/sessions/conformance/010-session-composition-subgraph.yaml
+29 −0 spec/sessions/conformance/011-session-composition-fan-out.md
+71 −0 spec/sessions/conformance/011-session-composition-fan-out.yaml
+43 −0 spec/sessions/conformance/012-session-observability.md
+101 −0 spec/sessions/conformance/012-session-observability.yaml
+33 −0 spec/sessions/conformance/013-session-migration-function-raises.md
+52 −0 spec/sessions/conformance/013-session-migration-function-raises.yaml
+364 −0 spec/sessions/spec.md
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
4 changes: 2 additions & 2 deletions src/openarmature/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion src/openarmature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@
"""

__version__ = "0.10.0"
__spec_version__ = "0.31.0"
__spec_version__ = "0.34.0"
23 changes: 21 additions & 2 deletions src/openarmature/observability/langfuse/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,13 +726,18 @@ 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;
# the main trace's link observation IS the SubgraphNode span
# (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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions src/openarmature/observability/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -96,6 +97,9 @@
"response_id",
"prompt",
"invocation_id",
"branch_name",
"detached",
"detached_from_invocation_id",
}
)

Expand Down
35 changes: 35 additions & 0 deletions tests/conformance/test_fixture_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/conformance/test_llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}


Expand Down
2 changes: 1 addition & 1 deletion tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/test_observability_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading