Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a0be780
fix(framework): inherit framework_agent lanes and lease TTL from the …
Aug 6, 2026
ead3dca
fix(resume): reconcile framework KEEPs on their real identity
Aug 6, 2026
325f22e
test: guard the executor wiring and the framework gain label
Aug 6, 2026
819af56
fix(naming): name framework_agent in the text the model and operators…
Aug 6, 2026
22c40a3
fix(schema,cli): align two declarations with the values they describe
Aug 6, 2026
3371025
docs(framework): correct comments that describe the pre-rename world
Aug 6, 2026
7b7af22
docs: call the Coordinator-internal action by its runtime name
Aug 6, 2026
386f158
fix(kb): give the framework lessons ledger a single owner
Aug 6, 2026
7cabd94
fix(kb): read the cross-framework seed map from the packaged tree
Aug 6, 2026
857851c
fix(kb): drop the reader-only KB override instead of honouring it
Aug 6, 2026
a2eccf4
docs(kb): describe the two KB roots that actually exist
Aug 7, 2026
f1e8da1
test: wire the pump integration stub to the action registry
Aug 7, 2026
99c8af8
fix(framework): source Critic denials from the ledger that records them
Aug 7, 2026
c967685
refactor(framework): make the per-repo discovery cap the constant it …
Aug 7, 2026
7e9f36c
build: drop the framework standalone installer that could not work
Aug 7, 2026
e1c856d
fix(state): migrate the FRAMEWORK fields the rename left behind
Aug 7, 2026
bdbcbb8
fix(kb): give the framework KB its own directory under the workspace
Aug 7, 2026
d4141a7
chore: drop the leftovers of the two removed state fields
Aug 7, 2026
3c8d3d5
fix(kb): validate at start-up, guard the root that could collide, car…
Aug 7, 2026
d8f7c59
fix(registry): let the lease outlast the bench timeout the executor g…
Aug 7, 2026
f411227
fix(dispatch): refuse a framework task the registry could not lane
Aug 7, 2026
8f27902
fix(state): reconcile the KEEPs a session had already stacked before …
Aug 7, 2026
c10664f
docs(kb): document the variable that became the only way in, and tidy…
Aug 7, 2026
8ab1778
test(framework): give the review stub the constant the helper reads
Aug 7, 2026
8dacce5
docs(kb): rewrap the module docstring an earlier edit left ragged
Aug 7, 2026
091e020
fix(kb): stop the ledger migration from being able to abort a session
Aug 8, 2026
ec525f4
test(registry): read the bench cap from the executor, and name the gap
Aug 8, 2026
663b59a
fix(kb): report the withdrawn KB variable instead of refusing to start
Aug 8, 2026
9c74763
revert(registry): leave integrate_patch's lease alone
Aug 8, 2026
1a370f2
fix(kb): stop the migration littering a directory the KB serves as co…
Aug 8, 2026
5a52a94
Merge origin/main into the framework-rename audit fixes
Aug 8, 2026
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
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ USER_DATA_PATH=/workspace/hyperloom
# never select remote mode. Local is the network-isolated default.
KNOWLEDGE_STORE_MODE=local
# KNOWLEDGE_LOCAL_ROOT=/shared/knowledge
# Framework-agent KB root (lessons ledger for the FRAMEWORK phase). Defaults to
# $USER_DATA_PATH/framework-kb. The `fa` reader and the orchestrator's writeback
# both resolve through this one variable, so it moves the whole KB. The old
# reader-only FRAMEWORK_AGENT_KB_DIR is no longer read; it is ignored with a
# warning rather than failing the run, since it can no longer split the KB.
# INFERENCE_OPTIMIZER_FA_KB_PATH=/shared/framework-kb
# For remote mode, set KNOWLEDGE_STORE_MODE=remote and both values below.
# GBRAIN_BASE_URL=http://<gbrain-host>:80
# GBRAIN_TOKEN=gbrain_your-token-here
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
*.env
!*.env.template
kernel-agent/env.sh
framework-agent/scripts/env.sh

