Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/hyrule_knowledge/agent_core_trace.py
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require an explicit out-of-tree trace sink

When HYRULE_KNOWLEDGE_AGENT_CORE_TRACE is enabled without setting HYRULE_KNOWLEDGE_AGENT_CORE_TRACE_PATH, this defaults raw agent-core traces into reports/agent-core-trace.jsonl inside the repository. The emitted context-pack payload includes the raw task text via task.summary and task_summary sections, and reports/ is not gitignored while the repo guide explicitly says to keep live traces/telemetry out of git except deterministic fixtures. Please default to a gitignored/out-of-tree location or require an explicit sink path so an enabled local run does not create commit-ready live trace data.

Useful? React with 👍 / 👎.



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
4 changes: 4 additions & 0 deletions src/hyrule_knowledge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_agent_core_trace.py
Original file line number Diff line number Diff line change
@@ -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"