fix(observability): populate Langfuse generation/tool I/O and trace metadata (#6)#8
Conversation
RetryPolicy gains a backoff_sleep flag (off by default) and a sleep_backoff helper. The agent-loop retry loop and RetryMiddleware now sleep for the computed backoff when opted in via with_backoff_sleep, so transient provider/network failures are retried after a real, growing delay instead of back-to-back. Default stays sleep-free to keep tests deterministic. Claude-Session: https://claude.ai/code/session_01NRL2bim9Gz3UN3SRtpeXEX
Add opt-in CompiledGraph::with_node_retry(RetryPolicy): each node handler is re-run from its start on a retryable (model/tool) error, up to the policy cap, emitting GraphEvent::NodeRetryScheduled and sleeping the opt-in backoff. Both the sequential and parallel runners drive handlers through the shared run_node_with_retry helper (which also applies the per-node timeout). When a handler fails after retries (or a non-retryable error hits), the runner now hands back partial progress instead of discarding it: completed branches' updates are folded into committed state and a resumable failure-boundary checkpoint is persisted (failed node scheduled as next_nodes, successful branches as completed_tasks, error + failed node in metadata). The Failed status carries that checkpoint id, so a crashed/exhausted run can be resumed or continued rather than lost. Non-checkpointed runs abort exactly as before. Claude-Session: https://claude.ai/code/session_01NRL2bim9Gz3UN3SRtpeXEX
Add CompiledGraph::retry(thread) — shorthand for resume with an empty command — to re-run a failed run's failure-boundary checkpoint, and document the inspect/update_state/retry feedback loop for continuing on user input. Tests: node retry recovers a transient failure (one NodeRetryScheduled per blip); exhausted retries leave a resumable checkpoint that retry() completes; a failure with no retry policy is still resumable (resumable-abort default); edit-state-then-retry runs against edited state; parallel partial progress is preserved (successful branch folded, only failed branch rescheduled). Plus a paused-time test that backoff sleep fires only when opted in. Claude-Session: https://claude.ai/code/session_01NRL2bim9Gz3UN3SRtpeXEX
Add examples/resilient_graph.rs demonstrating node-level retry absorbing a transient fetch blip and a resumable failure checkpoint that retry() restarts after an outage clears. Rewrite docs/modules/graph/fault-tolerance.md to document the implemented behavior, add a resilience section to the graph module docs and wiki, and list the example in README/Examples. Claude-Session: https://claude.ai/code/session_01NRL2bim9Gz3UN3SRtpeXEX
Add PayloadCapture policy (default payload-free) that gates capturing model request/completion and tool args/result onto ModelCompleted/ToolCompleted events. The Langfuse harness exporter now populates generation/tool input/output from those fields and defaults trace metadata from run lineage. Fixes empty Input/Output panels and empty trace metadata (#6). Claude-Session: https://claude.ai/code/session_019zY42oUyeXGxHGesQkTKWV
…ool I/O Drive a real harness run with PayloadCapture enabled, journal it, and assert the exported Langfuse batch populates generation Input/Output and tool-create Input/Output, plus defaulted trace metadata (#6). Claude-Session: https://claude.ai/code/session_019zY42oUyeXGxHGesQkTKWV
📝 WalkthroughWalkthroughThis PR adds an opt-in ChangesPayload capture feature
Estimated code review effort: 3 (Moderate) | ~30 minutes Sequence Diagram(s)sequenceDiagram
participant AgentHarness
participant Model
participant Tool
participant LangfuseClient
AgentHarness->>AgentHarness: policy.capture.model_io?
AgentHarness->>Model: send request.messages
Model-->>AgentHarness: response.message
AgentHarness->>AgentHarness: emit ModelCompleted(input, output, usage)
AgentHarness->>AgentHarness: policy.capture.tool_io?
AgentHarness->>Tool: call.arguments
Tool-->>AgentHarness: tool result
AgentHarness->>AgentHarness: emit ToolCompleted(input, output)
AgentHarness->>LangfuseClient: build_ingestion_batch(observations)
LangfuseClient->>LangfuseClient: trace_metadata(default lineage + caller metadata)
LangfuseClient-->>LangfuseClient: generation-create/tool-create with input/output/metadata
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0149256710
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "startTime": timestamp, | ||
| "endTime": timestamp, | ||
| "usage": usage.map(langfuse_usage), | ||
| "input": input, |
There was a problem hiding this comment.
Keep captured payloads out of Langfuse metadata
When PayloadCapture::model_io or tool_io is enabled, the same captured input/output values added here are also serialized into body.metadata.event because the metadata object above embeds obs.event wholesale. For captured Langfuse exports this stores prompts, completions, tool args, and tool results in metadata in addition to the dedicated Input/Output fields, which defeats consumers that hide or scrub only observation I/O and unnecessarily duplicates sensitive payloads. Strip the capture fields from the metadata copy, or avoid embedding the full event there, before exporting captured runs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/harness/observability/langfuse.rs (1)
308-358: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick winCaptured input/output get duplicated into
metadata.event.The per-observation
metadataobject embeds the wholeobs.event(Line 315), which now — forModelCompleted/ToolCompletedunderPayloadCapture::all()— includes the sameinput/outputvalues already placed atbody.input/body.output(Lines 334-335, 354-355). Every captured generation/tool observation thus ships the full prompt/completion/tool payload twice, needlessly inflating the Langfuse ingestion body (and risking size-limit issues for large contexts).♻️ Proposed fix: stop re-embedding the full event in metadata
fn observation_event(trace_id: &str, obs: &AgentObservation) -> Value { let timestamp = iso_ms(obs.ts_ms); let metadata = json!({ "run_id": obs.run_id.as_str(), "root_run_id": obs.root_run_id.as_str(), "parent_run_id": obs.parent_run_id.as_ref().map(|id| id.as_str()), "offset": obs.offset, - "event": obs.event, + "event_kind": obs.event.kind(), });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/harness/observability/langfuse.rs` around lines 308 - 358, The Langfuse observation payload is duplicating captured input/output because `observation_event` puts the full `obs.event` inside `metadata.event` while `ModelCompleted` and `ToolCompleted` already include the same values in `body.input` and `body.output`. Update `observation_event` to stop re-embedding the full event in `metadata` and keep only lightweight fields there, so the generated `generation-create` and `tool-create` bodies don’t carry duplicate payloads.
🧹 Nitpick comments (1)
src/harness/agent_loop/mod.rs (1)
447-486: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winFull-transcript snapshot on every model call — storage growth caveat.
captured_inputserializesrequest.messages, which is the entire accumulated transcript at that point in the run (built viaModelRequest::new(messages.clone()), wheremessagesis pushed to after every model/tool turn). Withmodel_iocapture enabled, eachModelCompletedevent therefore stores the whole prior transcript again, so per-run journaled payload size grows roughly quadratically with turn count for long-running conversations. This mirrors how other LLM observability tools render a generation's "Input" (the full prompt sent), so it's likely intentional, but it's worth calling out explicitly as a cost/storage tradeoff in the docs (docs/modules/harness/observability.md) so operators enablingPayloadCapture::model_ioon long-running agents understand the storage implications.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/harness/agent_loop/mod.rs` around lines 447 - 486, The `captured_input` logic in `src/harness/agent_loop/mod.rs` intentionally snapshots the full `request.messages` transcript for each `AgentEvent::ModelCompleted`, but this can cause quadratic journal growth when `PayloadCapture::model_io` is enabled. Keep the behavior as-is in `run_wrapped_model`/`emit`, and update the observability docs (`docs/modules/harness/observability.md`) to clearly warn that `model_io` captures the entire prompt transcript on every model call and can significantly increase storage for long-running conversations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/harness/observability/langfuse.rs`:
- Around line 308-358: The Langfuse observation payload is duplicating captured
input/output because `observation_event` puts the full `obs.event` inside
`metadata.event` while `ModelCompleted` and `ToolCompleted` already include the
same values in `body.input` and `body.output`. Update `observation_event` to
stop re-embedding the full event in `metadata` and keep only lightweight fields
there, so the generated `generation-create` and `tool-create` bodies don’t carry
duplicate payloads.
---
Nitpick comments:
In `@src/harness/agent_loop/mod.rs`:
- Around line 447-486: The `captured_input` logic in
`src/harness/agent_loop/mod.rs` intentionally snapshots the full
`request.messages` transcript for each `AgentEvent::ModelCompleted`, but this
can cause quadratic journal growth when `PayloadCapture::model_io` is enabled.
Keep the behavior as-is in `run_wrapped_model`/`emit`, and update the
observability docs (`docs/modules/harness/observability.md`) to clearly warn
that `model_io` captures the entire prompt transcript on every model call and
can significantly increase storage for long-running conversations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0df4c864-74c5-40d4-a39c-2ef141946dc0
📒 Files selected for processing (11)
docs/modules/harness/observability.mdsrc/harness/agent_loop/mod.rssrc/harness/events/types.rssrc/harness/observability/langfuse.rssrc/harness/observability/test.rssrc/harness/runtime/types.rssrc/harness/testkit/test.rssrc/harness/testkit/types.rstests/e2e_observability.rstests/e2e_orchestrator_subagents.rstests/e2e_registry_observability_contracts.rs
Summary
Fixes #6 — Langfuse generations and tool calls rendered with a name and timing
but empty Input/Output, and harness traces had empty metadata. The event
model was payload-free by design, so the exporter had nothing to forward.
This carries model/tool payloads through the event model behind an opt-in,
default-off capture policy (privacy-preserving by default), populates the
exporter's
input/outputbody fields from them, and defaults usefultrace-level metadata on the harness path.
Changes
PayloadCapturepolicy (src/harness/runtime/types.rs): newRunPolicy.capturefield,PayloadCapture { model_io, tool_io }, defaultingto payload-free.
PayloadCapture::all()opts in.src/harness/events/types.rs): optionalinput/output(
serdedefault, skip-if-none, backward compatible) onModelCompletedandToolCompleted.src/harness/agent_loop/mod.rs): when capture is enabled,snapshots the request messages + completion, and tool args + result, onto the
completion events. Snapshots happen before the value moves into the model/tool
wrap onion, so execution is unperturbed. Captured payloads still flow through
RedactingSink.src/harness/observability/langfuse.rs):populates generation/tool
input/outputfrom the event fields; defaultstrace metadata (root/first run ids + parent) so a trace is correlatable even
with no caller metadata, merging caller keys on top.
body.input/body.outputfor acaptured generation and tool call and caller-metadata merge; a new e2e test in
tests/e2e_observability.rsthat drives a real harness run with capture on andasserts the exported batch carries the I/O and defaulted metadata.
docs/modules/harness/observability.md.Design notes
unaffected unless they opt in; the fields are absent from serialized events
when disabled.
I/O — the real generations and tool calls are the child agent runs, exported
through the harness client and unified under the same
traceId(root run id).So the graph exporter needs no I/O change for Langfuse integration: events tracked but bodies (input/output) and trace metadata are empty #6; it already folds
graph/health into trace metadata.
input/outputin the batch they flow through, as the issue notes.Commands run locally
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo test(all suites pass, incl. new exporter + e2e tests)https://claude.ai/code/session_019zY42oUyeXGxHGesQkTKWV
Summary by CodeRabbit
New Features
Bug Fixes