# Dependency checkout cache (deps_cache_root default: $REPO_ROOT/.cache)
.cache/
Expand Down
1 change: 1 addition & 0 deletions docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ The following variables configure the Critic, Robustness, and knowledge base com
| `KNOWLEDGE_STORE_MODE` | `local` | Exclusive Recipe/KG backend: `local` or `remote`. Ambient GBrain credentials do not select remote mode. |
| `KNOWLEDGE_LOCAL_ROOT` | `$USER_DATA_PATH/knowledge`, otherwise `~/.cache/hyperloom/knowledge` | Shared knowledge root. Remote mode uses only `.remote-locks/recipes` beneath it. |
| `HYPERLOOM_`<br>`LOCAL_KB_ROOT` | Unset | Deprecated explicit local Recipe root compatibility input, overridden by `--local-kb-root`; explicit use skips automatic legacy migration. |
| `INFERENCE_OPTIMIZER_`<br>`FA_KB_PATH` | `$USER_DATA_PATH/framework-kb`, otherwise `/workspace/hyperloom/framework-kb` | Framework-agent KB root, holding the lessons ledger the FRAMEWORK phase reads and writes. The only supported override: the `fa` reader and the orchestrator's writeback both resolve through it, so it moves both halves at once. The withdrawn `FRAMEWORK_AGENT_KB_DIR` is ignored with a warning naming the resolved root. On first start-up an existing partition under the legacy `$USER_DATA_PATH/kb` is copied across once; a copy that fails warns and leaves the phase to cold-start. |
| `GBRAIN_BASE_URL` | Unset | GBrain endpoint; required with `KNOWLEDGE_STORE_MODE=remote` and ignored in local mode. |
| `GBRAIN_TOKEN` | Unset | GBrain bearer token; required with `KNOWLEDGE_STORE_MODE=remote` and ignored in local mode. |
| `RECIPE_KB_MIRROR_MODE` | Obsolete | Ignored. Remove it and select `KNOWLEDGE_STORE_MODE=local` or `remote`. |
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ hyperloom = [
"examples/*.json",
"examples/*.py",
"kb/framework_optimization/*.jsonl",
"scripts/*.sh",
]
"hyperloom.agents.quantization" = [
"SKILL.md",
Expand Down
4 changes: 2 additions & 2 deletions src/hyperloom/agents/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ the always-allowed `aiter`).
## Quick start

```bash
# Install from the repo root (idempotent)
# Install from the repo root; the fa CLI ships with the distribution.
cd Hyperloom
bash src/hyperloom/agents/framework/scripts/install.sh # or: pip install -e '.[test]'
pip install -e '.[test]'

# Live IO discovery path
fa phase-discover --request /path/to/request.json --out -
Expand Down
17 changes: 10 additions & 7 deletions src/hyperloom/agents/framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@ src/hyperloom/agents/framework/ # hyperloom.agents.framework
├── keywords.py / models.py # request models + keyword helpers
├── logging_setup.py # shared: structured logging
├── sources/ # primus_cortex + github discovery
├── scripts/install.sh # idempotent standalone installer
├── scripts/env.sh # env loader (generated by scripts/install.sh,
│ # not tracked)
└── tests/
```

The KB root resolves in order: `FRAMEWORK_AGENT_KB_DIR`, then
`INFERENCE_OPTIMIZER_FA_KB_PATH` (Hyperloom compatibility override), then
`${FRAMEWORK_AGENT_ROOT}/kb`. The runtime partition written under it is
`framework_optimization/<framework>/`.
The `fa` CLI ships with the distribution, so there is no separate installer
for this skill: `pip install -e '.[test]'` from the repo root provides it.

The KB a session reads and writes is `INFERENCE_OPTIMIZER_FA_KB_PATH` when
set, else `<workspace>/framework-kb` (`USER_DATA_PATH` or the pod-local
default). It is deliberately not `<workspace>/kb`, the legacy recipe root:
every directory under this root is reported as a framework domain. The
orchestrator's writeback resolves the same root, so both halves move together.
The runtime partition written under it is `framework_optimization/<framework>/`.
Read-only seed data shipped in the wheel lives separately under the package.

## Subcommands

Expand Down
12 changes: 9 additions & 3 deletions src/hyperloom/agents/framework/cross_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any

from ._audit_common import _obtain_patch_text, _resolve_local_file, _symbols, _verdict, parse_unified_diff
from .kb import path_for_framework
from .kb import packaged_kb_root


log = logging.getLogger(__name__)
Expand All @@ -26,8 +26,14 @@


