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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
239 changes: 239 additions & 0 deletions src/hyrule_engineering_loop/insights.py
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +220 to +221

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't map candidate-only routing to draft

When the Governor returns allow_candidate, it intentionally leaves the issue at loop:candidate for human review, while only loop:approved is consumed by the daemon for draft PR work. This branch records both allow_candidate and allow_approved as action_selected="draft", so insight logs or arbitration built on these records will report/learn a draft action for issues that were not approved to draft. Reserve draft for allow_approved and use a human-facing action for allow_candidate.

Useful? React with 👍 / 👎.

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]
46 changes: 46 additions & 0 deletions tests/fixtures/insights/decisions.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
62 changes: 62 additions & 0 deletions tests/test_agent_core_insight_contract.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.