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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.venv
.engineering-loop-state
.mypy_cache
.pytest_cache
.ruff_cache
__pycache__
*.pyc
.env
.env.*
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM python:3.12-slim AS runtime

ENV PYTHONUNBUFFERED=1 \
UV_NO_CACHE=1 \
PATH="/app/.venv/bin:${PATH}"

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git gh openssh-client \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /uvx /usr/local/bin/

WORKDIR /app

COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY configs ./configs
COPY docs ./docs
COPY skills ./skills
COPY model-policy.yml engineering-loop-policy.yml ./

RUN uv sync --frozen --no-dev

ENTRYPOINT ["hyrule-engineering-loop"]
CMD ["--help"]
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,16 @@ uv run hyrule-engineering-loop --help
uv run hyrule-engineering-loop daemon --once
```

A feature run can optionally include a local knowledge context pack from
A Docker image can be built for the `loop` VM runtime shape:

```bash
docker build -t as215932/engineering-loop:local .
docker run --rm as215932/engineering-loop:local --help
```

A feature run can optionally include a read-only knowledge context pack from
`AS215932/knowledge` without enabling live tools, telemetry, LLM calls, or writes
in the knowledge repo:
in the knowledge repo. Local development can shell out to a checkout:

```bash
uv run hyrule-engineering-loop feature CHANGE_ID \
Expand All @@ -68,6 +75,16 @@ uv run hyrule-engineering-loop feature CHANGE_ID \
--knowledge-repo /home/svag/Dev/knowledge
```

On the dedicated `loop` VM, prefer the containerized knowledge MCP server over
shelling out to a checkout:

```bash
uv run hyrule-engineering-loop daemon --once \
--knowledge-context \
--knowledge-mcp-url http://127.0.0.1:8767/mcp \
--knowledge-mcp-transport streamable-http
```

A run can also write a local sanitized learning-event artifact for human
promotion into `AS215932/knowledge` later. This does not write to the knowledge
repo and excludes raw prompts, diffs, transcripts, command output, and secrets.
Expand All @@ -84,9 +101,9 @@ uv run hyrule-engineering-loop feature CHANGE_ID \
--knowledge-learning-dir .engineering-loop-state/learning-events
```

The daemon's default production scope is the seven core repos:
The daemon's default production scope is the eight core repos:
`engineering-loop`, `network-operations`, `hyrule-cloud`, `hyrule-web`,
`hyrule-mcp`, `noc-agent`, and `hyrule-network-proxy`. It runs low-and-slow by
`hyrule-mcp`, `noc-agent`, `hyrule-network-proxy`, and `as215932.net`. It runs low-and-slow by
default: at most 2 runs/day, $10/day, and docs-only mutation boundaries unless
a later reviewed PR widens them.

Expand All @@ -100,12 +117,12 @@ The backend executes generated code. CI runs only on the unprivileged
`ci-pr` runner (label `hyrule-public-pr`); the daemon refuses to run when
`GITHUB_ACTIONS` is set. Never schedule it on a privileged runner.

Knowledge context is read-only and policy-scoped. It shells out to a local
`hyrule-knowledge context-pack` command (or reads an explicit JSON fixture in
tests) and stores the returned citations in graph state. It must not call live
MCP/Prometheus/Icinga endpoints or expose secrets. Optional learning events are
local sanitized artifacts only; humans import, review, and promote them in the
knowledge repo.
Knowledge context is read-only and policy-scoped. It can call the local
`hyrule-knowledge context-pack` command, a loopback `AS215932/knowledge` MCP
HTTP/SSE endpoint, or an explicit JSON fixture in tests. It stores returned
citations in graph state. It must not call live Prometheus/Icinga endpoints or
expose secrets. Optional learning events are local sanitized artifacts only;
humans import, review, and promote them in the knowledge repo.

## Related repositories

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Runnable LangGraph skeleton for the Hyrule Engineering Loop"
requires-python = ">=3.12"
dependencies = [
"langgraph>=0.2.70",
"mcp>=1.27.0",
"pydantic>=2.10",
"pyyaml>=6.0",
]
Expand Down
17 changes: 17 additions & 0 deletions src/hyrule_engineering_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ def _knowledge_context_config(args: argparse.Namespace) -> KnowledgeContextConfi
authority_min=getattr(args, "knowledge_context_authority_min", "A4"),
timeout_seconds=int(getattr(args, "knowledge_context_timeout", 20)),
fixture_path=Path(args.knowledge_context_fixture) if getattr(args, "knowledge_context_fixture", None) else None,
mcp_url=getattr(args, "knowledge_mcp_url", None),
mcp_transport=getattr(args, "knowledge_mcp_transport", "streamable-http"),
)


