Files
hermes-agent/tests/agent/test_moa_observability_bridge.py
kshitij e665300d6b feat(langfuse): widen tracing to errors, sessions, subagents, and MoA fan-out
Salvaged from PR #83437 by @erosika, with adopted fixes from @bgodlin (#81054),
@aldoeliacim (#82332), @nftpoetrist (#42326), @rodboev (#39653), @FnExpress
(#64292, supersedes #32175 by @db-aeon), @Per0-1 (#61166), @NaMinhyeok (#64797),
and @liuhao1024 (#43130).

Widens the bundled Langfuse plugin from 6 to 11 hooks and fixes two
attribution bugs. Also adopts shutdown/atexit lifecycle fixes and composes
8 prior community PRs with interaction-fix follow-ups.

Model attribution: on_pre_llm_request and on_post_llm_call now prefer the
wire value (request body model, response model) over the agent attribute,
which goes stale after /model switch or provider fallback.

Cost total: both cost paths now send a summed total alongside the per-type
breakdown, since Langfuse does not derive calculatedTotalCost from
cost_details keys. Subscription-included routes send no cost keys at all.

New coverage: api_request_error closes failed generations with ERROR level;
on_session_finalize/on_session_end close dangling traces for tool-only and
interrupted turns; subagent_start/subagent_stop trace delegated children as
spans; MoA advisor fan-out emits one generation per advisor priced at the
advisor's own model.

Capture modes: HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full (default
sanitized). Sanitized mode redacts secret patterns before truncation.

Adopted lifecycle fixes: shutdown client at session finalize when
reason=shutdown (not on session rotation); atexit finalizer ends open root
spans for short-lived processes; root context manager exited to prevent
interpreter-teardown TypeError; TOCTOU on _get_langfuse() fixed with lock;
reasoning_content surfaced in traces; system prompt included in generation
input for Anthropic/Codex/Bedrock; SDK v3 update_trace replaces set_trace_io.

Closes #29482, #43129, #72661.
Supersedes #81054, #82332, #42326, #39653, #64292, #32175, #61166, #64797, #43130.
Partially addresses #67544 (capture modes + secret redaction; user_id remains open).
2026-08-13 23:10:16 +05:30

138 lines
4.8 KiB
Python

"""Per-advisor MoA metrics crossing the plugin-hook boundary.
MoA runs N advisor models before its aggregator and returns only the
aggregator's response, so an observability plugin sees one generation for the
whole fan-out. ``_RefAccounting`` already computes each advisor's usage and
dollars — advisors routinely run on a different provider than the aggregator,
so their spend cannot be priced at the aggregator's rate. These tests pin the
bridge that carries it out: the renderer, the non-consuming accessor, and the
conversation-loop helper that reads it.
"""
from agent.moa_loop import _RefAccounting
from agent.moa_trace import _slot_trace, slot_metrics
from agent.usage_pricing import CanonicalUsage
def _acct():
return _RefAccounting(
CanonicalUsage(input_tokens=100, output_tokens=50, reasoning_tokens=7),
0.0042,
"ok",
"pricing_table",
messages=[{"role": "user", "content": "x" * 10000}],
output="advice",
model="claude-sonnet-4-6",
provider="anthropic",
temperature=0.7,
)
class TestSlotMetrics:
def test_carries_model_provider_usage_and_cost(self):
m = slot_metrics(_acct(), "anthropic:claude-sonnet-4-6")
assert m["label"] == "anthropic:claude-sonnet-4-6"
assert m["model"] == "claude-sonnet-4-6"
assert m["provider"] == "anthropic"
assert m["cost_usd"] == 0.0042
assert m["cost_status"] == "ok"
assert m["cost_source"] == "pricing_table"
assert m["usage"]["input_tokens"] == 100
assert m["usage"]["output_tokens"] == 50
assert m["usage"]["reasoning_tokens"] == 7
def test_drops_input_messages(self):
# input_messages is the bulk of a trace record and would cross the hook
# boundary for every advisor on every turn.
assert "input_messages" in _slot_trace(_acct(), "label")
assert "input_messages" not in slot_metrics(_acct(), "label")
def test_output_override_wins(self):
# The privacy-redacted advisor text lives alongside the accounting, not
# on it, so the caller supplies the output.
m = slot_metrics(_acct(), "label", output="[redacted]")
assert m["output"] == "[redacted]"
def test_missing_accounting_does_not_raise(self):
m = slot_metrics(None, "label")
assert m["label"] == "label"
assert m["usage"] == {}
class TestLastReferenceMetricsAccessor:
def _client(self):
from agent.moa_loop import MoAClient
return MoAClient("closed")
def test_defaults_to_none_off_the_fanout_path(self):
assert self._client().last_reference_metrics() is None
def test_read_does_not_consume(self):
client = self._client()
payload = [slot_metrics(_acct(), "label")]
client.chat.completions._last_reference_metrics = payload
# post_api_request fires on a different branch than
# consume_and_save_trace, so a consuming read would race it.
assert client.last_reference_metrics() is payload
assert client.last_reference_metrics() is payload
def test_read_does_not_disturb_usage_accounting(self):
client = self._client()
client.chat.completions._last_reference_metrics = [slot_metrics(_acct(), "label")]
client.chat.completions._pending_reference_usage = CanonicalUsage(input_tokens=5)
client.chat.completions._pending_reference_cost = 0.05
client.last_reference_metrics()
usage, cost = client.consume_reference_usage()
assert usage.input_tokens == 5
assert cost == 0.05
class TestConversationLoopHelper:
def test_returns_none_for_a_non_moa_client(self):
from agent.conversation_loop import _moa_reference_metrics_for_hook
class _Agent:
client = object()
assert _moa_reference_metrics_for_hook(_Agent()) is None
def test_returns_none_when_there_is_no_client(self):
from agent.conversation_loop import _moa_reference_metrics_for_hook
class _Agent:
client = None
assert _moa_reference_metrics_for_hook(_Agent()) is None
def test_returns_metrics_for_a_moa_client(self):
from agent.conversation_loop import _moa_reference_metrics_for_hook
payload = [slot_metrics(_acct(), "label")]
class _Client:
def last_reference_metrics(self):
return payload
class _Agent:
client = _Client()
assert _moa_reference_metrics_for_hook(_Agent()) is payload
def test_a_raising_accessor_is_swallowed(self):
from agent.conversation_loop import _moa_reference_metrics_for_hook
class _Client:
def last_reference_metrics(self):
raise RuntimeError("boom")
class _Agent:
client = _Client()
# Observability must never break a turn.
assert _moa_reference_metrics_for_hook(_Agent()) is None