def cross_framework_map_path() -> Path:
"""Return the active cross-framework module-map JSONL path."""
return path_for_framework("") / _MAP_FILE
"""Return the cross-framework module-map JSONL path.

The map is seed data shipped in the wheel, not something a session
accumulates, so it comes from the packaged tree rather than the writable
KB partition. Resolving it through the writable root is what made it
unreadable once that root moved to the operator workspace.
"""
return packaged_kb_root() / "framework_optimization" / _MAP_FILE


def load_cross_framework_map(src_framework: str, dst_framework: str) -> list[dict[str, Any]]:
Expand Down
258 changes: 237 additions & 21 deletions src/hyperloom/agents/framework/kb.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,202 @@

"""Knowledge-base selector + contributor for framework-agent.

``KB_ROOT`` is resolved at call time via ``_resolve_kb_root()`` (under
``${FRAMEWORK_AGENT_KB_DIR}``, with a test fallback) so tests can monkeypatch
the environment. :func:`synthesize_findings` distils :class:`Finding` records
into a markdown blob for ``contribute_to_kb``; the default path is pure-Python
(zero deps), ``with_llm=True`` lazy-imports ``claude_agent_sdk``. Per-domain
priority order is ``empirical_kb.md`` -> ``shared_pitfalls.md`` -> rest.
The KB splits in two. :func:`packaged_kb_root` is read-only seed data shipped
in the wheel; :func:`mutable_kb_root` is the per-deployment partition this
session reads and writes, and is the single owner of that path for both this
module and the orchestrator's ``kb_writeback``. Both resolve at call time so
tests can monkeypatch the environment. :func:`synthesize_findings` distils
:class:`Finding` records into a markdown blob for ``contribute_to_kb``; the
default path is pure-Python (zero deps), ``with_llm=True`` lazy-imports
``claude_agent_sdk``. Per-domain priority order is ``empirical_kb.md`` ->
``shared_pitfalls.md`` -> rest.
"""

from __future__ import annotations

import datetime
import json
import logging
import os
import shutil
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from .models import Finding

_log = logging.getLogger(__name__)


# Per-framework KB partition root under ``<KB_ROOT>/framework_optimization/``.
_FRAMEWORK_OPTIMIZATION_ROOT: str = "framework_optimization"

#: The only supported override for the mutable KB root; both this module and
#: ``kb_writeback`` honour it. It reaches the process through the
#: ``INFERENCE_OPTIMIZER_`` prefix rule in the ``common/env_safety`` dotenv
#: allowlist, which is a prefix rule rather than an entry for this name.
KB_ROOT_ENV: str = "INFERENCE_OPTIMIZER_FA_KB_PATH"

#: Workspace subdirectory holding this KB. Deliberately not ``kb``: that is the
#: legacy recipe root (``inference_optimizer.cli.kb._legacy_recipe_root``, still
#: read by the one-time recipe migration), and ``list_domains`` reports every
#: directory under this root as a framework domain, so sharing it would surface
#: recipe trees as framework domains. The current recipe root is
#: ``<workspace>/knowledge`` and never collided.
_MUTABLE_KB_DIRNAME: str = "framework-kb"

#: Where the writer put this partition before it was given its own directory.
#: Same value as ``inference_optimizer.cli.kb._legacy_recipe_root``'s leaf, which
#: this package cannot import; the guard test asserts they still agree.
_LEGACY_WORKSPACE_KB_DIRNAME: str = "kb"

#: Workspace root when ``USER_DATA_PATH`` is unset. Mirrors
#: ``session.paths.DEFAULT_SESSION_DIR``, which this package cannot import:
#: the ``fa`` CLI runs standalone and must not depend on inference_optimizer.
_DEFAULT_WORKSPACE_ROOT: str = "/workspace/hyperloom"

#: Withdrawn override. Only the reader honoured it, so setting it split the KB
#: in two. ``FRAMEWORK_AGENT_ROOT`` is deliberately absent: it means "where
#: this skill is installed", is used for other purposes, and never reached the
#: reader anyway because no installer exports it.
_REMOVED_KB_ROOT_ENV: str = "FRAMEWORK_AGENT_KB_DIR"


