From 59f2db91495287d1bcade8447f5a743b8f271a05 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 13:40:02 +0800 Subject: [PATCH] fix(runtime): defer unselected context and retirement implementations Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 60 ++++++ .../monorepo-distribution-split-v0.zh-CN.md | 49 +++++ loopx/capabilities/anti_entropy/__init__.py | 63 +++--- .../capabilities/anti_entropy/turn_context.py | 17 +- .../context_providers/__init__.py | 61 +++--- .../capabilities/context_providers/factory.py | 21 +- .../context_providers/openviking.py | 109 +--------- .../context_providers/openviking_scope.py | 116 +++++++++++ .../capabilities/reward_memory/application.py | 2 +- .../capabilities/reward_memory/experiment.py | 2 +- .../test_context_provider_loading.py | 191 ++++++++++++++++++ 11 files changed, 534 insertions(+), 157 deletions(-) create mode 100644 loopx/capabilities/context_providers/openviking_scope.py create mode 100644 tests/capabilities/test_context_provider_loading.py diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 33838e0114..a74df0d2e7 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3677,3 +3677,63 @@ closure, paired complete costs, progressive product control, R1/M3 and original Goal regression remain unqualified. Keep the nine original requirements, previous failed attempts and frozen budget failures; no minimal preset, provider default, package publication or paid operation is promoted here. + +### 2026-11-13 — Full Turn defers unselected provider implementations + +The installed core Turn previously loaded BM25 and OpenViking while resolving +Reward Memory configuration, and loaded the anti-entropy inspector even when +retirement governance was disabled. Context-provider exports and the existing +builtin builder registry now resolve implementations on explicit use. A pure +OpenViking scope owner is shared by binding normalization and the provider; +the original URI grammar, public aliases and Python serialization identity are +retained. Anti-entropy still uses the existing typed activation and applicable +basis; its feedback builder resolves only after those checks. No capability, +provider default, authority, claim, stop or settlement rule is added or relaxed. + +The 12 fresh-process checks cover registry rejections/custom builders, real +local BM25 retrieval, binding classification, disabled/enabled retirement and +attribute/from/star exports. The original 11-check corpus fails five loading +oracles on immutable base `441c6077e7c5c8e896ea79562761fd483baf157e`, while its +six export checks pass. Sixteen URI success/rejection cases and protocol-4 +pickle bytes match between installed base and candidate; cross-version reads +pass both ways. Definitions move to the pure scope source file: declaration +source introspection is not preserved by the original-module alias. + +The final independent Python 3.11 core wheel contains 1886 runtime files that +match source bytes (SHA256 +`bc36c92dd437b9418e21031e185fefd04984c91a30169cbd866a3c712d92b4fa`). +Source capability regression passes 628 cases. Real isolated File and SQLite +execution commits a checked artifact and one settlement; original-key replay +has no Host, state or quota effects. Instrumented full-Turn module counts, +including the package root, change from 525 to 516 on execution and 438 to 432 +on replay; capability counts change from 111 to 102 and 56 to 50. Execution +RPC counts remain 221/220 and replay remains 11. The unselected OpenViking, +BM25 and anti-entropy inspection implementations disappear from those paths. +These counts describe the observed Python path, not the complete minimal closure. + +Matched installed observations use macOS 27 arm64, Python 3.11.15, Node 26.0.0 +and five +alternating base/candidate pairs per backend and phase. Each invocation starts +a new Python process. Before first execution, the isolated managed RPC server +is normally stopped and must restart; completed-Turn replay reuses that server. +Setup and independent readback are outside timing; OS, bytecode and compilation +caches are not reset. Nearest-rank p95 is the maximum of five samples. + +| Backend / phase | Wall median / p95 ms, base → candidate | Median invocation RSS MiB, base → candidate | +| --- | --- | --- | +| SQLite / fresh execution | 2922.5 / 3561.9 → 3006.8 / 4139.9 | 81.81 → 81.25 | +| SQLite / completed-Turn replay | 809.6 / 948.6 → 889.0 / 1415.0 | 44.73 → 44.42 | +| File / fresh execution | 2858.7 / 3005.5 → 2862.5 / 2985.0 | 81.09 → 81.36 | +| File / completed-Turn replay | 645.4 / 679.7 → 692.5 / 773.9 | 44.73 → 44.28 | + +All 40 invocations preserve the same independent result/effect fingerprint; +stdout sizes remain 4002 bytes for execution and 4548 for replay. The candidate +has slower SQLite medians and a higher tail: no latency or complete-cost +improvement is qualified. Invocation RSS excludes the persistent Node server; +zero block-I/O counters do not prove zero file access. Separate controlled +observers verify Python import/open/process/loopback events and Node module/ +loopback/exit events, but do not provide OS-wide closure, complete IPC/I/O or +model-token, service-cost and human-attention accounting. The Host remains +synthetic. Full V1–V5/V10, real model adoption/recovery, progressive product +control, R1/M3, independent Standard packages and original-goal regression remain +open. Preserve all nine original requirements and earlier frozen failures. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index a8ca47279c..9e6bb10e19 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2866,3 +2866,52 @@ journal 使用的 transaction-key 预约保留精确恢复兼容分支;已启 实际模型采用、完整 V1–V5/V10、默认闭包、配对完整成本、渐进产品控制、R1/M3 与 原始 Goal 回归仍未验收。保留原九项要求、此前失败尝试和冻结预算失败;本次没有 推广 minimal preset、provider 默认、包发布或付费操作。 + +### 2026-11-13 — 完整 Turn 延后加载未选择的 Provider 实现 + +此前独立 core 的已安装 Turn 在解析 Reward Memory 配置时加载 BM25 和 +OpenViking,并在退休治理关闭时加载 anti-entropy 检查器。现在 context-provider +公开导出和原 builtin builder registry 在明确使用时解析实现。绑定归一化与实际 +provider 共用纯 OpenViking scope owner,原 URI 语法、公开别名与 Python 序列化 +身份保留。Anti-entropy 仍使用原 typed 激活规则和适用 basis,通过这些检查后才 +解析 feedback builder。没有新增或放宽 capability、provider 默认值、authority、 +claim、stop 或 settlement 规则。 + +12 项新进程检查覆盖 registry 拒绝/custom builder、真实本地 BM25 检索、绑定 +分类、关闭/开启退休治理,以及 attribute/from/star 导出。原 11 项检查在不可变 +基线 `441c6077e7c5c8e896ea79562761fd483baf157e` 上有五项加载 oracle 失败, +六项公开导出检查通过。两个安装版本的 16 类 URI 成功/拒绝结果与 protocol-4 +pickle 字节一致,跨版本双向读取通过。定义移到纯 scope 源文件,旧模块别名 +不能保留声明源码的 introspection 行为。 + +最终独立 Python 3.11 core wheel 的 1886 个 runtime 文件与源码字节一致,SHA256 +为 `bc36c92dd437b9418e21031e185fefd04984c91a30169cbd866a3c712d92b4fa`。 +源码 capability 回归通过 628 项。真实隔离 File 与 SQLite 执行提交已检查产物, +且只结算一次;原 key 重放没有 Host、state 或 quota 副作用。含 package root 的 +受控完整 Turn 模块数在执行时由 525 降到 516,重放由 438 降到 432;capability +模块数分别由 111 降到 102、56 降到 50。执行 RPC 数仍为 221/220,重放仍为 11。 +这些路径中不再出现未选择的 OpenViking、BM25 和 anti-entropy 检查实现。计数只 +描述已观测的 Python 路径,不能代表完整最小闭包。 + +配对安装测量使用 macOS 27 arm64、Python 3.11.15、Node 26.0.0,每个 backend、 +阶段各交替运行 +五对基线/候选。每次调用启动新 Python 进程;首次执行前通过原 owner 正常停止 +隔离的 managed RPC server,使其重新启动,已完成 Turn 重放则复用该 server。 +准备与独立回读不计入耗时,OS、bytecode 和 compilation cache 不重置。 +Nearest-rank p95 等于五个样本的最大值。 + +| Backend/阶段 | Wall 中位数/p95 ms,基线 → 候选 | 调用 RSS 中位数 MiB,基线 → 候选 | +| --- | --- | --- | +| SQLite/首次执行 | 2922.5 / 3561.9 → 3006.8 / 4139.9 | 81.81 → 81.25 | +| SQLite/已完成 Turn 重放 | 809.6 / 948.6 → 889.0 / 1415.0 | 44.73 → 44.42 | +| File/首次执行 | 2858.7 / 3005.5 → 2862.5 / 2985.0 | 81.09 → 81.36 | +| File/已完成 Turn 重放 | 645.4 / 679.7 → 692.5 / 773.9 | 44.73 → 44.28 | + +40 次调用的独立结果/副作用指纹一致,stdout 大小保持首次执行 4002 bytes、 +重放 4548 bytes。候选 SQLite 中位数较慢、尾延迟较高,本轮不取得延迟或完整成本 +改善资格。调用 RSS 不包含持久 Node server,block-I/O 计数为零也不能证明没有 +文件访问。独立受控 observer 验证 Python import/open/process/loopback 事件,以及 +Node module/loopback/exit 事件,但不能提供 OS 级完整闭包、完整 IPC/I/O、model +token、service cost 或人工注意力核算。Host 仍为 synthetic。完整 V1–V5/V10、 +真实模型采用/恢复、渐进产品控制、R1/M3、独立 Standard packages 与原目标回归 +仍开放,保留原九项要求和此前冻结失败。 diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index 65eca84019..19331597aa 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -1,30 +1,43 @@ from __future__ import annotations -from .agent_tool import anti_entropy_inspect -from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY -from .cli import run_anti_entropy_verify -from .contract import ( - ANTI_ENTROPY_CAPABILITY_ID, - ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, - ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, - AntiEntropyFinding, - AntiEntropyReceipt, - RetirementBasis, -) -from .feedback import ( - ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, - build_anti_entropy_turn_context, - run_anti_entropy_feedback_cycle, -) -from .oracles import ( - audit_ghost_code, - evaluate_diff_hygiene, - verify_delete_first, -) -from .turn_context import ( - anti_entropy_context_registration, - anti_entropy_context_result, -) +from importlib import import_module +from typing import Any + +# Importing the Turn registration or contract is not a request to run the +# inspector. Explicit compatibility exports still resolve to their owners. +_EXPORTS = { + "anti_entropy_inspect": "agent_tool", + "ANTI_ENTROPY_CATALOG_ENTRY": "catalog_entry", + "run_anti_entropy_verify": "cli", + "ANTI_ENTROPY_CAPABILITY_ID": "contract", + "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION": "contract", + "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION": "contract", + "AntiEntropyFinding": "contract", + "AntiEntropyReceipt": "contract", + "RetirementBasis": "contract", + "ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR": "feedback", + "build_anti_entropy_turn_context": "feedback", + "run_anti_entropy_feedback_cycle": "feedback", + "audit_ghost_code": "oracles", + "evaluate_diff_hygiene": "oracles", + "verify_delete_first": "oracles", + "anti_entropy_context_registration": "turn_context", + "anti_entropy_context_result": "turn_context", +} + + +def __getattr__(name: str) -> Any: + module = _EXPORTS.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(f".{module}", __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) + __all__ = [ "ANTI_ENTROPY_CAPABILITY_ID", diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index aa5a38df3e..cd2ddb61d4 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -3,13 +3,14 @@ from __future__ import annotations import json +import sys from collections.abc import Mapping +from importlib import import_module from pathlib import Path from typing import Any from ...control_plane.capability_hooks import TurnContextHookRegistration from .contract import ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis -from .feedback import build_anti_entropy_turn_context ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 _BASIS_KEYS = ("retirement_basis", "anti_entropy_basis") @@ -26,6 +27,18 @@ _UNSET = object() +def __getattr__(name: str) -> Any: + if name != "build_anti_entropy_turn_context": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(".feedback", __package__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | {"build_anti_entropy_turn_context"}) + + def _embedded_json_objects(text: str): """Yield JSON objects embedded in free text, outermost first. @@ -484,7 +497,7 @@ def anti_entropy_context_result( ) is_recovery = bool(recovery_evidence) - context = build_anti_entropy_turn_context( + context = getattr(sys.modules[__name__], "build_anti_entropy_turn_context")( basis=basis, goal_id=goal_id, workspace=workspace, diff --git a/loopx/capabilities/context_providers/__init__.py b/loopx/capabilities/context_providers/__init__.py index 2fe607e7fb..d70018f4a6 100644 --- a/loopx/capabilities/context_providers/__init__.py +++ b/loopx/capabilities/context_providers/__init__.py @@ -1,27 +1,40 @@ -"""Reusable bounded context-provider integrations for LoopX capabilities.""" - -from .base import ( - canonical_context_text, - canonical_context_lines, - canonical_context_matches, - ContextProviderItem, - ContextProviderRetrieval, - ContextProviderSync, -) -from .factory import build_context_provider, register_context_provider -from .bm25 import BM25ContextProvider, build_bm25_context_provider -from .openviking import ( - OpenVikingContextProvider, - build_openviking_context_provider, -) -from .service_ownership import ( - context_provider_service_restarted, - load_context_provider_service_ownership, -) - - -register_context_provider("openviking", build_openviking_context_provider) -register_context_provider("bm25", build_bm25_context_provider) +"""Context-provider exports resolve without selecting unrelated implementations.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +_EXPORTS = { + "canonical_context_text": "base", + "canonical_context_lines": "base", + "canonical_context_matches": "base", + "ContextProviderItem": "base", + "ContextProviderRetrieval": "base", + "ContextProviderSync": "base", + "build_context_provider": "factory", + "register_context_provider": "factory", + "BM25ContextProvider": "bm25", + "build_bm25_context_provider": "bm25", + "OpenVikingContextProvider": "openviking", + "build_openviking_context_provider": "openviking", + "context_provider_service_restarted": "service_ownership", + "load_context_provider_service_ownership": "service_ownership", +} + + +def __getattr__(name: str) -> Any: + module = _EXPORTS.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(f".{module}", __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_EXPORTS)) + __all__ = [ "ContextProviderItem", diff --git a/loopx/capabilities/context_providers/factory.py b/loopx/capabilities/context_providers/factory.py index aba1429c13..874d58ea6d 100644 --- a/loopx/capabilities/context_providers/factory.py +++ b/loopx/capabilities/context_providers/factory.py @@ -7,7 +7,26 @@ ContextProviderBuilder = Callable[[Mapping[str, Any]], ContextProvider] -_CONTEXT_PROVIDER_BUILDERS: dict[str, ContextProviderBuilder] = {} + + +def _build_openviking(config: Mapping[str, Any]) -> ContextProvider: + from .openviking import build_openviking_context_provider + + return build_openviking_context_provider(config) + + +def _build_bm25(config: Mapping[str, Any]) -> ContextProvider: + from .bm25 import build_bm25_context_provider + + return build_bm25_context_provider(config) + + +# Builtin ids are already registered, so extensions retain the same duplicate +# rejection. Resolving that registry does not select either provider's code. +_CONTEXT_PROVIDER_BUILDERS: dict[str, ContextProviderBuilder] = { + "openviking": _build_openviking, + "bm25": _build_bm25, +} def register_context_provider( diff --git a/loopx/capabilities/context_providers/openviking.py b/loopx/capabilities/context_providers/openviking.py index 736d3a9aeb..b838506345 100644 --- a/loopx/capabilities/context_providers/openviking.py +++ b/loopx/capabilities/context_providers/openviking.py @@ -8,10 +8,14 @@ import subprocess import time from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass from pathlib import Path from ...control_plane.runtime.public_safety import public_safe_compact_text +from .openviking_scope import ( + OPENVIKING_IDENTITY_RE, + OpenVikingScope, + classify_openviking_scope, +) from .base import ( ContextProviderItem, ContextProviderRetrieval, @@ -25,10 +29,7 @@ MAX_OPENVIKING_RESULTS = 6 MAX_OPENVIKING_SYNC_RESOURCES = 24 DEFAULT_MINIMUM_VERSION = "0.4.9" -# OpenViking v0.4.19 rejects identity segments containing path separators, -# dot segments, colons, or plus signs. Keep the client-side boundary at least -# as strict as the current server contract. -OPENVIKING_IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$") + PEER_SCOPE_MINIMUM_VERSION = "0.4.18" PEER_SCOPE_MINIMUM_SERVER_VERSION = "0.4.19" @@ -42,104 +43,6 @@ class _ScopeMetadata(TypedDict): CommandRunner = Callable[..., subprocess.CompletedProcess[str]] -@dataclass(frozen=True) -class OpenVikingScope: - """Public-safe classification of one exact OpenViking URI.""" - - visibility: str - scope_kind: str - write_strategy: str - user_scope_id: str | None = None - actor_scope_id: str | None = None - - @property - def actor_binding_required(self) -> bool: - return self.actor_scope_id is not None - - -def classify_openviking_scope(scope_ref: str) -> OpenVikingScope: - """Classify supported public/private storage without inferring identity. - - OpenViking resource ingestion is valid only below a ``resources`` root. - Structured Reward Memory records under a memory root use the filesystem - content-write API instead. Agent/peer identity is returned to the caller - for an explicit equality check against ``actor_peer_id``. - """ - - value = str(scope_ref or "").strip().rstrip("/") - if not value.startswith("viking://"): - raise ValueError("OpenViking scope_ref must use viking://") - segments = value[len("viking://") :].split("/") - if not segments or any( - not segment or segment in {".", ".."} for segment in segments - ): - raise ValueError("OpenViking scope_ref must use safe non-empty path segments") - if segments[0] == "resources": - return OpenVikingScope( - visibility="public", - scope_kind="account_resources", - write_strategy="resource_ingest", - ) - if ( - segments[0] == "~" - and len(segments) >= 2 - and segments[1] - in { - "resources", - "memories", - } - ): - return OpenVikingScope( - visibility="private", - scope_kind=f"current_user_{segments[1]}", - write_strategy=( - "resource_ingest" if segments[1] == "resources" else "content_write" - ), - ) - if segments[0] == "agent": - raise ValueError( - "viking://agent is a shared read-only compatibility scope in " - "OpenViking v0.4.19; use an explicit user/peer target" - ) - if segments[0] != "user" or len(segments) < 3: - raise ValueError( - "OpenViking scope_ref must select account resources or a supported " - "user/peer private collection" - ) - user_id = segments[1] - if not OPENVIKING_IDENTITY_RE.fullmatch(user_id): - raise ValueError("OpenViking user scope must use a safe user id") - if segments[2] in {"resources", "memories"}: - return OpenVikingScope( - visibility="private", - scope_kind=f"user_{segments[2]}", - write_strategy=( - "resource_ingest" if segments[2] == "resources" else "content_write" - ), - user_scope_id=user_id, - ) - if ( - len(segments) >= 5 - and segments[2] == "peers" - and segments[4] in {"resources", "memories"} - ): - actor_id = segments[3] - if not OPENVIKING_IDENTITY_RE.fullmatch(actor_id): - raise ValueError("OpenViking peer scope must use a safe actor id") - return OpenVikingScope( - visibility="private", - scope_kind=f"peer_{segments[4]}", - write_strategy=( - "resource_ingest" if segments[4] == "resources" else "content_write" - ), - user_scope_id=user_id, - actor_scope_id=actor_id, - ) - raise ValueError( - "OpenViking private scope_ref must select user resources/memories or " - "one explicit peer collection" - ) - def _compact(value: Any, *, limit: int) -> str | None: return public_safe_compact_text(value, limit=limit) diff --git a/loopx/capabilities/context_providers/openviking_scope.py b/loopx/capabilities/context_providers/openviking_scope.py new file mode 100644 index 0000000000..558c23685b --- /dev/null +++ b/loopx/capabilities/context_providers/openviking_scope.py @@ -0,0 +1,116 @@ +"""Pure OpenViking URI classification shared by bindings and the provider.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# OpenViking v0.4.19 rejects identity segments containing path separators, +# dot segments, colons, or plus signs. Keep the client-side boundary at least +# as strict as the current server contract. +OPENVIKING_IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$") + + +@dataclass(frozen=True) +class OpenVikingScope: + """Public-safe classification of one exact OpenViking URI.""" + + visibility: str + scope_kind: str + write_strategy: str + user_scope_id: str | None = None + actor_scope_id: str | None = None + + @property + def actor_binding_required(self) -> bool: + return self.actor_scope_id is not None + + +def classify_openviking_scope(scope_ref: str) -> OpenVikingScope: + """Classify supported public/private storage without inferring identity. + + OpenViking resource ingestion is valid only below a ``resources`` root. + Structured Reward Memory records under a memory root use the filesystem + content-write API instead. Agent/peer identity is returned to the caller + for an explicit equality check against ``actor_peer_id``. + """ + + value = str(scope_ref or "").strip().rstrip("/") + if not value.startswith("viking://"): + raise ValueError("OpenViking scope_ref must use viking://") + segments = value[len("viking://") :].split("/") + if not segments or any( + not segment or segment in {".", ".."} for segment in segments + ): + raise ValueError("OpenViking scope_ref must use safe non-empty path segments") + if segments[0] == "resources": + return OpenVikingScope( + visibility="public", + scope_kind="account_resources", + write_strategy="resource_ingest", + ) + if ( + segments[0] == "~" + and len(segments) >= 2 + and segments[1] + in { + "resources", + "memories", + } + ): + return OpenVikingScope( + visibility="private", + scope_kind=f"current_user_{segments[1]}", + write_strategy=( + "resource_ingest" if segments[1] == "resources" else "content_write" + ), + ) + if segments[0] == "agent": + raise ValueError( + "viking://agent is a shared read-only compatibility scope in " + "OpenViking v0.4.19; use an explicit user/peer target" + ) + if segments[0] != "user" or len(segments) < 3: + raise ValueError( + "OpenViking scope_ref must select account resources or a supported " + "user/peer private collection" + ) + user_id = segments[1] + if not OPENVIKING_IDENTITY_RE.fullmatch(user_id): + raise ValueError("OpenViking user scope must use a safe user id") + if segments[2] in {"resources", "memories"}: + return OpenVikingScope( + visibility="private", + scope_kind=f"user_{segments[2]}", + write_strategy=( + "resource_ingest" if segments[2] == "resources" else "content_write" + ), + user_scope_id=user_id, + ) + if ( + len(segments) >= 5 + and segments[2] == "peers" + and segments[4] in {"resources", "memories"} + ): + actor_id = segments[3] + if not OPENVIKING_IDENTITY_RE.fullmatch(actor_id): + raise ValueError("OpenViking peer scope must use a safe actor id") + return OpenVikingScope( + visibility="private", + scope_kind=f"peer_{segments[4]}", + write_strategy=( + "resource_ingest" if segments[4] == "resources" else "content_write" + ), + user_scope_id=user_id, + actor_scope_id=actor_id, + ) + raise ValueError( + "OpenViking private scope_ref must select user resources/memories or " + "one explicit peer collection" + ) + + +# The original module re-exports these objects. Keep Python's public object +# identity and pickle references compatible with previously installed clients. +OpenVikingScope.__module__ = "loopx.capabilities.context_providers.openviking" +classify_openviking_scope.__module__ = "loopx.capabilities.context_providers.openviking" diff --git a/loopx/capabilities/reward_memory/application.py b/loopx/capabilities/reward_memory/application.py index 866171c523..ce33361b3f 100644 --- a/loopx/capabilities/reward_memory/application.py +++ b/loopx/capabilities/reward_memory/application.py @@ -21,7 +21,7 @@ canonical_context_text, opaque_provider_ref, ) -from ..context_providers.openviking import classify_openviking_scope +from ..context_providers.openviking_scope import classify_openviking_scope from .candidate_review import REWARD_MEMORY_REVIEW_SCHEMA_VERSION from .experience_quality import ( normalize_procedural_experience, diff --git a/loopx/capabilities/reward_memory/experiment.py b/loopx/capabilities/reward_memory/experiment.py index 2206941978..f56c5239b3 100644 --- a/loopx/capabilities/reward_memory/experiment.py +++ b/loopx/capabilities/reward_memory/experiment.py @@ -20,7 +20,7 @@ from ...control_plane.todos.contract import normalize_todo_claimed_by from ..context_providers import build_context_provider from ..context_providers.base import ContextProvider -from ..context_providers.openviking import classify_openviking_scope +from ..context_providers.openviking_scope import classify_openviking_scope from .application import TOKEN_RE, normalize_reward_memory_provider_binding from .ingestion import normalize_reward_memory_standing_policy from .registry import MAX_CORPORA, normalize_reward_memory_corpus diff --git a/tests/capabilities/test_context_provider_loading.py b/tests/capabilities/test_context_provider_loading.py new file mode 100644 index 0000000000..8e3ec73462 --- /dev/null +++ b/tests/capabilities/test_context_provider_loading.py @@ -0,0 +1,191 @@ +"""Real imports and local provider use preserve the selection boundary.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest + + +def _run(code: str) -> None: + # Resolve the intended checkout from invocation cwd, including baseline + # sensitivity runs; the test file location is not the production revision. + prefix = "import loopx, pathlib; assert pathlib.Path(loopx.__file__).resolve().is_relative_to(pathlib.Path.cwd().resolve())\n" + result = subprocess.run( + [sys.executable, "-c", prefix + textwrap.dedent(code)], + cwd=Path.cwd(), + env={**os.environ, "LOOPX_USAGE_PING": "0"}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_context_types_do_not_select_provider_implementations() -> None: + _run(""" + import sys + from loopx.capabilities.context_providers.base import ContextProviderItem + from loopx.capabilities.context_providers import canonical_context_text + item = ContextProviderItem(resource_ref='source:fixture', summary='safe', content='retained source') + assert item.summary == 'safe' + assert canonical_context_text(' retained source\\r\\n') == ' retained source' + assert 'loopx.capabilities.context_providers.openviking' not in sys.modules + assert 'loopx.capabilities.context_providers.bm25' not in sys.modules + assert 'loopx.capabilities.context_providers.lexical_retrieval' not in sys.modules + """) + + +def test_provider_registry_rejections_and_custom_builder_remain_cold() -> None: + _run(""" + import sys + from loopx.capabilities.context_providers import build_context_provider, register_context_provider + for value in ('', 'unregistered'): + try: + build_context_provider({'provider': value}) + except ValueError as exc: + assert str(exc) == 'unsupported context provider: ' + (value or '') + else: + raise AssertionError('unknown provider accepted') + for value in ('bm25', 'openviking'): + try: + register_context_provider(value, lambda config: None) + except ValueError as exc: + assert str(exc) == 'context provider already registered: ' + value + else: + raise AssertionError('builtin registration replaced') + calls = [] + marker = object() + def build(config): + calls.append(config) + return marker + register_context_provider('fixture-custom', build) + config = {'provider': 'fixture-custom', 'fixture_option': True} + assert build_context_provider(config) is marker and calls == [config] + assert 'loopx.capabilities.context_providers.openviking' not in sys.modules + assert 'loopx.capabilities.context_providers.bm25' not in sys.modules + """) + + +def test_local_bm25_retrieval_does_not_select_openviking() -> None: + _run(""" + import json, pathlib, sys, tempfile + from loopx.capabilities.context_providers import build_context_provider + with tempfile.TemporaryDirectory() as temporary: + path = pathlib.Path(temporary) / 'corpus.json' + path.write_text(json.dumps({'schema_version': 'local_bm25_corpus_v0', + 'scope_ref': 'corpus:test', 'documents': [ + {'id': 'match', 'content': 'alpha alpha beta'}, + {'id': 'other', 'content': 'beta gamma'}]})) + provider = build_context_provider({'provider': 'bm25', 'corpus_path': str(path)}) + from loopx.capabilities.context_providers import BM25ContextProvider + assert isinstance(provider, BM25ContextProvider) + result = provider.retrieve(namespace='decision_context', scope_ref='corpus:test', + query='alpha', query_summary='source match', max_results=3, + timeout_seconds=5, observed_at='2026-01-01T00:00:00Z') + assert result.status == 'completed' and len(result.items) == 1 + assert result.items[0].content == 'alpha alpha beta' + public = json.dumps(result.public_packet()) + assert str(path) not in public and 'alpha alpha beta' not in public + rejected = provider.retrieve(namespace='decision_context', scope_ref='corpus:foreign', + query='alpha', query_summary='source match', max_results=3, + timeout_seconds=5, observed_at='2026-01-01T00:00:00Z') + assert rejected.reason_code == 'scope_mismatch' + assert 'loopx.capabilities.context_providers.openviking' not in sys.modules + """) + + +def test_binding_normalization_does_not_select_provider_transport() -> None: + _run(""" + import sys + from loopx.capabilities.reward_memory import application, experiment + scope = application.classify_openviking_scope('viking://user/alice/peers/worker/memories') + assert scope.user_scope_id == 'alice' and scope.actor_scope_id == 'worker' + assert scope.actor_binding_required and scope.write_strategy == 'content_write' + assert experiment.classify_openviking_scope is application.classify_openviking_scope + try: + application.classify_openviking_scope('viking://user/../memories') + except ValueError as exc: + assert str(exc) == 'OpenViking scope_ref must use safe non-empty path segments' + else: + raise AssertionError('invalid scope accepted') + assert 'loopx.capabilities.context_providers.openviking' not in sys.modules + assert 'loopx.capabilities.context_providers.bm25' not in sys.modules + """) + + +def test_scope_public_identity_and_serialization_remain_compatible() -> None: + _run(""" + import pickle, sys + from loopx.capabilities.context_providers.openviking_scope import OpenVikingScope, classify_openviking_scope + legacy = 'loopx.capabilities.context_providers.openviking' + assert OpenVikingScope.__module__ == legacy + assert classify_openviking_scope.__module__ == legacy + scope = classify_openviking_scope('viking://user/alice/peers/worker/memories') + assert legacy not in sys.modules + # Explicit serialization may resolve the compatibility module; ordinary + # binding classification above must remain independent of transport. + for value in (scope, classify_openviking_scope): + serialized = pickle.dumps(value) + assert b'loopx.capabilities.context_providers.openviking_scope' not in serialized + restored = pickle.loads(serialized) + assert restored == value + from loopx.capabilities.context_providers.openviking import OpenVikingScope as exported + assert exported is OpenVikingScope + """) + + +def test_disabled_retirement_context_does_not_select_inspector() -> None: + _run(""" + import sys + from loopx.capabilities.anti_entropy.turn_context import anti_entropy_context_result + disabled = anti_entropy_context_result({}, goal_id='fixture', decision={}) + assert disabled == {'readback': {}, 'context_fields': {}, 'instructions': [], 'result_fields': {}} + assert 'loopx.capabilities.anti_entropy.agent_tool' not in sys.modules + assert 'loopx.capabilities.anti_entropy.oracles' not in sys.modules + assert 'loopx.capabilities.anti_entropy.feedback' not in sys.modules + active = anti_entropy_context_result({'retirement_basis': {'target_symbols': ['old_api']}}, + goal_id='fixture', decision={}) + assert active['readback']['anti_entropy_guidance']['target_symbols'] == ['old_api'] + assert active['context_fields']['anti_entropy_context']['inspect_tool']['name'] == 'anti_entropy_inspect' + assert any('required' in line for line in active['instructions']) + """) + + +@pytest.mark.parametrize("package", ["context_providers", "anti_entropy"]) +@pytest.mark.parametrize("mode", ["attribute", "from", "star"]) +def test_explicit_exports_remain_original_owner_objects( + package: str, mode: str +) -> None: + _run(f""" + import importlib + name = 'loopx.capabilities.' + {package!r} + module = importlib.import_module(name) + namespace = {{}} + if {mode!r} == 'star': + exec('from ' + name + ' import *', namespace) + assert set(module.__all__) <= set(namespace) + elif {mode!r} == 'from': + exec('from ' + name + ' import ' + ', '.join(module.__all__), namespace) + else: + namespace = {{key: getattr(module, key) for key in module.__all__}} + for key in module.__all__: + assert namespace[key] is getattr(module, key) + if {package!r} == 'context_providers': + owner = importlib.import_module(name + '.factory') + assert namespace['build_context_provider'] is owner.build_context_provider + assert namespace['OpenVikingContextProvider'] is importlib.import_module(name + '.openviking').OpenVikingContextProvider + else: + owner = importlib.import_module(name + '.agent_tool') + assert namespace['anti_entropy_inspect'] is owner.anti_entropy_inspect + try: + getattr(module, 'missing_fixture_export') + except AttributeError: + pass + else: + raise AssertionError('unknown export accepted') + """)