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
64 changes: 64 additions & 0 deletions .github/workflows/pr-agent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
name: pr-agent

# Advisory AI PR review (same loop as network-operations/noc-agent). Runs on
# the UNPRIVILEGED `hyrule-public-pr` runner only, with read + PR/issue-comment
# permissions and our own OpenRouter key. Never deploys, never writes code,
# never auto-merges. Config (model/fallback/instructions): .pr_agent.toml.
# Design: network-operations/docs/ci/pr-agent.md.
#
# Org prerequisites (one-time, done 2026-07-10): repo is in the public-pr
# runner group and in OPENROUTER_API_KEY's selected repositories.

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

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 Configure PR-Agent for synchronize runs

For PRs that receive new commits after opening, this workflow now fires on synchronize, but neither .pr_agent.toml nor the env block sets github_action_config.pr_actions or handle_push_trigger. I checked PR-Agent’s GitHub Action docs: adding "synchronize" to pr_actions enables auto tools on new commits, and the documented default omits it; as written, follow-up pushes start the job but skip the advertised review/improve pass, so updated diffs can miss advisory review.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining for this PR, same as the two prior rounds (agent-core#11, soc-agent#4): skipping bare synchronize pushes is the documented fleet behavior (network-operations/docs/ci/pr-agent.md — re-run via close/reopen), and this repo stays consistent with network-operations/noc-agent. Noted as a fleet-policy option for the operator: adding "synchronize" to github_action_config.pr_actions in each repo's .pr_agent.toml enables push-refresh reviews everywhere if wanted.

issue_comment:
types: [created]

permissions:
contents: read
pull-requests: write
issues: write

# Group by event name too: the push-triggered review posts comments, and each
# comment fires an issue_comment run that would otherwise share this group and
# cancel the in-flight check run ("higher priority waiting request",
# network-operations#408). Same-event storms still dedupe.
concurrency:
group: pr-agent-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true

jobs:
pr-agent:
# Public-fork policy: auto-review ONLY for same-repo (internal) PRs, and
# slash commands ONLY from trusted authors. This keeps OPENROUTER_API_KEY
# off untrusted fork PRs and stops anyone from burning the OpenRouter
# budget via /ask, /improve, etc.
if: >
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: [self-hosted, linux, x64, hyrule-public-pr]
timeout-minutes: 12
steps:
- name: PR-Agent
uses: The-PR-Agent/pr-agent@8e4d32e5497defd43c023a404f73560c62728961 # v0.39.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PR-Agent reads the OpenRouter key from openrouter__key; litellm also
# honours OPENROUTER_API_KEY. Set both from the org secret.
OPENROUTER__KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
# Model pin via dynaconf env (SECTION__KEY). .pr_agent.toml is only
# honoured from the DEFAULT branch (no checkout step in the action),
# so until this PR merges the toml is invisible and PR-Agent would
# fall back to its packaged gpt-5.5 default. Keep the env pin even
# after the toml lands (belt and braces; custom models require
# custom_model_max_tokens).
CONFIG__MODEL: openrouter/deepseek/deepseek-v4-flash
CONFIG__FALLBACK_MODELS: '["openrouter/minimax/minimax-m2.7"]'
CONFIG__CUSTOM_MODEL_MAX_TOKENS: "128000"
48 changes: 48 additions & 0 deletions .pr_agent.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# PR-Agent (advisory) configuration for AS215932/engineering-loop.
# Replaces hosted Sourcery. Runs only on the unprivileged hyrule-public-pr
# runner with read/comment-only perms, on our OpenRouter key.
# Design: network-operations/docs/ci/pr-agent.md.

[config]
git_provider = "github"
# deepseek-v4-flash / minimax-m2.7 are custom models -> need custom_model_max_tokens.
model = "openrouter/deepseek/deepseek-v4-flash"
fallback_models = ["openrouter/minimax/minimax-m2.7"]
custom_model_max_tokens = 128000
temperature = 0.2
use_repo_settings_file = true
publish_output = true

[github_action_config]
auto_review = true
auto_describe = false
auto_improve = true

[pr_reviewer]
require_security_review = true
require_tests_review = true
require_score_review = true
require_estimate_effort_to_review = true
extra_instructions = """
This is engineering-loop: the autonomous change-implementation runtime
(LangGraph daemon + Reliability Governor) that turns approved GitHub issues
into draft PRs inside guarded worktrees. HIGHEST RISK is autonomy-boundary
erosion. Flag, with specifics:
- anything that could merge, push to protected branches, or apply production
changes (the loop stops at a DRAFT PR — that invariant is absolute);
- consumption of issues without the loop:approved label, Governor
tier/capability-envelope weakening, or auto-approval beyond Tier 0/1 without
the success-history evidence path;
- path-allowlist / denied_path_globs / denied_content_patterns bypasses in
policy or worktree handling; secrets reaching prompts, logs, or artifacts;
- LHP trust violations: issue prose treated as trusted instead of the
HMAC-fetched payload, pointer-hash checks skipped, callbacks setting
verified/resolved (NOC/SOC own closure);
- budget/backstop weakening (runs/cost per day, CI-refusal, singleton lock);
- unpinned GitHub Actions, broad GITHUB_TOKEN permissions, added
pull_request_target, or pull_request jobs on privileged runner labels.
Do not restate the diff; surface risk, invariant breaks, missing tests.
"""

[pr_description]
publish_description_as_comment = true
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.7.0" }
agent-core = { git = "https://github.com/AS215932/agent-core", tag = "v0.8.0" }
122 changes: 111 additions & 11 deletions src/hyrule_engineering_loop/agent_core_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,23 @@ def enabled() -> bool:

def _sink_from_env() -> Any:
sink_mod = importlib.import_module("agent_core.tracing.sink")
path_configured = bool(os.environ.get(PATH_ENV, "").strip())
collector_configured = bool(os.environ.get(COLLECTOR_URL_ENV, "").strip())
if path_configured or collector_configured:
return sink_mod.sink_from_env(FLAG_ENV)

original_path = os.environ.get(PATH_ENV)
os.environ[PATH_ENV] = _DEFAULT_PATH
overlays: dict[str, str] = {}
if not os.environ.get(PATH_ENV, "").strip() and not os.environ.get(COLLECTOR_URL_ENV, "").strip():
overlays[PATH_ENV] = _DEFAULT_PATH
if not enabled() and _insight_records_enabled():
# sink_from_env gates on the master trace flag; the insight flag is a
# complete opt-in of its own, so satisfy the gate for this build only.
overlays[FLAG_ENV] = "1"
originals = {key: os.environ.get(key) for key in overlays}
os.environ.update(overlays)
try:
return sink_mod.sink_from_env(FLAG_ENV)
finally:
if original_path is None:
os.environ.pop(PATH_ENV, None)
else:
os.environ[PATH_ENV] = original_path
for key, original in originals.items():
if original is None:
os.environ.pop(key, None)
else:
os.environ[key] = original


def emit_loop_trace(state: Mapping[str, Any]) -> int:
Expand Down Expand Up @@ -74,6 +77,103 @@ def emit_published_trace(state: Mapping[str, Any], pr_results: list[dict[str, An
return emit_loop_trace({**dict(state), "pr_status": "pushed", "pr_results": pr_results})


def emit_insight_decision_envelopes(
insights: list[dict[str, Any]],
*,
input_event: Mapping[str, Any] | None = None,
) -> int:
"""Emit one LoopDecisionEnvelope TraceEvent per insight record.

Mirrors the NOC/SOC modules: the payload carries both the envelope and the
full validated ``InsightDecisionRecord`` (the envelope alone drops
sampling_class/utility/cost/support_facts, which the knowledge repo's
IDQ/CGS evaluation needs). Best-effort like everything else here.
"""
if not insights or not (enabled() or _insight_records_enabled()):
return 0
try:
sink = _sink_from_env()
count = 0
for insight in insights:
event = _insight_decision_event(insight, input_event=dict(input_event or {}))
if sink.emit(event):
count += 1
return count
except Exception:
return 0


def _insight_records_enabled() -> bool:
# HYRULE_ENGINEERING_INSIGHT_RECORDS=1 is a complete opt-in for insight
# envelopes: operators must not need to discover the trace master flag
# too, or the ledger and the trace sink silently diverge. The sink still
# honours the {FLAG_ENV}_COLLECTOR_URL/_PATH vars (JSONL fallback when
# neither is set).
return os.environ.get("HYRULE_ENGINEERING_INSIGHT_RECORDS", "").strip().lower() in _TRUTHY


def _insight_decision_event(insight: Mapping[str, Any], *, input_event: dict[str, Any]) -> Any:
contracts = importlib.import_module("agent_core.contracts")
TraceEvent = getattr(contracts, "TraceEvent")
LoopDecisionEnvelope = getattr(contracts, "LoopDecisionEnvelope")
InsightDecisionRecord = getattr(contracts, "InsightDecisionRecord")

validated = InsightDecisionRecord.model_validate(dict(insight))
# Correlate with the daemon/governor cycle when the insight carries no
# explicit trace id, so consumers can join the stream back to its run.
trace_id = validated.trace_id or _string_or_none(input_event.get("run_id"))
envelope = LoopDecisionEnvelope(
envelope_id=(
f"ldec_eng_{_stable_hash([validated.insight_id, validated.fingerprint, validated.action_selected])}"
),
loop="engineering",
environment="production",
graph_id="engineering-loop",
node_id="insight_stream",
agent_role="engineering_loop",
run_id=_string_or_none(input_event.get("run_id")) or validated.run_id,
trace_id=trace_id,
input_event={
**input_event,
"candidate_type": validated.candidate_type,
"candidate_source": validated.candidate_source,
},
retrieved_context=validated.evidence_refs,
decision=validated.action_selected,
evidence_refs=validated.evidence_refs,
proposed_action={
"candidate_type": validated.candidate_type,
"candidate_source": validated.candidate_source,
"why_now": validated.why_now,
"support_fact_count": len(validated.support_facts),
},
human_outcome=validated.human_feedback,
governance=validated.governance,
insight_id=validated.insight_id,
case_id=validated.case_id,
fingerprint=validated.fingerprint,
policy_version=validated.policy_version,
)
return TraceEvent(
event_type="loop_decision_envelope",
graph_id="engineering-loop",
node_id="loop_decision_envelope",
agent_role="engineering_loop",
environment="production",
run_id=envelope.run_id,
trace_id=envelope.trace_id,
summary=f"Engineering loop decision envelope for {validated.insight_id}",
payload={
"loop_decision_envelope": envelope.model_dump(mode="json"),
"insight_decision_record": validated.model_dump(mode="json"),
# support_facts carry issue/signal titles — LHP-untrusted text —
# so the payload gets the same guard fields as the loop traces.
"untrusted_loop_text": True,
"model_consumption_allowed": False,
},
)


def _trace_payload(state: Mapping[str, Any]) -> dict[str, Any]:
payload = dict(state)
pr_results = state.get("pr_results")
Expand Down
Loading