From 462456c035bf0d4e7fc552181705aeb12887d524 Mon Sep 17 00:00:00 2001 From: David Hyrule Date: Mon, 29 Jun 2026 03:24:43 +0200 Subject: [PATCH] feat: optional agent-core TraceEvent/CostUsage emission (Phase 2, additive) Flag-gated, best-effort observability groundwork for the Agent Runtime Framework. Emits standard agent-core TraceEvent (context packs) and CostUsage-bearing model_call events (enrichment) ALONGSIDE existing behavior. No-op unless HYRULE_KNOWLEDGE_AGENT_CORE_TRACE is truthy and the optional agent-core package is installed (NOT a declared dependency; CI without it skips emission). agent_core is imported dynamically so 'mypy --strict src' and 'uv sync --frozen' are unaffected. Files: src/hyrule_knowledge/agent_core_trace.py (emit_context_pack/emit_enrich_cost); cli.py additive calls in cmd_context_pack + cmd_enrich; tests/test_agent_core_trace.py (importorskip). ruff + mypy --strict clean; full suite 97 passed; off by default (no behavior change). Co-Authored-By: Claude Opus 4.8 --- src/hyrule_knowledge/agent_core_trace.py | 85 ++++++++++++++++++++++++ src/hyrule_knowledge/cli.py | 4 ++ tests/test_agent_core_trace.py | 47 +++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/hyrule_knowledge/agent_core_trace.py create mode 100644 tests/test_agent_core_trace.py diff --git a/src/hyrule_knowledge/agent_core_trace.py b/src/hyrule_knowledge/agent_core_trace.py new file mode 100644 index 0000000..64babb9 --- /dev/null +++ b/src/hyrule_knowledge/agent_core_trace.py @@ -0,0 +1,85 @@ +"""Optional, flag-gated emission of agent-core TraceEvent / CostUsage. + +Phase 2 groundwork for the Agent Runtime Framework: emit standard observability +records *alongside* existing behavior. This is intentionally best-effort and additive: + +- It is a no-op unless ``HYRULE_KNOWLEDGE_AGENT_CORE_TRACE`` is truthy AND the optional + ``agent-core`` package is importable. (agent-core is NOT a declared dependency of this + repo, so CI without it simply skips emission.) +- ``agent_core`` is imported dynamically via ``importlib`` so static analysis here never + depends on it. +- Any failure is swallowed; emission must never affect the calling command's output. + +Records are appended as JSON lines to ``HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH`` +(default ``reports/agent-core-trace.jsonl``). +""" + +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +from typing import Any + +FLAG_ENV = "HYRULE_KNOWLEDGE_AGENT_CORE_TRACE" +PATH_ENV = "HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH" +_DEFAULT_PATH = "reports/agent-core-trace.jsonl" + + +def enabled() -> bool: + return os.environ.get(FLAG_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _sink_path() -> Path: + return Path(os.environ.get(PATH_ENV) or _DEFAULT_PATH) + + +def _append(record: dict[str, Any]) -> None: + path = _sink_path() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") + + +def emit_context_pack(pack_json: dict[str, Any], *, run_id: str | None = None) -> dict[str, Any] | None: + """Emit a TraceEvent for a built context pack. No-op unless enabled + agent_core present.""" + if not enabled(): + return None + try: + adapter = importlib.import_module("agent_core.adapters.knowledge") + event = adapter.trace_event_from_context_pack(pack_json, run_id=run_id) + record: dict[str, Any] = event.model_dump(mode="json") + _append(record) + return record + except Exception: # best-effort: emission must never break the command + return None + + +def emit_enrich_cost( + provider: str, model: str, target: str, *, run_id: str | None = None +) -> dict[str, Any] | None: + """Emit a model_call TraceEvent carrying CostUsage(provider, model) for an enrichment. + + Token/USD fidelity is a follow-up: the enrichment path does not yet surface usage. + """ + if not enabled(): + return None + try: + models_mod = importlib.import_module("agent_core.contracts.models") + tracing_mod = importlib.import_module("agent_core.contracts.tracing") + cost = models_mod.CostUsage(provider=provider, model=model) + event = tracing_mod.TraceEvent( + event_type="model_call", + node_id="enrich", + graph_id="knowledge", + agent_role="knowledge_synthesizer", + summary=f"enrich {target} via {provider}:{model}", + cost=cost, + run_id=run_id, + ) + record: dict[str, Any] = event.model_dump(mode="json") + _append(record) + return record + except Exception: # best-effort: emission must never break the command + return None diff --git a/src/hyrule_knowledge/cli.py b/src/hyrule_knowledge/cli.py index 89a0cb1..6864a1c 100644 --- a/src/hyrule_knowledge/cli.py +++ b/src/hyrule_knowledge/cli.py @@ -9,6 +9,7 @@ from datetime import UTC, datetime from pathlib import Path +from .agent_core_trace import emit_context_pack, emit_enrich_cost from .authority import AuthorityTier from .builder import build_all, source_ref from .config import SourceConfig, load_config @@ -405,6 +406,8 @@ def cmd_enrich(args: argparse.Namespace) -> int: print(f"enrichment failed: {exc}", file=sys.stderr) return 1 print(f"wrote enrichment concept: {path}") + if not args.dry_run: + emit_enrich_cost(args.provider, args.model, args.target) return 0 @@ -531,6 +534,7 @@ def cmd_context_pack(args: argparse.Namespace) -> int: print(str(exc), file=sys.stderr) return 1 data = pack.as_json() + emit_context_pack(data) if args.write: out = Path(args.write) out.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_agent_core_trace.py b/tests/test_agent_core_trace.py new file mode 100644 index 0000000..c34635a --- /dev/null +++ b/tests/test_agent_core_trace.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("agent_core") + +from hyrule_knowledge import agent_core_trace + +_PACK = { + "id": "ctx_0123456789abcdef0123456789abcdef", + "retrieval_version": "r3", + "policy_version": "p2", + "included_refs": [{"ref": "okf:x", "authority": "A1"}], + "policy_decision": {"decision": "allow"}, + "unresolved_questions": [], +} + + +def test_disabled_by_default(monkeypatch, tmp_path): + monkeypatch.delenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE", raising=False) + monkeypatch.setenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH", str(tmp_path / "t.jsonl")) + assert agent_core_trace.emit_context_pack(dict(_PACK)) is None + assert not (tmp_path / "t.jsonl").exists() + + +def test_context_pack_emits_when_enabled(monkeypatch, tmp_path): + sink = tmp_path / "t.jsonl" + monkeypatch.setenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE", "1") + monkeypatch.setenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH", str(sink)) + record = agent_core_trace.emit_context_pack(dict(_PACK)) + assert record is not None + assert record["event_type"] == "knowledge_context_pack" + lines = sink.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["event_type"] == "knowledge_context_pack" + + +def test_enrich_cost_emits_when_enabled(monkeypatch, tmp_path): + sink = tmp_path / "t.jsonl" + monkeypatch.setenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE", "1") + monkeypatch.setenv("HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH", str(sink)) + record = agent_core_trace.emit_enrich_cost("openrouter", "anthropic/claude-sonnet-4.6", "ansible") + assert record is not None + assert record["event_type"] == "model_call" + assert record["cost"]["provider"] == "openrouter"