diff --git a/pyproject.toml b/pyproject.toml index 6be3002..257c96e 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.4.0" } +agent-core = { git = "https://github.com/AS215932/agent-core", tag = "v0.5.0" } diff --git a/src/hyrule_engineering_loop/agent_core_trace.py b/src/hyrule_engineering_loop/agent_core_trace.py index fdb0670..298874e 100644 --- a/src/hyrule_engineering_loop/agent_core_trace.py +++ b/src/hyrule_engineering_loop/agent_core_trace.py @@ -54,7 +54,7 @@ def emit_loop_trace(state: Mapping[str, Any]) -> int: adapter = importlib.import_module("agent_core.adapters.engineering_loop") sink = _sink_from_env() run_id = state.get("change_id") - events = adapter.trace_events_from_loop_trace(dict(state), run_id=run_id) + events = adapter.trace_events_from_loop_trace(_trace_payload(state), run_id=run_id) count = 0 for event in events: if sink.emit(event): @@ -62,3 +62,28 @@ def emit_loop_trace(state: Mapping[str, Any]) -> int: return count except Exception: # best-effort: emission must never break the loop return 0 + + +def emit_published_trace(state: Mapping[str, Any], pr_results: list[dict[str, Any]]) -> int: + """Re-emit trace after PR publication adds GitHub URL/commit metadata.""" + if not pr_results: + return 0 + return emit_loop_trace({**dict(state), "pr_status": "pushed", "pr_results": pr_results}) + + +def _trace_payload(state: Mapping[str, Any]) -> dict[str, Any]: + payload = dict(state) + pr_results = state.get("pr_results") + if not isinstance(pr_results, list) or not pr_results: + return payload + first = pr_results[0] + if not isinstance(first, Mapping): + return payload + github_pr = first.get("github_pr") + if isinstance(github_pr, Mapping) and github_pr.get("url") and not payload.get("pr_url"): + payload["pr_url"] = github_pr.get("url") + if first.get("commit") and not payload.get("commit_sha"): + payload["commit_sha"] = first.get("commit") + if first.get("repo") and not payload.get("repository"): + payload["repository"] = first.get("repo") + return payload diff --git a/src/hyrule_engineering_loop/cli.py b/src/hyrule_engineering_loop/cli.py index 5d7a183..889c2ff 100644 --- a/src/hyrule_engineering_loop/cli.py +++ b/src/hyrule_engineering_loop/cli.py @@ -19,6 +19,7 @@ run_feature_dry_live, run_feature_intake, ) +from hyrule_engineering_loop.agent_core_trace import emit_published_trace from hyrule_engineering_loop.graph import build_graph from hyrule_engineering_loop.knowledge_context import KnowledgeContextConfig from hyrule_engineering_loop.intake import ( @@ -292,6 +293,7 @@ def pr_command(args: argparse.Namespace) -> int: state["pr_labels"] = args.label state["pr_reviewers"] = args.reviewer state["pr_create_github"] = args.create_github_pr + emit_published_trace(state, pr_results) _write_state(path, state) print(f"[CLI] published {len(pr_results)} promoted worktree(s)") return 0 diff --git a/src/hyrule_engineering_loop/daemon.py b/src/hyrule_engineering_loop/daemon.py index 3acf075..dc40fd4 100644 --- a/src/hyrule_engineering_loop/daemon.py +++ b/src/hyrule_engineering_loop/daemon.py @@ -27,6 +27,7 @@ from pathlib import Path from typing import Any, Callable, TypeAlias +from hyrule_engineering_loop.agent_core_trace import emit_published_trace from hyrule_engineering_loop.feature import run_feature_intake from hyrule_engineering_loop.knowledge_context import KnowledgeContextConfig from hyrule_engineering_loop.lhp import LhpClientConfig, fetch_lhp_payload, parse_lhp_pointer, post_lhp_update, render_lhp_request @@ -526,6 +527,7 @@ def daemon_once( pr_reviewers=[], create_github_pr=True, ) + emit_published_trace(publish_state, pr_results) report.outcome = "published" github = pr_results[0].get("github_pr", {}) if pr_results else {} report.pr_url = github.get("url") if isinstance(github, dict) else None diff --git a/src/hyrule_engineering_loop/operator_harness.py b/src/hyrule_engineering_loop/operator_harness.py index 2ec6f6d..e94ab5d 100644 --- a/src/hyrule_engineering_loop/operator_harness.py +++ b/src/hyrule_engineering_loop/operator_harness.py @@ -10,6 +10,7 @@ from langgraph.checkpoint.memory import MemorySaver +from hyrule_engineering_loop.agent_core_trace import emit_published_trace from hyrule_engineering_loop.graph import build_graph from hyrule_engineering_loop.nodes import ALL_ROLES from hyrule_engineering_loop.pr import publish_promoted_worktrees @@ -143,6 +144,7 @@ def run_operator_dry_run( "pr_results": pr_results, "pr_create_github": True, } + emit_published_trace(published_state, pr_results) _write_state(state_path, published_state) first_result = pr_results[0] diff --git a/tests/test_agent_core_trace.py b/tests/test_agent_core_trace.py index 206ee70..8bd32c3 100644 --- a/tests/test_agent_core_trace.py +++ b/tests/test_agent_core_trace.py @@ -43,6 +43,21 @@ def log_message(self, _format: str, *args: object) -> None: def _state() -> dict[str, object]: return { "change_id": "chg-test-1", + "pr_results": [ + { + "repo": "network-operations", + "commit": "abc123", + "github_pr": {"created": True, "url": "https://github.com/AS215932/network-operations/pull/318"}, + } + ], + "workflow_run_id": "28392093138", + "trace_events": [ + { + "node": "hydrate_context", + "timestamp": "2026-06-29T00:00:00Z", + "output": {"status": "passed"}, + } + ], "llm_outputs": [ { "role": "security_auditor", @@ -73,16 +88,49 @@ def test_emits_when_enabled(monkeypatch, tmp_path): monkeypatch.setenv("HYRULE_ENGINEERING_AGENT_CORE_TRACE", "1") monkeypatch.setenv("HYRULE_ENGINEERING_AGENT_CORE_TRACE_PATH", str(sink)) count = agent_core_trace.emit_loop_trace(_state()) - assert count == 3 + assert count == 4 lines = sink.read_text(encoding="utf-8").strip().splitlines() - assert len(lines) == 3 + assert len(lines) == 4 kinds = {json.loads(line)["event_type"] for line in lines} - assert kinds == {"model_call", "tool_call", "backend_execution"} + assert kinds == {"model_call", "tool_call", "backend_execution", "loop_node"} backend = next( json.loads(line) for line in lines if json.loads(line)["event_type"] == "backend_execution" ) assert backend["cost"]["input_tokens"] == 100 assert backend["run_id"] == "chg-test-1" + assert backend["change_id"] == "chg-test-1" + assert backend["pr_number"] == 318 + assert backend["commit_sha"] == "abc123" + assert backend["workflow_run_id"] == "28392093138" + loop_node = next(json.loads(line) for line in lines if json.loads(line)["event_type"] == "loop_node") + assert loop_node["parent_event_id"] + + +def test_emit_published_trace_reemits_pr_correlation(monkeypatch, tmp_path): + sink = tmp_path / "published.jsonl" + state = _state() + state.pop("pr_results") + monkeypatch.setenv("HYRULE_ENGINEERING_AGENT_CORE_TRACE", "1") + monkeypatch.setenv("HYRULE_ENGINEERING_AGENT_CORE_TRACE_PATH", str(sink)) + + count = agent_core_trace.emit_published_trace( + state, + [ + { + "repo": "network-operations", + "commit": "publishedabc", + "github_pr": { + "created": True, + "url": "https://github.com/AS215932/network-operations/pull/319", + }, + } + ], + ) + + assert count == 4 + records = [json.loads(line) for line in sink.read_text(encoding="utf-8").splitlines()] + assert {record["pr_number"] for record in records} == {319} + assert {record["commit_sha"] for record in records} == {"publishedabc"} def test_emits_to_collector_and_file_when_collector_url_is_set(monkeypatch, tmp_path): @@ -93,10 +141,12 @@ def test_emits_to_collector_and_file_when_collector_url_is_set(monkeypatch, tmp_ monkeypatch.setenv("HYRULE_ENGINEERING_AGENT_CORE_TRACE_COLLECTOR_URL", url) count = agent_core_trace.emit_loop_trace(_state()) - assert count == 3 - assert len(sink.read_text(encoding="utf-8").strip().splitlines()) == 3 + assert count == 4 + assert len(sink.read_text(encoding="utf-8").strip().splitlines()) == 4 assert [event["event_type"] for event in received] == [ "model_call", "tool_call", "backend_execution", + "loop_node", ] + assert received[-1]["change_id"] == "chg-test-1" diff --git a/uv.lock b/uv.lock index 14551da..6f07372 100644 --- a/uv.lock +++ b/uv.lock @@ -11,8 +11,8 @@ resolution-markers = [ [[package]] name = "agent-core" -version = "0.4.0" -source = { git = "https://github.com/AS215932/agent-core?tag=v0.4.0#0faf25793eaa0ca4ad74382f544aa2e10d7c80db" } +version = "0.5.0" +source = { git = "https://github.com/AS215932/agent-core?tag=v0.5.0#0765283c2644fcab53ccfb5b8b3ce4184b083adb" } dependencies = [ { name = "pydantic" }, ] @@ -365,7 +365,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-core", git = "https://github.com/AS215932/agent-core?tag=v0.4.0" }, + { name = "agent-core", git = "https://github.com/AS215932/agent-core?tag=v0.5.0" }, { name = "langgraph", specifier = ">=0.2.70" }, { name = "mcp", specifier = ">=1.27.0" }, { name = "pydantic", specifier = ">=2.10" },