Expand Down Expand Up @@ -504,6 +506,8 @@ def daemon_command(args: argparse.Namespace) -> int:
repo: tuple(prefixes)
for repo, prefixes in _parse_repo_paths(args.allow, option="--allow").items()
},
knowledge_context=_knowledge_context_config(args),
knowledge_learning_dir=args.knowledge_learning_dir,
)
report = daemon_once(config, client=GhCli())
print(json.dumps(report.as_dict(), indent=2, sort_keys=True))
Expand Down Expand Up @@ -838,6 +842,8 @@ def build_parser() -> argparse.ArgumentParser:
feature_parser.add_argument("--knowledge-context", action="store_true", help="include a read-only AS215932 knowledge context pack (default off)")
feature_parser.add_argument("--knowledge-context-fixture", help="load a context-pack JSON fixture instead of invoking the knowledge CLI")
feature_parser.add_argument("--knowledge-repo", default="../knowledge")
feature_parser.add_argument("--knowledge-mcp-url", help="load context through a read-only knowledge MCP HTTP/SSE endpoint")
feature_parser.add_argument("--knowledge-mcp-transport", default="streamable-http", choices=["streamable-http", "http", "sse"])
feature_parser.add_argument("--knowledge-context-role", default="engineering_loop")
feature_parser.add_argument("--knowledge-context-risk", default="low")
feature_parser.add_argument("--knowledge-context-budget", type=int, default=6000)
Expand Down Expand Up @@ -878,6 +884,17 @@ def build_parser() -> argparse.ArgumentParser:
metavar="REPO=PATH_PREFIX",
help="widen allowed write paths for a repo (default: docs only). Repeatable.",
)
daemon_parser.add_argument("--knowledge-context", action="store_true", help="include a read-only AS215932 knowledge context pack (default off)")
daemon_parser.add_argument("--knowledge-context-fixture", help="load a context-pack JSON fixture instead of invoking knowledge")
daemon_parser.add_argument("--knowledge-repo", default="../knowledge")
daemon_parser.add_argument("--knowledge-mcp-url", help="load context through a read-only knowledge MCP HTTP/SSE endpoint")
daemon_parser.add_argument("--knowledge-mcp-transport", default="streamable-http", choices=["streamable-http", "http", "sse"])
daemon_parser.add_argument("--knowledge-context-role", default="engineering_loop")
daemon_parser.add_argument("--knowledge-context-risk", default="low")
daemon_parser.add_argument("--knowledge-context-budget", type=int, default=6000)
daemon_parser.add_argument("--knowledge-context-authority-min", default="A4")
daemon_parser.add_argument("--knowledge-context-timeout", type=int, default=20)
daemon_parser.add_argument("--knowledge-learning-dir", help="write a sanitized local learning-event artifact (default off)")
daemon_parser.set_defaults(func=daemon_command)

intake_parser = subparsers.add_parser("intake", help="signal mining and triage inbox")
Expand Down
5 changes: 5 additions & 0 deletions src/hyrule_engineering_loop/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from typing import Any, Callable, TypeAlias

from hyrule_engineering_loop.feature import run_feature_intake
from hyrule_engineering_loop.knowledge_context import KnowledgeContextConfig
from hyrule_engineering_loop.intake import (
APPROVED_LABEL,
GhClient,
Expand Down Expand Up @@ -101,6 +102,8 @@ class DaemonConfig:
max_wall_clock_minutes_per_run: int = 45
max_cost_usd_per_run: float = 5.0
lock_max_age_seconds: int = DEFAULT_LOCK_MAX_AGE_SECONDS
knowledge_context: KnowledgeContextConfig | None = None
knowledge_learning_dir: str | None = None


@dataclass
Expand Down Expand Up @@ -420,6 +423,8 @@ def daemon_once(
"max_wall_clock_minutes": config.max_wall_clock_minutes_per_run,
"max_cost_usd": config.max_cost_usd_per_run,
},
knowledge_context=config.knowledge_context,
knowledge_learning_dir=config.knowledge_learning_dir,
)
final_state = dict(result.get("final_state", {}))
final_state["risk_level"] = final_state.get("risk_level", risk)
Expand Down
68 changes: 68 additions & 0 deletions src/hyrule_engineering_loop/knowledge_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
import os
import subprocess
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from typing import Any

import anyio


class KnowledgeContextError(RuntimeError):
"""Raised when optional knowledge context loading fails."""
Expand All @@ -30,6 +33,8 @@ class KnowledgeContextConfig:
authority_min: str = "A4"
timeout_seconds: int = 20
fixture_path: Path | None = None
mcp_url: str | None = None
mcp_transport: str = "streamable-http"

@classmethod
def from_env(cls) -> KnowledgeContextConfig:
Expand All @@ -42,6 +47,8 @@ def from_env(cls) -> KnowledgeContextConfig:
authority_min=os.environ.get("HYRULE_KNOWLEDGE_CONTEXT_AUTHORITY_MIN", "A4"),
timeout_seconds=int(os.environ.get("HYRULE_KNOWLEDGE_CONTEXT_TIMEOUT", "20")),
fixture_path=Path(os.environ["HYRULE_KNOWLEDGE_CONTEXT_FIXTURE"]) if os.environ.get("HYRULE_KNOWLEDGE_CONTEXT_FIXTURE") else None,
mcp_url=os.environ.get("HYRULE_KNOWLEDGE_MCP_URL") or None,
mcp_transport=os.environ.get("HYRULE_KNOWLEDGE_MCP_TRANSPORT", "streamable-http"),
)


