diff --git a/pyproject.toml b/pyproject.toml index 257c96e..f5c1fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,4 +36,4 @@ python_version = "3.12" strict = true [tool.uv.sources] -agent-core = { git = "https://github.com/AS215932/agent-core", tag = "v0.5.0" } +agent-core = { git = "https://github.com/AS215932/agent-core", tag = "v0.7.0" } diff --git a/src/hyrule_engineering_loop/insights.py b/src/hyrule_engineering_loop/insights.py new file mode 100644 index 0000000..bd5426c --- /dev/null +++ b/src/hyrule_engineering_loop/insights.py @@ -0,0 +1,239 @@ +"""Private insight ledger for Engineering Loop proactivity decisions. + +The public ``agent-core`` contract owns the canonical shape. This module emits +compatible JSON and validates it when records are written. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +InsightAction = Literal["notify", "question", "draft", "stay_silent"] +SamplingClass = Literal["surfaced", "withheld_logged", "sampled_quiet_interval"] + + +def write_insight_record(record: dict[str, Any], state_dir: Path) -> Path: + _validate_with_agent_core(record) + root = state_dir.expanduser().resolve() / "insights" + root.mkdir(parents=True, exist_ok=True) + day = datetime.now(UTC).strftime("%Y-%m-%d") + path = root / f"{day}.jsonl" + path.open("a", encoding="utf-8").write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + return path + + +def _validate_with_agent_core(record: dict[str, Any]) -> None: + """Validate when the released insight contract is installed. + + Older deployments may still carry an agent-core version that predates the + insight contract; those keep emitting the already-compatible JSON until the + repo dependency is bumped. + """ + + try: + contracts = importlib.import_module("agent_core.contracts") + model = getattr(contracts, "InsightDecisionRecord") + except (ImportError, AttributeError): + return + model.model_validate(record) + + +def signal_insight_record( + *, + repo: str, + source: str, + identifier: str, + title: str, + context: str, + action_items: tuple[str, ...] | list[str], + related: tuple[str, ...] | list[str], + fingerprint: str, + action_selected: InsightAction, + sampling_class: SamplingClass, + why_now: str, + issue_ref: str = "", + policy_version: str = "engineering-intake.v1", +) -> dict[str, Any]: + utility = _utility_for_signal(action_items=action_items, related=related, issue_ref=issue_ref) + cost = _interruption_cost_for_action(action_selected=action_selected, why_now=why_now) + return _base_record( + fingerprint=fingerprint, + sampling_class=sampling_class, + candidate_type="signal", + candidate_source=f"engineering_intake:{source}", + action_selected=action_selected, + why_now=why_now, + support_facts=[title, context, *list(action_items)[:4]], + evidence_refs=[{"kind": "repo", "ref": repo}, *[{"kind": "related", "ref": item} for item in list(related)[:6]]], + expected_utility=utility, + interruption_cost=cost, + confidence=0.75 if action_selected != "stay_silent" else 0.65, + policy_version=policy_version, + budget_context={"repo": repo, "issue_ref": issue_ref}, + ) + + +def governor_insight_record( + *, + issue_id: str, + title: str, + routing_decision: str, + reasons: list[str], + labels: list[str], + policy_version: str, + action_selected: InsightAction | None = None, + sampling_class: SamplingClass = "surfaced", +) -> dict[str, Any]: + action = action_selected or _action_for_routing_decision(routing_decision) + why_now = "; ".join(reasons[:3]) or f"Governor routed issue as {routing_decision}." + return _base_record( + fingerprint=_stable_hash(f"governor:{issue_id}:{routing_decision}"), + sampling_class=sampling_class, + candidate_type="github_issue", + candidate_source="reliability_governor", + action_selected=action, + why_now=why_now, + support_facts=[title, routing_decision, *reasons[:6]], + evidence_refs=[{"kind": "github_issue", "ref": issue_id}, *[{"kind": "label", "ref": label} for label in labels]], + expected_utility={ + "total": 0.7 if action != "stay_silent" else 0.15, + "components": {"routing_decision": 0.7 if action != "stay_silent" else 0.15}, + "rationale": [routing_decision], + }, + interruption_cost=_interruption_cost_for_action(action_selected=action, why_now=why_now), + confidence=0.8, + policy_version=policy_version, + budget_context={"issue_id": issue_id}, + ) + + +def daemon_insight_record( + *, + action_selected: InsightAction, + sampling_class: SamplingClass, + why_now: str, + issue_ref: str = "", + policy_version: str = "engineering-daemon.v1", + budget_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + return _base_record( + fingerprint=_stable_hash(f"daemon:{issue_ref}:{why_now}"), + sampling_class=sampling_class, + candidate_type="approved_queue", + candidate_source="engineering_daemon", + action_selected=action_selected, + why_now=why_now, + support_facts=[why_now, issue_ref] if issue_ref else [why_now], + evidence_refs=[{"kind": "github_issue", "ref": issue_ref}] if issue_ref else [], + expected_utility={ + "total": 0.75 if action_selected == "draft" else 0.05, + "components": {"approved_queue": 0.75 if action_selected == "draft" else 0.05}, + "rationale": [why_now], + }, + interruption_cost=_interruption_cost_for_action(action_selected=action_selected, why_now=why_now), + confidence=0.8, + policy_version=policy_version, + budget_context=budget_context or {}, + ) + + +def _base_record( + *, + fingerprint: str, + sampling_class: SamplingClass, + candidate_type: str, + candidate_source: str, + action_selected: InsightAction, + why_now: str, + support_facts: list[str], + evidence_refs: list[dict[str, str]], + expected_utility: dict[str, Any], + interruption_cost: dict[str, Any], + confidence: float, + policy_version: str, + budget_context: dict[str, Any], +) -> dict[str, Any]: + now = datetime.now(UTC).isoformat() + return { + "schema_version": "0.1.0", + "insight_id": f"ins_eng_{_stable_hash(f'{fingerprint}:{now}')}", + "loop": "engineering", + "created_at": now, + "fingerprint": fingerprint, + "sampling_class": sampling_class, + "candidate_type": candidate_type, + "candidate_source": candidate_source, + "state_snapshot_refs": [], + "support_facts": [str(item) for item in support_facts if str(item).strip()][:10], + "evidence_refs": evidence_refs, + "action_space": ["notify", "question", "draft", "stay_silent"], + "action_selected": action_selected, + "why_now": why_now, + "why_not_other_actions": _why_not_other_actions(action_selected), + "expected_utility": expected_utility, + "interruption_cost": interruption_cost, + "confidence": confidence, + "risk_class": "medium", + "policy_version": policy_version, + "tool_versions": {}, + "budget_context": budget_context, + } + + +def _utility_for_signal( + *, action_items: tuple[str, ...] | list[str], related: tuple[str, ...] | list[str], issue_ref: str +) -> dict[str, Any]: + action_component = min(0.4, len(action_items) * 0.12) + grounding_component = min(0.35, len(related) * 0.08) + dedupe_component = 0.0 if issue_ref else 0.2 + total = round(min(1.0, action_component + grounding_component + dedupe_component), 3) + return { + "total": total, + "components": { + "actionability": action_component, + "grounding": grounding_component, + "novelty": dedupe_component, + }, + "rationale": ["read-only mined signal", "candidate issue protocol"], + } + + +def _interruption_cost_for_action(*, action_selected: InsightAction, why_now: str) -> dict[str, Any]: + cost = 0.15 if action_selected in {"draft", "stay_silent"} else 0.35 + if "dedupe" in why_now.lower() or "unchanged" in why_now.lower(): + cost += 0.25 + return { + "total": round(min(1.0, cost), 3), + "components": {"operator_attention": cost}, + "rationale": [why_now], + } + + +def _action_for_routing_decision(routing_decision: str) -> InsightAction: + if routing_decision in {"needs_context", "knowledge_gap", "needs_human"}: + return "question" + if routing_decision in {"allow_candidate", "allow_approved"}: + return "draft" + return "stay_silent" + + +def _why_not_other_actions(action_selected: InsightAction) -> dict[str, str]: + reasons: dict[str, str] = {} + if action_selected != "notify": + reasons["notify"] = "A durable issue/decision artifact is more appropriate than a transient notification." + if action_selected != "question": + reasons["question"] = "The available evidence is sufficient for the selected route." + if action_selected != "draft": + reasons["draft"] = "The candidate is not ready for draft work under current policy." + if action_selected != "stay_silent": + reasons["stay_silent"] = "The candidate is relevant now and should remain visible." + return reasons + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] diff --git a/tests/fixtures/insights/decisions.json b/tests/fixtures/insights/decisions.json new file mode 100644 index 0000000..f3b37dc --- /dev/null +++ b/tests/fixtures/insights/decisions.json @@ -0,0 +1,46 @@ +{ + "insights": [ + { + "schema_version": "0.1.0", + "insight_id": "ins_eng_question", + "loop": "engineering", + "fingerprint": "eng-question", + "sampling_class": "surfaced", + "candidate_type": "github_issue", + "candidate_source": "reliability_governor", + "support_facts": ["Issue needs context", "needs_context"], + "evidence_refs": [{"kind": "github_issue", "ref": "AS215932/engineering-loop#32"}], + "action_selected": "question", + "why_now": "Governor needs missing context before routing.", + "policy_version": "engineering-insight.fixture" + }, + { + "schema_version": "0.1.0", + "insight_id": "ins_eng_draft", + "loop": "engineering", + "fingerprint": "eng-draft", + "sampling_class": "surfaced", + "candidate_type": "approved_queue", + "candidate_source": "engineering_daemon", + "support_facts": ["Approved work is ready"], + "evidence_refs": [{"kind": "github_issue", "ref": "AS215932/engineering-loop#33"}], + "action_selected": "draft", + "why_now": "Approved queue item can be drafted.", + "policy_version": "engineering-insight.fixture" + }, + { + "schema_version": "0.1.0", + "insight_id": "ins_eng_silent", + "loop": "engineering", + "fingerprint": "eng-silent", + "sampling_class": "withheld_logged", + "candidate_type": "signal", + "candidate_source": "engineering_intake:fixture", + "support_facts": ["Duplicate signal already has an open issue"], + "evidence_refs": [{"kind": "related", "ref": "AS215932/engineering-loop#32"}], + "action_selected": "stay_silent", + "why_now": "Existing issue already covers the signal.", + "policy_version": "engineering-insight.fixture" + } + ] +} diff --git a/tests/test_agent_core_insight_contract.py b/tests/test_agent_core_insight_contract.py new file mode 100644 index 0000000..02e52e0 --- /dev/null +++ b/tests/test_agent_core_insight_contract.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from agent_core.contracts import InsightDecisionRecord + +from hyrule_engineering_loop.insights import ( + daemon_insight_record, + governor_insight_record, + signal_insight_record, +) + + +FIXTURE = Path(__file__).resolve().parent / "fixtures" / "insights" / "decisions.json" + + +def test_engineering_insight_fixtures_validate_against_agent_core_contract() -> None: + payload = json.loads(FIXTURE.read_text(encoding="utf-8")) + + actions: set[str] = set() + for row in payload["insights"]: + actions.add(str(row["action_selected"])) + InsightDecisionRecord.model_validate(row) + + assert actions == {"question", "draft", "stay_silent"} + + +def test_engineering_insight_builders_emit_agent_core_records() -> None: + rows = [ + signal_insight_record( + repo="AS215932/engineering-loop", + source="fixture", + identifier="sig-1", + title="Fixture signal", + context="A read-only mined signal exists.", + action_items=["review"], + related=["AS215932/engineering-loop#32"], + fingerprint="sig-fp", + action_selected="stay_silent", + sampling_class="withheld_logged", + why_now="Existing issue already covers the signal.", + ), + governor_insight_record( + issue_id="AS215932/engineering-loop#32", + title="Needs context", + routing_decision="needs_context", + reasons=["Knowledge context is missing."], + labels=["loop:needs-context"], + policy_version="fixture-governor.v1", + ), + daemon_insight_record( + action_selected="draft", + sampling_class="surfaced", + why_now="Approved queue item can be drafted.", + issue_ref="AS215932/engineering-loop#33", + policy_version="fixture-daemon.v1", + ), + ] + + for row in rows: + InsightDecisionRecord.model_validate(row) diff --git a/uv.lock b/uv.lock index 6f07372..0b45454 100644 --- a/uv.lock +++ b/uv.lock @@ -11,8 +11,8 @@ resolution-markers = [ [[package]] name = "agent-core" -version = "0.5.0" -source = { git = "https://github.com/AS215932/agent-core?tag=v0.5.0#0765283c2644fcab53ccfb5b8b3ce4184b083adb" } +version = "0.7.0" +source = { git = "https://github.com/AS215932/agent-core?tag=v0.7.0#6f79630aeab6f5d9019671fe612a153e286f5321" } dependencies = [ { name = "pydantic" }, ] @@ -365,7 +365,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-core", git = "https://github.com/AS215932/agent-core?tag=v0.5.0" }, + { name = "agent-core", git = "https://github.com/AS215932/agent-core?tag=v0.7.0" }, { name = "langgraph", specifier = ">=0.2.70" }, { name = "mcp", specifier = ">=1.27.0" }, { name = "pydantic", specifier = ">=2.10" },