Files
cleveragents-core/robot/helper_llm_trace.py
T
freemo 3f14cbbf7e
CI / lint (pull_request) Successful in 40s
CI / security (pull_request) Successful in 32s
CI / quality (pull_request) Successful in 42s
CI / typecheck (pull_request) Successful in 1m1s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 14s
CI / unit_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 4m34s
CI / docker (pull_request) Successful in 1m6s
CI / coverage (pull_request) Successful in 6m21s
CI / quality (push) Successful in 16s
CI / lint (push) Successful in 21s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / build (push) Successful in 39s
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m2s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 4m9s
CI / benchmark-publish (push) Successful in 14m42s
CI / benchmark-regression (pull_request) Successful in 26m29s
feat(observability): add LLMTrace model and operational metrics
Add LLMTrace Pydantic v2 domain model with all required fields (trace_id,
plan_id, decision_id, actor, provider, model, prompt_tokens, completion_tokens,
cost_usd, latency_ms, tool_calls, context_hash, streaming, retry_count, error).

Define 14 OperationalMetricKey values (PLAN_DURATION_MS, PLAN_TOTAL_COST_USD,
PLAN_DECISION_COUNT, ACTOR_INVOCATION_COUNT, ACTOR_LATENCY_MS,
TOOL_INVOCATION_COUNT, TOOL_ERROR_RATE, CONTEXT_BUILD_TIME_MS,
CONTEXT_TOKEN_COUNT, LLM_CALL_COUNT, LLM_TOTAL_TOKENS, LLM_TOTAL_COST_USD,
LLM_AVG_LATENCY_MS, SUBPLAN_COUNT) with MetricEntry model and MetricCollector.

Add llm_traces database table (LLMTraceModel) with LLMTraceRepository for
persistence. TraceService provides recording, querying, metric computation,
plan lifecycle hooks, and optional LangSmith forwarding when
LANGCHAIN_TRACING_V2=true.

Wired into DI container as trace_service. Includes 28 Behave BDD scenarios,
6 Robot Framework smoke tests, 3 ASV benchmark suites, and reference
documentation.

Fix pre-existing cli_core server_mode test flake by mocking
resolve_server_mode in test steps to avoid stale config file interference.

Closes #500
2026-03-03 17:04:17 -05:00

215 lines
6.4 KiB
Python