def prepare_kb_environment() -> None:
"""Start-up sequence for the framework KB: report the environment, then migrate.

Both entry points that can reach this KB — the inference_optimizer preflight
and the standalone ``fa`` CLI — call this one function, so a future start-up
step is added in one place instead of being remembered in two.

**Cannot stop a session.** Nothing this KB does at start-up is worth refusing
to run over: the phase treats an unreadable or empty ledger as a cold start,
and a session that disabled the phase never reads it at all. The guarantee is
enforced here rather than left to each step, so a step added later inherits
it; ``test_start_up_never_raises`` holds the line. Problems are announced at
warning level, which is where an operator can act on them.
"""
try:
check_kb_configuration()
migrate_legacy_partition_once()
except Exception: # noqa: BLE001 — start-up for an advisory KB may not fail a run
_log.warning("FRAMEWORK KB: start-up preparation failed; continuing without it", exc_info=True)


def check_kb_configuration() -> None:
"""Report an environment naming a KB variable this build no longer reads.

Announced, not rejected. The withdrawn override was dangerous because only
the reader honoured it, so setting it split the KB in two without saying so.
Now that the reader and ``kb_writeback`` both resolve through
:func:`mutable_kb_root`, it is inert: the KB lands in the same correct place
whether or not it is exported. Refusing to start would guard nothing and
would strand a deployment still carrying it in a file someone forgot about.

Names the resolved root as well as the replacement, so an operator who did
mean to move the KB can see where it actually went.
"""
if not os.environ.get(_REMOVED_KB_ROOT_ENV, "").strip():
return
_log.warning(
"FRAMEWORK KB: %s is set but no longer read. It only ever redirected the reader, which is "
"how reads and writes came to point at different places; it is now ignored and this KB "
"resolves to %s. Use %s instead — that one moves both halves together.",
_REMOVED_KB_ROOT_ENV,
mutable_kb_root(),
KB_ROOT_ENV,
)


def migrate_legacy_partition_once() -> Path | None:
"""Carry the framework partition over from the legacy ``<workspace>/kb`` root.

Until this KB was given its own directory, the writer put the ledger under
``<workspace>/kb/framework_optimization``. The writer was working, so every
deployment that ever ran a FRAMEWORK phase has real data there — leaving it
behind silently empties the dedup ledger and re-proposes PRs that already
lost an accuracy gate.

Never raises. A missing ledger is a cold start, which the phase handles, so
this is a convenience and must not be able to stop a session — least of all
a ``--no-framework-agent`` one that will never read this KB. A full disk or
one unreadable file therefore costs a warning, not the run.

Only runs when the destination has no framework data yet, so it can never
overwrite a live partition. Skipped entirely when :data:`KB_ROOT_ENV` is
set: the operator named a location, and the legacy default was never theirs.
The source is left in place.

Returns:
The destination partition when data was migrated, else ``None``.
"""
if os.environ.get(KB_ROOT_ENV, "").strip():
return None

workspace = Path(os.environ.get("USER_DATA_PATH", "").strip() or _DEFAULT_WORKSPACE_ROOT).expanduser()
source = workspace / _LEGACY_WORKSPACE_KB_DIRNAME / _FRAMEWORK_OPTIMIZATION_ROOT
destination = framework_optimization_root()

try:
if not source.is_dir() or not any(source.iterdir()):
return None
if destination.exists() and any(destination.iterdir()):
return None
_copy_partition_atomically(source, destination)
except Exception: # noqa: BLE001 — a convenience copy may not stop the run
_log.warning(
"FRAMEWORK KB: could not carry the legacy partition over from %s; continuing with "
"whatever is at %s. The FRAMEWORK phase treats a missing ledger as a cold start, so "
"it may re-propose PRs it has already tried.",
source,
destination,
exc_info=True,
)
return None

_log.warning(
"FRAMEWORK KB: migrated the legacy partition %s -> %s. The source is left in place; "
"remove it once the new location looks right.",
source,
destination,
)
return destination