Expand Down Expand Up @@ -70,6 +77,8 @@ def load_knowledge_context(task: str, *, config: KnowledgeContextConfig | None =
def _load_pack(task: str, config: KnowledgeContextConfig) -> dict[str, Any]:
if config.fixture_path is not None:
return _read_fixture(config.fixture_path)
if config.mcp_url:
return _read_mcp_context_pack(task, config)
repo_path = config.repo_path.expanduser().resolve()
if not repo_path.is_dir():
raise KnowledgeContextError(f"knowledge repo not found: {repo_path}")
Expand Down Expand Up @@ -110,6 +119,65 @@ def _load_pack(task: str, config: KnowledgeContextConfig) -> dict[str, Any]:
return loaded


def _read_mcp_context_pack(task: str, config: KnowledgeContextConfig) -> dict[str, Any]:
try:
return anyio.run(_read_mcp_context_pack_async, task, config)
except KnowledgeContextError:
raise
except Exception as exc:
raise KnowledgeContextError(f"knowledge MCP context-pack request failed: {exc}") from exc


async def _read_mcp_context_pack_async(task: str, config: KnowledgeContextConfig) -> dict[str, Any]:
try:
client_session = getattr(import_module("mcp"), "ClientSession")
if config.mcp_transport == "sse":
client_factory = getattr(import_module("mcp.client.sse"), "sse_client")
elif config.mcp_transport in {"streamable-http", "http"}:
client_factory = getattr(import_module("mcp.client.streamable_http"), "streamablehttp_client")
else:
raise KnowledgeContextError(f"unsupported knowledge MCP transport: {config.mcp_transport}")
except ModuleNotFoundError as exc:
raise KnowledgeContextError("optional dependency `mcp` is required for HYRULE_KNOWLEDGE_MCP_URL") from exc

assert config.mcp_url is not None
async with client_factory(config.mcp_url, timeout=config.timeout_seconds, sse_read_timeout=config.timeout_seconds) as streams:
read_stream, write_stream = _mcp_read_write_streams(streams)
async with client_session(read_stream, write_stream) as session:
await session.initialize()
result = await session.call_tool(
"knowledge_context_pack",
{
"task": task,
"role": config.role,
"risk_level": config.risk_level,
"budget_tokens": config.budget_tokens,
},
)
return _mcp_tool_result_to_dict(result)


def _mcp_read_write_streams(streams: Any) -> tuple[Any, Any]:
if isinstance(streams, tuple) and len(streams) >= 2:
return streams[0], streams[1]
raise KnowledgeContextError("knowledge MCP client did not return read/write streams")


def _mcp_tool_result_to_dict(result: Any) -> dict[str, Any]:
structured = getattr(result, "structuredContent", None) or getattr(result, "structured_content", None)
if isinstance(structured, dict):
return structured
content = getattr(result, "content", None)
if isinstance(content, list):
for item in content:
text = getattr(item, "text", None)
if isinstance(text, str):
loaded = json.loads(text)
if isinstance(loaded, dict):
return loaded
raise KnowledgeContextError("knowledge MCP context-pack result was not a JSON object")


def _read_fixture(path: Path) -> dict[str, Any]:
resolved = path.expanduser().resolve()
if not resolved.is_file():
Expand Down
19 changes: 18 additions & 1 deletion tests/test_phase26_knowledge_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace

from hyrule_engineering_loop.feature import build_feature_state
from hyrule_engineering_loop.knowledge_context import KnowledgeContextConfig, load_knowledge_context
from hyrule_engineering_loop.knowledge_context import (
KnowledgeContextConfig,
_mcp_read_write_streams,
_mcp_tool_result_to_dict,
load_knowledge_context,
)


FIXTURE_PACK = {
Expand Down Expand Up @@ -60,6 +66,17 @@ def test_knowledge_context_fixture_is_rendered(tmp_path: Path) -> None:
assert "generated/services/hyrule-cloud" in result["summary"]


def test_mcp_tool_result_text_content_is_parsed() -> None:
result = SimpleNamespace(content=[SimpleNamespace(text=json.dumps(FIXTURE_PACK))])

assert _mcp_tool_result_to_dict(result)["id"] == "ctx_test"


def test_mcp_read_write_streams_accepts_sse_and_streamable_shapes() -> None:
assert _mcp_read_write_streams(("read", "write")) == ("read", "write")
assert _mcp_read_write_streams(("read", "write", "session")) == ("read", "write")


def test_feature_state_includes_optional_knowledge_context(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
_init_repo(workspace / "hyrule-cloud")
Expand Down
Loading