"""Helper script for LLM trace Robot Framework tests.
Usage:
python helper_llm_trace.py record-and-retrieve
python helper_llm_trace.py list-by-plan
python helper_llm_trace.py compute-metrics
python helper_llm_trace.py metric-key-count
python helper_llm_trace.py validation
python helper_llm_trace.py lifecycle-hooks
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
# Ensure source tree is importable — force the local checkout to win
# over any inherited PYTHONPATH (e.g. /app/src in CI containers).
_SRC = str(Path(__file__).resolve().parents[1] / "src")
sys.path.insert(0, _SRC)
from cleveragents.application.services.trace_service import TraceService # noqa: E402
from cleveragents.domain.models.observability.llm_trace import LLMTrace # noqa: E402
from cleveragents.domain.models.observability.metrics import ( # noqa: E402
OperationalMetricKey,
)
VALID_ULID_1 = "01HXAAAAAAAAAAAAAAAAAAAAAA"
VALID_ULID_2 = "01HXBBBBBBBBBBBBBBBBBBBBBB"
VALID_ULID_3 = "01HXCCCCCCCCCCCCCCCCCCCCCC"
VALID_PLAN_ID = "01HX0000000000PPPPPPPPPPPP"
class InMemoryRepo:
"""Minimal in-memory repo for testing."""
def __init__(self) -> None:
self._store: dict[str, LLMTrace] = {}
def save(self, trace: LLMTrace) -> None:
self._store[trace.trace_id] = trace
def get(self, trace_id: str) -> LLMTrace | None:
return self._store.get(trace_id)
def list_by_plan(self, plan_id: str) -> list[LLMTrace]:
return [t for t in self._store.values() if t.plan_id == plan_id]
def list_by_decision(self, decision_id: str) -> list[LLMTrace]:
return [t for t in self._store.values() if t.decision_id == decision_id]
def _make_trace(trace_id: str = VALID_ULID_1, **kwargs: Any) -> LLMTrace:
defaults: dict[str, Any] = {
"trace_id": trace_id,
"plan_id": VALID_PLAN_ID,
"actor": "planner",
"provider": "openai",
"model": "gpt-4o",
"prompt_tokens": 500,
"completion_tokens": 200,
"cost_usd": 0.01,
"latency_ms": 350.0,
}
defaults.update(kwargs)
return LLMTrace(**defaults)
def _make_service() -> TraceService:
repo = InMemoryRepo()
return TraceService(settings=MagicMock(), repository=repo) # type: ignore[arg-type]
def cmd_record_and_retrieve() -> None:
"""Record a trace and retrieve by ID."""
svc = _make_service()
trace = _make_trace()
svc.record_trace(trace)
result = svc.get_trace(trace.trace_id)
if result is None:
print("FAIL: trace not found after recording")
sys.exit(1)
if result.trace_id != trace.trace_id:
print(f"FAIL: trace_id mismatch {result.trace_id} != {trace.trace_id}")
sys.exit(1)
print("llm-trace-record-ok")
def cmd_list_by_plan() -> None:
"""Record multiple traces and list by plan."""
svc = _make_service()
for uid in (VALID_ULID_1, VALID_ULID_2, VALID_ULID_3):
svc.record_trace(_make_trace(trace_id=uid))
results = svc.get_traces(VALID_PLAN_ID)
if len(results) != 3:
print(f"FAIL: expected 3 traces, got {len(results)}")
sys.exit(1)
print("llm-trace-list-plan-ok")
def cmd_compute_metrics() -> None:
"""Compute metrics from traces."""
svc = _make_service()
for uid in (VALID_ULID_1, VALID_ULID_2, VALID_ULID_3):
svc.record_trace(_make_trace(trace_id=uid))
metrics = svc.compute_metrics(VALID_PLAN_ID)
if len(metrics) != 4:
print(f"FAIL: expected 4 metrics, got {len(metrics)}")
sys.exit(1)
keys = {m.key for m in metrics}
expected = {
OperationalMetricKey.LLM_CALL_COUNT,
OperationalMetricKey.LLM_TOTAL_TOKENS,
OperationalMetricKey.LLM_TOTAL_COST_USD,
OperationalMetricKey.LLM_AVG_LATENCY_MS,
}
if keys != expected:
print(f"FAIL: unexpected metric keys {keys}")
sys.exit(1)
print("llm-trace-metrics-ok")
def cmd_metric_key_count() -> None:
"""Verify all 14 metric keys exist."""
if len(OperationalMetricKey) != 14:
print(f"FAIL: expected 14 keys, got {len(OperationalMetricKey)}")
sys.exit(1)
print("llm-trace-metric-keys-ok")
def cmd_validation() -> None:
"""Verify model validation rejects bad inputs."""
from pydantic import ValidationError
# Invalid trace_id
try:
_make_trace(trace_id="INVALID")
print("FAIL: invalid trace_id accepted")
sys.exit(1)
except ValidationError:
pass
# Negative prompt tokens
try:
_make_trace(prompt_tokens=-1)
print("FAIL: negative prompt_tokens accepted")
sys.exit(1)
except ValidationError:
pass
# Negative cost
try:
_make_trace(cost_usd=-0.5)
print("FAIL: negative cost_usd accepted")
sys.exit(1)
except ValidationError:
pass
print("llm-trace-validation-ok")
def cmd_lifecycle_hooks() -> None:
"""Verify lifecycle hooks produce correct metrics."""
svc = _make_service()
# on_plan_start
entry = svc.on_plan_start(VALID_PLAN_ID)
if entry is None or entry.key != OperationalMetricKey.PLAN_DECISION_COUNT:
print("FAIL: on_plan_start incorrect")
sys.exit(1)
# on_actor_invocation
entries = svc.on_actor_invocation(VALID_PLAN_ID, "planner", 250.0)
keys = {m.key for m in entries}
if OperationalMetricKey.ACTOR_INVOCATION_COUNT not in keys:
print("FAIL: on_actor_invocation missing ACTOR_INVOCATION_COUNT")
sys.exit(1)
# on_tool_execution
entries = svc.on_tool_execution(VALID_PLAN_ID, "file_write", errored=True)
keys = {m.key for m in entries}
if OperationalMetricKey.TOOL_ERROR_RATE not in keys:
print("FAIL: on_tool_execution missing TOOL_ERROR_RATE")
sys.exit(1)
print("llm-trace-hooks-ok")
def main() -> None:
"""Dispatch subcommand."""
if len(sys.argv) < 2:
print("Usage: helper_llm_trace.py <command>")
sys.exit(1)
cmd = sys.argv[1]
commands = {
"record-and-retrieve": cmd_record_and_retrieve,
"list-by-plan": cmd_list_by_plan,
"compute-metrics": cmd_compute_metrics,
"metric-key-count": cmd_metric_key_count,
"validation": cmd_validation,
"lifecycle-hooks": cmd_lifecycle_hooks,
}
fn = commands.get(cmd)
if fn is None:
print(f"Unknown command: {cmd}")
sys.exit(1)
fn()
if __name__ == "__main__":
main()