Skip to content

Commit 5a586c8

Browse files
Implement proposal 0043 (trace input/output) (#99)
* Implement proposal 0043 (trace input/output) Adds the observability §8.4.1 *Trace input/output sourcing* mechanism: the Langfuse observer populates `trace.input` at invocation entry and `trace.output` at invocation exit via a three-lever decision tree (caller hook returning non-null → hook value; raw state when `disable_state_payload=False` → serialized state; default → minimal stub `{entry_node, correlation_id}` / `{final_node, status}` where status is the closed `Literal["completed", "failed"]` enum). Wires through two new observer event types delivered on the existing serial-delivery queue: `InvocationStartedEvent` and `InvocationCompletedEvent`. The engine enqueues both at the invocation lifecycle's outermost boundaries (entry before any node fires; exit on both success and failure paths). Mirrors the 0040 pattern used for `MetadataAugmentationEvent`. The `Observer.__call__` signature widens to a four-variant union; the new `ObserverEvent` type alias gives observer authors a one-name handle and is re-exported from `openarmature.graph`. The OTel observer no-ops on both new events (OTel has no Trace- level input/output concept). The LangfuseSDKAdapter caches input and output on `_trace_info`; live-Trace emission via the v4 SDK is deferred to a follow-up (the InMemoryLangfuseClient used by tests applies the fields directly so the contract is unit-test-pinned). Bumps the spec pin from v0.34.0 to v0.35.0. `conformance.toml` records 0043 as implemented since 0.11.0. Conformance fixture 037 is deferred because cases 3/4/5 need a caller-hook YAML directive the cross-capability harness doesn't model yet; the five-case decision tree is pinned by new unit tests at `tests/unit/test_observability_langfuse.py::test_trace_input_output_*`. * Fix final_node_box leak + JSON-mode state dump Two issues surfaced by PR #99 review: `final_node_box` is shared by reference across subgraph, fan-out, and parallel-branches descents (`descend_into_*` propagates the list). Inner-node writes leak into the outer box on the success path, so the outermost `invoke()` reads the wrong `final_node` when an outer wrapper is the last node before the END-routing edge. For parallel-branches the leaked value depends on which branch finishes last, making `InvocationCompletedEvent.final_node` nondeterministic. Restore the outer `current` to the box after each `_step_*` call returns successfully. The restore is on the success path only — the failure path's raise bypasses it, so the inner-most node that raised stays in the box for the spec §4 attribution. A follow-up race remains for parallel-branches and fan-out failure cases: concurrent inner writes mean the box may end with a successful sibling's inner rather than the failing sibling's. Addressing that requires error-aware tracking the engine doesn't currently expose. Pydantic's `model_dump()` defaults to Python mode and leaves `datetime` / `UUID` / `Decimal` as Python objects. The downstream `json.dumps` truncation path raises `TypeError` on those types, and the observer raise is swallowed by the engine's warnings-only observer-isolation contract, silently leaving `trace.input` / `trace.output` blank under `disable_state_payload=False`. `_state_to_jsonable` now calls `model_dump(mode="json")` so the common non-JSON-native types serialize to their JSON-compatible string forms before reaching the truncation step. Adds a regression test using a State with `datetime`, `UUID`, and `Decimal` fields.
1 parent ecf4216 commit 5a586c8

29 files changed

Lines changed: 844 additions & 125 deletions

CHANGELOG.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **`LangfuseObserver` Trace input/output sourcing** (proposal 0043, observability §8.4.1). New observer construction knobs populate `trace.input` and `trace.output` per the three-lever decision tree:
12+
- **`disable_state_payload: bool = True`** — privacy knob symmetric to `disable_llm_payload`. When ON (default), Trace fields receive the minimal stub `{entry_node, correlation_id}` / `{final_node, status}`; when OFF, the raw state object is serialized.
13+
- **`trace_input_from_state` / `trace_output_from_state`** — optional caller hooks returning the domain-shaped value to use for `trace.input` / `trace.output`. Returning `None` falls through to the next applicable lever.
14+
- `status` is the closed `Literal["completed", "failed"]` enum from spec §8.4.1.
15+
- **Two new observer event types** delivered through the existing `graph.observer.Observer` queue:
16+
- **`InvocationStartedEvent(initial_state, invocation_id, correlation_id, entry_node)`** — emitted once at invocation entry before any node fires.
17+
- **`InvocationCompletedEvent(final_state, status, final_node, invocation_id, correlation_id)`** — emitted once at invocation exit on both the success path (`status="completed"`) and failure path (`status="failed"`).
18+
19+
The `Observer.__call__` signature widens to `NodeEvent | MetadataAugmentationEvent | InvocationStartedEvent | InvocationCompletedEvent`. The new `ObserverEvent` type alias (re-exported from `openarmature.graph`) gives observer authors a one-name handle on the union; existing observers that ignore non-`NodeEvent` variants early-return after an `isinstance(event, NodeEvent)` check.
20+
- **`LangfuseTrace.input` / `LangfuseTrace.output` dataclass fields** on the in-memory recorder, populated by the new observer paths.
21+
922
### Changed
1023

1124
- **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.
1225
- **`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.
26+
- **`LangfuseClient.update_trace` Protocol grows `input` / `output` keyword parameters** so observer-supplied values land on the Trace's headline fields.
1327

1428
### Notes
1529

16-
- **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).
30+
- **Pinned spec version bumped from v0.31.0 to v0.35.0.** Absorbs proposals 0042 (reserved-key extension), 0043 (Langfuse trace.input/output sourcing), and the textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented).
31+
- The SDK adapter caches `input` / `output` in its `_trace_info` map; landing the values on the live Langfuse Trace from outside an active span context requires SDK-version-specific calls (v4's `langfuse.update_current_trace` works inside a context; cross-context REST updates need `client.api.trace.update`). The `InMemoryLangfuseClient` used by tests applies the fields directly. SDK-adapter end-to-end emit lands in a follow-up.
1732

1833
## [0.10.0] — 2026-05-27
1934

conformance.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
[manifest]
3434
implementation = "openarmature-python"
35-
spec_pin = "v0.34.0"
35+
spec_pin = "v0.35.0"
3636

3737
# Status values:
3838
# implemented — shipped behavior matches the proposal's contract
@@ -205,3 +205,8 @@ status = "not-yet"
205205
[proposals."0042"]
206206
status = "implemented"
207207
since = "0.11.0"
208+
209+
# Spec v0.35.0 (proposal 0043).
210+
[proposals."0043"]
211+
status = "implemented"
212+
since = "0.11.0"

examples/00-hello-world/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
END,
5050
CompiledGraph,
5151
GraphBuilder,
52-
MetadataAugmentationEvent,
5352
NodeEvent,
53+
ObserverEvent,
5454
State,
5555
append,
5656
merge,
@@ -195,7 +195,7 @@ def route(state: PipelineState) -> str:
195195
return state.classification.intent
196196

197197

198-
async def trace(event: NodeEvent | MetadataAugmentationEvent) -> None:
198+
async def trace(event: ObserverEvent) -> None:
199199
# OpenAIProvider emits NodeEvent-shaped events for LLM-span
200200
# tracking under a sentinel namespace; those have post_state=None.
201201
# ``set_invocation_metadata`` from within a node body emits a

examples/03-observer-hooks/main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@
5454
CompiledGraph,
5555
ExplicitMapping,
5656
GraphBuilder,
57-
MetadataAugmentationEvent,
5857
NodeEvent,
5958
Observer,
59+
ObserverEvent,
6060
State,
6161
append,
6262
)
@@ -187,7 +187,7 @@ def build_review_subgraph() -> CompiledGraph[ReviewState]:
187187
# fire on every invocation of the compiled graph until removed.
188188

189189

190-
async def console_tracer(event: NodeEvent | MetadataAugmentationEvent) -> None:
190+
async def console_tracer(event: ObserverEvent) -> None:
191191
"""Print one structured line per node boundary to stderr.
192192
193193
Format: `[step=N] namespace.path → fields_changed_in_this_step`
@@ -197,7 +197,7 @@ async def console_tracer(event: NodeEvent | MetadataAugmentationEvent) -> None:
197197
reach observers as ``MetadataAugmentationEvent`` instances; this
198198
tracer ignores them.
199199
"""
200-
if isinstance(event, MetadataAugmentationEvent):
200+
if not isinstance(event, NodeEvent):
201201
return
202202
namespace = ".".join(event.namespace)
203203
if event.error is not None:
@@ -239,8 +239,8 @@ def __init__(self) -> None:
239239
self.errors: int = 0
240240
self.namespaces: set[tuple[str, ...]] = set()
241241

242-
async def __call__(self, event: NodeEvent | MetadataAugmentationEvent) -> None:
243-
if isinstance(event, MetadataAugmentationEvent):
242+
async def __call__(self, event: ObserverEvent) -> None:
243+
if not isinstance(event, NodeEvent):
244244
return
245245
self.events += 1
246246
if event.error is not None:

examples/04-nested-subgraphs/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
CompiledGraph,
5050
ExplicitMapping,
5151
GraphBuilder,
52-
MetadataAugmentationEvent,
5352
NodeEvent,
53+
ObserverEvent,
5454
State,
5555
append,
5656
)
@@ -350,8 +350,8 @@ def _fmt_state(state: Any) -> str:
350350
return " ".join(parts) if parts else "(empty)"
351351

352352

353-
async def depth_observer(event: NodeEvent | MetadataAugmentationEvent) -> None:
354-
if isinstance(event, MetadataAugmentationEvent):
353+
async def depth_observer(event: ObserverEvent) -> None:
354+
if not isinstance(event, NodeEvent):
355355
return
356356
depth = len(event.namespace)
357357
indent = " " * (depth - 1)

examples/05-fan-out-with-retry/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@
7878
END,
7979
CompiledGraph,
8080
GraphBuilder,
81-
MetadataAugmentationEvent,
8281
NodeEvent,
82+
ObserverEvent,
8383
State,
8484
append,
8585
)
@@ -297,7 +297,7 @@ def build_graph(error_policy: str = "fail_fast") -> CompiledGraph[BatchState]:
297297
)
298298

299299

300-
async def fan_out_config_observer(event: NodeEvent | MetadataAugmentationEvent) -> None:
300+
async def fan_out_config_observer(event: ObserverEvent) -> None:
301301
"""Print the fan-out node's resolved config when its dispatch event
302302
fires.
303303

examples/06-parallel-branches/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@
7070
BranchSpec,
7171
CompiledGraph,
7272
GraphBuilder,
73-
MetadataAugmentationEvent,
7473
NodeEvent,
74+
ObserverEvent,
7575
State,
7676
append,
7777
)
@@ -241,7 +241,7 @@ async def present(s: ArticleState) -> Mapping[str, Any]:
241241
return {"trace": ["present"]}
242242

243243

244-
async def branch_attribution_observer(event: NodeEvent | MetadataAugmentationEvent) -> None:
244+
async def branch_attribution_observer(event: ObserverEvent) -> None:
245245
"""Print which branch each inner-node event came from.
246246
247247
NodeEvent carries ``branch_name`` on events from nodes that

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
5858
openarmature = "openarmature.cli:main"
5959

6060
[tool.openarmature]
61-
spec_version = "0.34.0"
61+
spec_version = "0.35.0"
6262

6363
[dependency-groups]
6464
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*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`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.10.0 (spec v0.35.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`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents
1010

1111
## Capability contracts
1212

13-
_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._
13+
_Sourced from openarmature-spec v0.35.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._
1414

1515
### Capability: `graph-engine`
1616

0 commit comments

Comments
 (0)