def _copy_partition_atomically(source: Path, destination: Path) -> None:
"""Copy ``source`` onto a not-yet-existing ``destination`` in one visible step.

Staged and renamed so an interrupted copy cannot leave a half-populated
destination that the next start-up would read as a live partition.

The staging directory is unique per process and sits beside the KB root
rather than inside it. Two starts sharing a ``USER_DATA_PATH`` (the
orchestrator and the ``fa`` CLI, say) would otherwise stage onto the same
path and delete each other's work; and ``list_domains`` reports every
directory under the root as a framework domain, so one left behind by a
crash would surface as a domain.

The rename is what serialises concurrent migrations: whoever arrives second
finds a non-empty destination and fails, which the caller downgrades.

Args:
source: The populated legacy partition.
destination: The path to create; must not already hold data.
"""
root = destination.parent
staging = root.with_name(f"{root.name}.migrating-{os.getpid()}-{uuid.uuid4().hex[:8]}")
try:
# symlinks=True: copy links as links. Following them would pull the
# content of whatever they point at — possibly outside the workspace —
# into a directory the KB reader serves as its own.
shutil.copytree(source, staging, symlinks=True)
root.mkdir(parents=True, exist_ok=True)
os.replace(staging, destination)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise


def path_for_framework(framework: str) -> Path:
"""Resolve the KB sub-partition path for a per-framework finding bag.
Expand Down Expand Up @@ -80,28 +253,71 @@ class KBFile:
content: str


def packaged_kb_root() -> Path:
"""Root of the read-only KB shipped inside the wheel.

Holds seed data seeded at build time, currently just the cross-framework
module map. Nothing writes here: an installed package may sit on a
read-only filesystem, and an upgrade would overwrite whatever was added.

Returns:
The packaged KB root path.
"""
return Path(__file__).resolve().parent / "kb"


def mutable_kb_root() -> Path:
"""Root of the KB partition this session reads and writes.

The single owner of that path. Both the ``fa`` reader and the
orchestrator's ``kb_writeback`` resolve through here, so a deployment that
moves the KB moves both halves at once; resolving it independently on each
side is what left written lessons unreadable by the next session.

Resolved per call rather than at import, because the environment is not
fully settled when this module is first imported.

Deliberately not ``<workspace>/kb``: that is the legacy recipe root, and
this reader enumerates whatever directories sit under its own root, so
sharing one would present recipe trees as framework domains.

Returns:
``$INFERENCE_OPTIMIZER_FA_KB_PATH`` when set, else
``<workspace>/framework-kb`` where the workspace is ``$USER_DATA_PATH``
or the pod-local default.
"""
override = os.environ.get(KB_ROOT_ENV, "").strip()
if override:
return Path(override).expanduser()
workspace = os.environ.get("USER_DATA_PATH", "").strip() or _DEFAULT_WORKSPACE_ROOT
return Path(workspace).expanduser() / _MUTABLE_KB_DIRNAME


def framework_optimization_root() -> Path:
"""The partition holding the lessons ledger, for reader and writer alike.

``kb_writeback`` resolves the ledger directory through here rather than
re-spelling the leaf, so the two halves cannot come to disagree about the
name the way they once disagreed about the root.

Returns:
``<KB_ROOT>/framework_optimization``.
"""
return mutable_kb_root() / _FRAMEWORK_OPTIMIZATION_ROOT


def _resolve_kb_root() -> Path:
"""Resolve the active KB root each call (so tests can monkeypatch env).

Order: (1) ``FRAMEWORK_AGENT_KB_DIR``; (2) IO's
``INFERENCE_OPTIMIZER_FA_KB_PATH`` compatibility override;
(3) ``${FRAMEWORK_AGENT_ROOT}/kb``; (4) ``<hyperloom package>/kb``, a
last-resort path that does not exist in the tree or in an install --
installs always set ``FRAMEWORK_AGENT_ROOT``, so step (3) wins.
Never raises: read paths swallow their own failures by design, so an
exception here would be absorbed rather than surfaced. The withdrawn
override is reported by :func:`check_kb_configuration` at start-up and
otherwise ignored.

Returns:
The resolved KB root path.
"""
explicit = os.environ.get("FRAMEWORK_AGENT_KB_DIR", "").strip()
if explicit:
return Path(explicit).expanduser()
io_override = os.environ.get("INFERENCE_OPTIMIZER_FA_KB_PATH", "").strip()
if io_override:
return Path(io_override).expanduser()
root = os.environ.get("FRAMEWORK_AGENT_ROOT", "").strip()
if root:
return Path(root).expanduser() / "kb"
return Path(__file__).resolve().parents[2] / "kb"
return mutable_kb_root()


def list_domains() -> list[str]:
Expand Down
Loading
Loading