From f89dca1e15adc799534cf70af1bc61805fbde3e8 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:37:51 +0800 Subject: [PATCH 1/4] fix(reward-memory): share exact surface checkpoints and safe input diagnostics Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../capabilities/agent_turn_recall/runtime.py | 25 +----- loopx/capabilities/reward_memory/__init__.py | 2 + .../capabilities/reward_memory/application.py | 83 ++++++++++++++----- loopx/capabilities/reward_memory/decision.py | 1 + .../reward_memory/read_authority.py | 28 +++++++ .../reward_memory/runtime_hooks.py | 9 ++ .../capabilities/reward_memory_decision.ts | 35 ++++++++ .../control_plane/effect_runtime_handlers.ts | 2 + 8 files changed, 141 insertions(+), 44 deletions(-) create mode 100644 loopx/capabilities/reward_memory/read_authority.py diff --git a/loopx/capabilities/agent_turn_recall/runtime.py b/loopx/capabilities/agent_turn_recall/runtime.py index 62b5e78264..b6173ff93c 100644 --- a/loopx/capabilities/agent_turn_recall/runtime.py +++ b/loopx/capabilities/agent_turn_recall/runtime.py @@ -17,6 +17,7 @@ resolve_reward_memory_experiment, resolve_reward_memory_surface_config, ) +from ..reward_memory.read_authority import build_reward_memory_surface_read_authority_checkpoints from .core import ( AGENT_TURN_RECALL_SCHEMA_VERSION, AGENT_TURN_RECALL_SURFACE_ID, @@ -149,28 +150,10 @@ def resolve_reward_memory_turn_session_ref( def reward_memory_turn_read_authority_checkpoints( config: Mapping[str, Any], goal_id: str ) -> dict[str, dict[str, Any]]: - route = resolve_reward_memory_surface_config( - config, - AGENT_TURN_RECALL_SURFACE_ID, + return build_reward_memory_surface_read_authority_checkpoints( + config, AGENT_TURN_RECALL_SURFACE_ID, + verified=True, source_ref=f"registry:{goal_id}:reward-memory", ) - checkpoints: dict[str, dict[str, Any]] = {} - for item in route["recall_corpora"]: - corpus = item["corpus"] - scope = corpus["scope"] - checkpoint = { - "verified": True, - "corpus_id": corpus["corpus_id"], - "workspace_ref": scope["workspace_ref"], - "project_ref": scope["project_ref"], - "surface_id": AGENT_TURN_RECALL_SURFACE_ID, - "read_authority": corpus["read_authority"], - "source_ref": f"registry:{goal_id}:reward-memory", - } - for field in ("user_ref", "peer_ref", "session_ref"): - if scope.get(field): - checkpoint[field] = scope[field] - checkpoints[corpus["corpus_id"]] = checkpoint - return checkpoints def deduplicated_agent_turn_recall_payload( diff --git a/loopx/capabilities/reward_memory/__init__.py b/loopx/capabilities/reward_memory/__init__.py index dd34eb42a5..c6830fac9b 100644 --- a/loopx/capabilities/reward_memory/__init__.py +++ b/loopx/capabilities/reward_memory/__init__.py @@ -61,6 +61,7 @@ run_reward_memory_automatic_ingest_hook, run_reward_memory_automatic_recall_hook, ) +from .read_authority import build_reward_memory_surface_read_authority_checkpoints from .outcome_lifecycle import ( reconcile_pending_turn_outcome_ingests, reconcile_pending_turn_outcome_ingests_fail_open, @@ -73,6 +74,7 @@ "RewardMemoryDecisionResult", "assess_reward_memory_decision", "run_reward_memory_decision", + "build_reward_memory_surface_read_authority_checkpoints", "RewardMemoryFilteredRecallItem", "RewardMemoryRecallItem", "RewardMemoryRecallSession", diff --git a/loopx/capabilities/reward_memory/application.py b/loopx/capabilities/reward_memory/application.py index 5609f156b9..cec5912a62 100644 --- a/loopx/capabilities/reward_memory/application.py +++ b/loopx/capabilities/reward_memory/application.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, Literal, get_args from ...control_plane.runtime.public_safety import public_safe_compact_text from ..context_providers import build_context_provider @@ -98,6 +98,22 @@ class _ActiveItemDecision: ] +RecallInputErrorCode = Literal[ + "freshness_age_invalid", "freshness_context_invalid", + "read_authority_checkpoint_missing", "read_authority_checkpoint_invalid", +] + + +class RewardMemoryRecallInputError(ValueError): + """An existing SDK input rejection with an allowlisted, non-content code.""" + + def __init__(self, reason_code: RecallInputErrorCode, message: str) -> None: + if reason_code not in get_args(RecallInputErrorCode): + raise ValueError("unsupported recall input error code") + super().__init__(message) + self.reason_code = reason_code + + def _token(value: object, label: str) -> str: result = str(value or "").strip() if not TOKEN_RE.fullmatch(result): @@ -243,22 +259,17 @@ def _authority_checkpoint( raw: object, *, corpus: Mapping[str, Any], request: Mapping[str, Any] ) -> tuple[dict[str, Any], list[str]]: if not isinstance(raw, Mapping): - raise ValueError("read_authority_checkpoint must be an object") - checkpoint = { - "verified": _boolean(raw, "verified"), - "corpus_id": _token(raw.get("corpus_id"), "checkpoint.corpus_id"), - "workspace_ref": _token(raw.get("workspace_ref"), "checkpoint.workspace_ref"), - "project_ref": _token(raw.get("project_ref"), "checkpoint.project_ref"), - "surface_id": _token(raw.get("surface_id"), "checkpoint.surface_id"), - "read_authority": _token( - raw.get("read_authority"), "checkpoint.read_authority" - ), - "source_ref": _optional_token(raw.get("source_ref"), "checkpoint.source_ref"), - } - for field in IDENTITY_SCOPE_FIELDS: - expected_scope = corpus["scope"].get(field) - if expected_scope: - checkpoint[field] = _optional_token(raw.get(field), f"checkpoint.{field}") + raise RewardMemoryRecallInputError( + "read_authority_checkpoint_missing" if raw is None else "read_authority_checkpoint_invalid", + "read_authority_checkpoint must be an object", + ) + try: + checkpoint = _normalize_authority_checkpoint(raw, corpus=corpus) + except ValueError as exc: + raise RewardMemoryRecallInputError( + "read_authority_checkpoint_missing" if not raw else "read_authority_checkpoint_invalid", + str(exc), + ) from exc reasons: list[str] = [] expected = { "corpus_id": corpus["corpus_id"], @@ -283,22 +294,48 @@ def _authority_checkpoint( return checkpoint, reasons +def _normalize_authority_checkpoint( + raw: Mapping[str, Any], *, corpus: Mapping[str, Any], +) -> dict[str, Any]: + checkpoint = { + "verified": _boolean(raw, "verified"), + "corpus_id": _token(raw.get("corpus_id"), "checkpoint.corpus_id"), + "workspace_ref": _token(raw.get("workspace_ref"), "checkpoint.workspace_ref"), + "project_ref": _token(raw.get("project_ref"), "checkpoint.project_ref"), + "surface_id": _token(raw.get("surface_id"), "checkpoint.surface_id"), + "read_authority": _token( + raw.get("read_authority"), "checkpoint.read_authority" + ), + "source_ref": _optional_token(raw.get("source_ref"), "checkpoint.source_ref"), + } + for field in IDENTITY_SCOPE_FIELDS: + expected_scope = corpus["scope"].get(field) + if expected_scope: + checkpoint[field] = _optional_token(raw.get(field), f"checkpoint.{field}") + return checkpoint + + def _freshness_reasons( corpus: Mapping[str, Any], freshness: Mapping[str, Any] ) -> list[str]: reasons: list[str] = [] mode = corpus["freshness"]["mode"] - source_truth_current = _boolean(freshness, "source_truth_current") - source_revision = _optional_token( - freshness.get("source_revision"), "freshness_context.source_revision" - ) + try: + source_truth_current = _boolean(freshness, "source_truth_current") + source_revision = _optional_token( + freshness.get("source_revision"), "freshness_context.source_revision" + ) + except ValueError as exc: + raise RewardMemoryRecallInputError("freshness_context_invalid", str(exc)) from exc age_seconds = freshness.get("age_seconds") if age_seconds is not None and ( isinstance(age_seconds, bool) or not isinstance(age_seconds, int) or age_seconds < 0 ): - raise ValueError("freshness_context.age_seconds must be a non-negative integer") + raise RewardMemoryRecallInputError( + "freshness_age_invalid", "freshness_context.age_seconds must be a non-negative integer", + ) if mode in {"source_truth_bound", "execution_bound"} and not source_truth_current: reasons.append("source_truth_not_current") if mode in {"revision_bound", "session_archive_bound"} and ( @@ -391,7 +428,7 @@ def build_reward_memory_recall_request( ): raise ValueError(f"limit must be between 1 and {MAX_RESULTS}") if not isinstance(request.get("freshness_context"), Mapping): - raise ValueError("freshness_context must be an object") + raise RewardMemoryRecallInputError("freshness_context_invalid", "freshness_context must be an object") if _boolean(request, "raw_content_captured"): raise ValueError("recall requests must not capture raw content") diff --git a/loopx/capabilities/reward_memory/decision.py b/loopx/capabilities/reward_memory/decision.py index b176b04fdc..913441fd02 100644 --- a/loopx/capabilities/reward_memory/decision.py +++ b/loopx/capabilities/reward_memory/decision.py @@ -61,6 +61,7 @@ def _recall_telemetry(hook: Mapping[str, Any]) -> dict[str, Any]: "filtered_count": sum(item.get("filtered_item_count", 0) for item in attempts), "recall_status": attempts[-1].get("status") if attempts else None, "boundary_reason_code": hook.get("reason_code"), + "boundary_detail_code": hook.get("boundary_detail_code"), } diff --git a/loopx/capabilities/reward_memory/read_authority.py b/loopx/capabilities/reward_memory/read_authority.py new file mode 100644 index 0000000000..c446f716fa --- /dev/null +++ b/loopx/capabilities/reward_memory/read_authority.py @@ -0,0 +1,28 @@ +"""Original configuration IO adapter for the shared TS checkpoint projection.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from ...control_plane.effect_runtime import effect_runtime_result +from .experiment import resolve_reward_memory_surface_config + + +def build_reward_memory_surface_read_authority_checkpoints( + config: Mapping[str, Any], surface_id: str, *, verified: bool, source_ref: str, +) -> dict[str, dict[str, Any]]: + """Project only the configured surface; the caller supplies existing read proof. + + Enabled configuration is not proof. False remains false. This does not call + a provider, select a policy source, or verify/expand the caller's authority. + """ + route = resolve_reward_memory_surface_config(config, surface_id) + result = effect_runtime_result("reward_memory.read_authority.surface_checkpoints", { + "surface_id": surface_id, "verified": verified, "source_ref": source_ref, + "corpora": [{"corpus_id": item["corpus"]["corpus_id"], + "read_authority": item["corpus"]["read_authority"], + "scope": {key: item["corpus"]["scope"].get(key) for key in ( + "workspace_ref", "project_ref", "user_ref", "peer_ref", "session_ref", + )}} for item in route["recall_corpora"]], + }) + return cast(dict[str, dict[str, Any]], result["checkpoints"]) diff --git a/loopx/capabilities/reward_memory/runtime_hooks.py b/loopx/capabilities/reward_memory/runtime_hooks.py index 545ed142f4..ad277a501e 100644 --- a/loopx/capabilities/reward_memory/runtime_hooks.py +++ b/loopx/capabilities/reward_memory/runtime_hooks.py @@ -7,6 +7,7 @@ from .application import ( RewardMemoryApplier, RewardMemoryRecallSession, + RewardMemoryRecallInputError, apply_reward_memory_recall, build_reward_memory_recall_request, execute_reward_memory_recall, @@ -168,6 +169,14 @@ def run_reward_memory_automatic_recall_hook( provider_binding=corpus_route["provider_binding"], provider=provider, ) + except RewardMemoryRecallInputError as exc: + return base | { + "status": "guard_rejected", + "reason_code": "exact_corpus_request_invalid", + "boundary_detail_code": exc.reason_code, + "recall_attempts": attempts, + "telemetry": telemetry, + } except (KeyError, OSError, RuntimeError, TypeError, ValueError): return base | { "status": "guard_rejected", diff --git a/loopx/control_plane/capabilities/reward_memory_decision.ts b/loopx/control_plane/capabilities/reward_memory_decision.ts index 244df8e24b..3df1f5aba7 100644 --- a/loopx/control_plane/capabilities/reward_memory_decision.ts +++ b/loopx/control_plane/capabilities/reward_memory_decision.ts @@ -5,6 +5,8 @@ import { requireJsonObject, requireStringLiteral } from "../runtime_decode.ts"; const MODES = ["execute", "preview", "recall_only"] as const; const KINDS = ["context_delivery", "semantic_application"] as const; const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,199}$/; +const BOUNDARY_DETAILS = ["freshness_age_invalid", "freshness_context_invalid", + "read_authority_checkpoint_missing", "read_authority_checkpoint_invalid"] as const; function token(value: unknown, name: string, optional = false): string | null { if (optional && (value === null || value === undefined)) return null; @@ -26,6 +28,31 @@ function count(value: unknown, name: string): number { return value as number; } +/** Assemble an exact surface's original-owner proof, never verify or grant it. */ +export function buildRewardMemorySurfaceReadCheckpoints(params: JsonObject): JsonObject { + const surface = token(params.surface_id, "surface_id"); + const verified = boolean(params.verified, "verified"); + const source = token(params.source_ref, "source_ref"); + if (!Array.isArray(params.corpora)) throw new EffectRuntimeRequestError("corpora must be an array"); + const entries = params.corpora.map((item) => { + const corpus = requireJsonObject(item, "corpus"); + const scope = requireJsonObject(corpus.scope, "scope"); + const id = token(corpus.corpus_id, "corpus_id") as string; + const checkpoint: JsonObject = {verified, corpus_id: id, surface_id: surface, + source_ref: source, workspace_ref: token(scope.workspace_ref, "workspace_ref"), + project_ref: token(scope.project_ref, "project_ref"), + read_authority: token(corpus.read_authority, "read_authority")}; + for (const field of ["user_ref", "peer_ref", "session_ref"] as const) { + if (scope[field] != null && scope[field] !== "") checkpoint[field] = token(scope[field], field); + } + return [id, checkpoint] as const; + }); + if (new Set(entries.map(([id]) => id)).size !== entries.length) { + throw new EffectRuntimeRequestError("corpus ids must be unique"); + } + return {checkpoints: Object.fromEntries(entries)}; +} + /** Query-ready consumption policy; no config, provider content or model calls. */ export function planRewardMemoryDecision(params: JsonObject): JsonObject { const mode = requireStringLiteral(params.mode, MODES, "mode"); @@ -80,6 +107,14 @@ export function projectRewardMemoryDecision(params: JsonObject): JsonObject { result_readback_verified: readback, recall_status: recallStatus, }; + // Add detail only for a typed input rejection; never publish exception text. + if (observation.boundary_detail_code != null) { + const detail = requireStringLiteral(observation.boundary_detail_code, BOUNDARY_DETAILS, "boundary_detail_code"); + if (hookStatus !== "guard_rejected" || packet.boundary_reason_code !== "exact_corpus_request_invalid") { + throw new EffectRuntimeRequestError("boundary detail requires an exact-corpus input rejection"); + } + packet.boundary_detail_code = detail; + } if (hookStatus === "provider_unavailable") { return {...packet, status: "provider_unavailable", reason_code: "provider_unavailable"}; } diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 90b4049cd6..1a9ee13738 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -232,6 +232,7 @@ import { recordExternalEvidenceReceiptObservation, } from "./capabilities/external_evidence.ts"; import { + buildRewardMemorySurfaceReadCheckpoints, planRewardMemoryDecision, projectRewardMemoryDecision, } from "./capabilities/reward_memory_decision.ts"; @@ -732,6 +733,7 @@ export function createEffectRuntimeHandlers( ["external_evidence.retire", projectExternalEvidenceRetirement], ["reward_memory.decision.plan", planRewardMemoryDecision], ["reward_memory.decision.project", projectRewardMemoryDecision], + ["reward_memory.read_authority.surface_checkpoints", buildRewardMemorySurfaceReadCheckpoints], [ "manager.return_delivery.normalize_attempt", (params) => normalizeManagerReturnDeliveryAttempt(params.attempt), From bfabdd07c4e00b0a19da4138114a2c0e0dc12ecd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:38:08 +0800 Subject: [PATCH 2/4] test(reward-memory): qualify scoped helper compatibility and rejection diagnostics Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../reward-memory-decision-consumption.md | 52 ++++++++- .../test_reward_memory_decision.py | 106 ++++++++++++++++++ .../reward_memory_decision.test.ts | 35 +++++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/docs/reference/reward-memory-decision-consumption.md b/docs/reference/reward-memory-decision-consumption.md index 9bd24fc16e..35feea5f23 100644 --- a/docs/reference/reward-memory-decision-consumption.md +++ b/docs/reference/reward-memory-decision-consumption.md @@ -26,7 +26,11 @@ Read-authority checkpoints must match the exact consumer surface and corpus; a turn-admission checkpoint cannot authorize a different review surface. `freshness_context.age_seconds`, when supplied, is a nonnegative integer. Rejected requests expose only the original hook's allowlisted -`boundary_reason_code`, never exception text or private input values. +`boundary_reason_code` and, for typed input errors, `boundary_detail_code`, +never exception text or private input values. The details distinguish +`freshness_age_invalid`, `freshness_context_invalid`, +`read_authority_checkpoint_missing` and `read_authority_checkpoint_invalid`. +Existing reason codes, ValueError compatibility, validation order and gates remain unchanged. 使用上述导出入口和 `resolve_reward_memory_experiment` 的原配置读回;不可用时 不能拿未经验证的配置替代。原 hook 的范围、revision、问题、时点、时效/冲突、 @@ -36,7 +40,32 @@ Rejected requests expose only the original hook's allowlisted 读授权 checkpoint 必须匹配本次 surface/corpus,不能拿 Turn 准入的 checkpoint 授权另一评审入口;age_seconds 如提供,须为非负整数。拒绝回执仅投影原 hook -白名单内的 boundary_reason_code,不暴露异常正文或私有参数。 +白名单内的 boundary_reason_code,以及输入错误的 boundary_detail_code;细分年龄非法、 +时效上下文非法、读授权缺失和读授权格式非法,不暴露异常正文或私有参数。 +保留原错误码、ValueError 兼容、校验顺序与门禁。 + +Use `build_reward_memory_surface_read_authority_checkpoints(config, surface_id, +verified=original_proof_verified, source_ref=original_read_authority_source)` +from the same package. It selects only that surface's configured corpora through +the existing configuration owner, then TS assembles the exact workspace/project, +optional user/peer/session, read-authority and surface references. The caller must +actually verify its original read authority: an enabled config or ingest policy +alone is not read proof. `verified=False` stays false and blocks recall. It does +not infer a proof source, enable the capability or contact a provider. The Turn +wrapper uses this same projection and retains its verified registry source. + +通用 helper 按实际 surface 和原配置选择 corpus,由 TS 组装精确范围;调用方仍须 +真实核验原读权限并显式传入 verified/source_ref,不能把开关或写入 policy 当作读授权。 +False 不会升级为 True;不推断授权来源、不启用能力、不调用 provider。原 Turn wrapper +复用该投影并保留 registry 来源。不要以生成了 checkpoint 为由宣称授权核验已完成。 + +If computing age from timestamps, first reject an observation in the future; +then round elapsed seconds upward to an integer. Never clamp a negative age, +refresh the original observation time, or change policy to make recall pass. +This helper intentionally does not calculate or correct age for the caller. + +由时间戳计算年龄时,先拒绝未来观察,再将经过秒数向上取整;不能截断负值、 +刷新原观察时间或改 policy 来过门。helper 不替调用方计算或纠正年龄。 TypeScript owns admission and completion (`reward_memory.decision.plan/project`); Python adapts the existing provider/applier and retains transient private values. @@ -48,6 +77,14 @@ TS 负责准入和完成语义;Python 只适配现有 provider/applier 并保 TS 只收到引用、状态、计数与摘要,不收到问题、经验正文、原产物或模型判断内容; 不新增存储、SDK、密钥、开关或行动授权。 +This slice does not migrate the existing Python SDK's scope/freshness validation; +it adds no second TS admission rule for those checks. Python remains the original +configuration/provider adapter and input-error source; TS owns the shared +checkpoint projection and allowlisted decision diagnostics. + +此切片不迁移原 Python SDK 的范围/时效校验,也不在 TS 复制准入规则。Python 保留 +原配置/provider 适配与输入错误来源,TS 持有共享 checkpoint 投影及白名单诊断。 + | Mode / 模式 | Provider / 调用 | Meaning / 意义 | | --- | --- | --- | | Disabled/unconfigured / 未配置或关闭 | Zero; returns `None` / 零调用,无新 packet | Original path unchanged / 原路径不变 | @@ -111,10 +148,21 @@ returns `replay_request_mismatch`. Reassessment uses retained qualified items an the original **cumulative** multi-corpus counters, not a second query. This is caller-retained replay, not automatic cross-process persistence or a new cache. +Retain the complete private result, not just context/public_packet/application +receipt. `assess_reward_memory_decision` needs the exact recall session and +attribution. A lost session after EOF/restart is incomplete, even if context was +delivered; do not re-query or fabricate semantic completion. There is currently +no supported cross-process restore API. Caller-owned persistence and a future +validated restore contract remain separate from this in-process replay API. + 仅 `public_packet` 用于展示,其余结果私有。通过既有执行上下文保留结果; `previous_result` 仅复用配置和输入均匹配的请求,变化则拒绝复用。后续判断使用 原条目和累计多 corpus 遥测,不重复查询。这不是自动跨进程存储或新的缓存。 +需保留完整私有 result,不能只存 context/public_packet/application receipt。 +EOF/重启丢失 recall_session 时,交付过上下文也不能完成 assessment;不重查、不补造 +语义完成。当前没有受支持的跨进程恢复 API,持久化与后续验证恢复合同是独立缺口。 + Empty/filtered/unavailable and invalid model/transport results preserve the base and allow ordinary research. Post-provider transport failure retains actual call/filter counts and the original private receipt. The existing route still diff --git a/tests/capabilities/test_reward_memory_decision.py b/tests/capabilities/test_reward_memory_decision.py index 8f9d9d32b0..61a8dc7269 100644 --- a/tests/capabilities/test_reward_memory_decision.py +++ b/tests/capabilities/test_reward_memory_decision.py @@ -160,10 +160,116 @@ def test_invalid_original_request_retains_safe_boundary_reason(tmp_path): apply_memory=delivery, provider=provider, **arguments) assert result.public_packet["reason_code"] == "recall_boundary_rejected" assert result.public_packet["boundary_reason_code"] == "exact_corpus_request_invalid" + assert result.public_packet["boundary_detail_code"] == "freshness_age_invalid" assert result.public_packet["provider_call_count"] == provider.calls == 0 assert "freshness_context" not in json.dumps(result.public_packet) +@pytest.mark.parametrize("checkpoint,detail", [ + ({}, "read_authority_checkpoint_missing"), + ({"verified": "private invalid proof"}, "read_authority_checkpoint_invalid"), +]) +def test_checkpoint_input_diagnostics_do_not_call_provider(tmp_path, checkpoint, detail): + config, arguments, records = context(tmp_path) + arguments["read_authority_checkpoints"] = {"primary": checkpoint} + provider = Provider(records) + result = run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", + apply_memory=delivery, provider=provider, **arguments) + assert result.public_packet["boundary_reason_code"] == "exact_corpus_request_invalid" + assert result.public_packet["boundary_detail_code"] == detail + assert result.public_packet["provider_call_count"] == provider.calls == 0 + assert result.output == arguments["base_output"] + assert "private invalid proof" not in json.dumps(result.public_packet) + + +@pytest.mark.parametrize("verified", [True, False]) +def test_shared_surface_checkpoint_builder_preserves_explicit_read_proof(tmp_path, verified): + from loopx.capabilities.reward_memory import build_reward_memory_surface_read_authority_checkpoints + + config, arguments, records = context(tmp_path, two_corpora=True) + # An unrelated configured corpus must not be included in this surface's proof. + config["surfaces"][SURFACE]["corpus_ids"] = ["primary"] + before = copy.deepcopy(config) + arguments["read_authority_checkpoints"] = build_reward_memory_surface_read_authority_checkpoints( + config, SURFACE, verified=verified, source_ref="policy:original:review", + ) + assert config == before + assert list(arguments["read_authority_checkpoints"]) == ["primary"] + checkpoint = arguments["read_authority_checkpoints"]["primary"] + assert checkpoint["surface_id"] == SURFACE + assert checkpoint["verified"] is verified + assert checkpoint["source_ref"] == "policy:original:review" + provider = Provider(records) + result = run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", + apply_memory=delivery, provider=provider, **arguments) + assert result.public_packet["context_delivery_verified"] is verified + assert provider.calls == (1 if verified else 0) + assert not result.public_packet["grants_new_action_authority"] + + +def test_turn_checkpoint_wrapper_retains_original_scope_and_source(tmp_path): + from loopx.capabilities.agent_turn_recall.runtime import reward_memory_turn_read_authority_checkpoints + from loopx.capabilities.reward_memory import build_reward_memory_surface_read_authority_checkpoints + + config, _, _ = context(tmp_path) + turn = "agent_workflow.turn_admission" + config["surfaces"][turn] = {**config["surfaces"][SURFACE], "surface_id": turn} + scope = config["corpora"]["primary"]["corpus"]["scope"] + scope["surface_ids"].append(turn) + scope.update(user_ref="user:example", peer_ref="peer:reviewer", session_ref="session:one") + expected = {"primary": {"verified": True, "corpus_id": "primary", + **{key: scope[key] for key in ("workspace_ref", "project_ref", "user_ref", "peer_ref", "session_ref")}, + "surface_id": turn, "read_authority": "module_scoped", + "source_ref": "registry:goal:example:reward-memory"}} + assert reward_memory_turn_read_authority_checkpoints(config, "goal:example") == expected + assert build_reward_memory_surface_read_authority_checkpoints( + config, turn, verified=True, source_ref="registry:goal:example:reward-memory", + ) == expected + + +@pytest.mark.parametrize("age", [True, -1, 0.5, "1"]) +def test_invalid_age_is_not_coerced_even_with_a_missing_checkpoint(tmp_path, age): + config, arguments, records = context(tmp_path) + arguments["freshness_context"]["age_seconds"] = age + arguments["read_authority_checkpoints"] = {} + provider = Provider(records) + result = run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", + apply_memory=delivery, provider=provider, **arguments) + assert result.public_packet["boundary_detail_code"] == "freshness_age_invalid" + assert result.output == arguments["base_output"] + assert provider.calls == 0 + + +@pytest.mark.parametrize("freshness", [{}, {"source_truth_current": "true"}, + {"source_truth_current": True, "source_revision": "private invalid revision text"}]) +def test_other_freshness_input_errors_are_safe_and_zero_call(tmp_path, freshness): + config, arguments, records = context(tmp_path) + arguments["freshness_context"] = freshness + provider = Provider(records) + result = run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", + apply_memory=delivery, provider=provider, **arguments) + assert result.public_packet["boundary_detail_code"] == "freshness_context_invalid" + assert provider.calls == 0 + assert "private invalid revision text" not in json.dumps(result.public_packet) + + +def test_shared_builder_does_not_substitute_an_unconfigured_surface(tmp_path): + from loopx.capabilities.reward_memory import build_reward_memory_surface_read_authority_checkpoints + + config, _, _ = context(tmp_path) + with pytest.raises(ValueError, match="surface is not configured"): + build_reward_memory_surface_read_authority_checkpoints( + config, "review.unconfigured", verified=True, source_ref="policy:original:review", + ) + + +def test_typed_input_error_cannot_carry_an_arbitrary_public_code(): + from loopx.capabilities.reward_memory.application import RewardMemoryRecallInputError + + with pytest.raises(ValueError, match="unsupported recall input error code"): + RewardMemoryRecallInputError("private exception text", "private content") + + @pytest.mark.parametrize("disposition", ["applied", "applied_unchanged", "ignored", "refuted"]) def test_delivery_then_actual_bound_assessment_and_exact_replay(tmp_path, disposition): outcome = "applied" if disposition == "applied_unchanged" else disposition diff --git a/tests/control_plane_ts/reward_memory_decision.test.ts b/tests/control_plane_ts/reward_memory_decision.test.ts index d82a012b0c..b07761f816 100644 --- a/tests/control_plane_ts/reward_memory_decision.test.ts +++ b/tests/control_plane_ts/reward_memory_decision.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {planRewardMemoryDecision, projectRewardMemoryDecision} from "../../loopx/control_plane/capabilities/reward_memory_decision.ts"; +import {buildRewardMemorySurfaceReadCheckpoints, planRewardMemoryDecision, projectRewardMemoryDecision} from "../../loopx/control_plane/capabilities/reward_memory_decision.ts"; const request = {mode: "execute", query_ready: true, application_kind: "semantic_application", has_applier: true, application_id: "application:one", artifact_ref: "artifact:current", surface_id: "review.summary"}; @@ -64,3 +64,36 @@ test("project only the original hook's safe typed boundary reason", () => { assert.equal(rejected.boundary_reason_code, "exact_corpus_request_invalid"); assert.equal(rejected.provider_call_count, 0); }); + +test("surface checkpoint assembly preserves caller proof and exact identity without verifying it", () => { + const input = {surface_id: "review.summary", verified: false, source_ref: "policy:original", + corpora: [{corpus_id: "one", read_authority: "actor_scoped", + scope: {workspace_ref: "workspace:one", project_ref: "project:one", + user_ref: "user:one", peer_ref: "peer:one", session_ref: "session:one"}}]}; + assert.deepEqual(buildRewardMemorySurfaceReadCheckpoints(input), {checkpoints: {one: { + verified: false, source_ref: "policy:original", surface_id: "review.summary", corpus_id: "one", + read_authority: "actor_scoped", ...input.corpora[0].scope, + }}}); + for (const patch of [{verified: "true"}, {source_ref: ""}, {source_ref: "private prose is not proof"}, + {corpora: [...input.corpora, ...input.corpora]}, {corpora: [{}]}]) { + assert.throws(() => buildRewardMemorySurfaceReadCheckpoints({...input, ...patch})); + } +}); + +test("safe diagnostic details never become unbound success or private exception text", () => { + const rejected = {...observation, status: "guard_rejected", recall_status: null, + provider_call_count: 0, result_readback_verified: false, boundary_reason_code: "exact_corpus_request_invalid"}; + for (const detail of ["freshness_age_invalid", "freshness_context_invalid", + "read_authority_checkpoint_missing", "read_authority_checkpoint_invalid"]) { + const result = projectRewardMemoryDecision({request, observation: {...rejected, boundary_detail_code: detail}}); + assert.equal(result.boundary_detail_code, detail); + assert.equal(result.reason_code, "recall_boundary_rejected"); + assert.equal(result.provider_call_count, 0); + assert.equal(result.decision_consumption_complete, false); + } + for (const patch of [{boundary_detail_code: "private exception body"}, + {boundary_detail_code: "freshness_age_invalid", status: "applied"}, + {boundary_detail_code: "freshness_age_invalid", boundary_reason_code: "automation_config_invalid"}]) { + assert.throws(() => projectRewardMemoryDecision({request, observation: {...rejected, ...patch}})); + } +}); From 4caf96f2728d3f3762b6f6756068c9dec199a6a4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:49:15 +0800 Subject: [PATCH 3/4] fix(agent-turn-recall): retain safe feedback on checkpoint transport failure Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../reward-memory-decision-consumption.md | 12 ++++ loopx/capabilities/agent_turn_recall/cli.py | 24 ++++++- tests/capabilities/test_agent_turn_recall.py | 68 +++++++++++++++++++ .../test_reward_memory_decision.py | 18 +++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/docs/reference/reward-memory-decision-consumption.md b/docs/reference/reward-memory-decision-consumption.md index 35feea5f23..8ee742cdb3 100644 --- a/docs/reference/reward-memory-decision-consumption.md +++ b/docs/reference/reward-memory-decision-consumption.md @@ -59,6 +59,18 @@ wrapper uses this same projection and retains its verified registry source. False 不会升级为 True;不推断授权来源、不启用能力、不调用 provider。原 Turn wrapper 复用该投影并保留 registry 来源。不要以生成了 checkpoint 为由宣称授权核验已完成。 +Checkpoint transport failure remains optional-enrichment failure: managed Turn +admission returns its existing fail-open `runtime_unavailable` packet. The explicit +`agent-turn-recall --execute` CLI returns a safe `runtime_unavailable` packet and +exit code 2. Neither path calls the provider or writes a successful same-Turn +receipt when checkpoint construction fails; a later healthy retry uses the same +Turn identity. These zero-call guarantees apply before provider invocation only. + +checkpoint 传输失败不成为普通 Turn 的新门禁:managed 准入沿用原 fail-open +`runtime_unavailable`;显式 CLI 返回安全的同类 packet 和退出码 2。构建失败时 +均不调用 provider、不写成功的同 Turn 回执;恢复后沿用原 Turn 身份重试。 +零调用保证仅适用于 provider 调用前的构建失败,不覆盖调用后的异常。 + If computing age from timestamps, first reject an observation in the future; then round elapsed seconds upward to an integer. Never clamp a negative age, refresh the original observation time, or change policy to make recall pass. diff --git a/loopx/capabilities/agent_turn_recall/cli.py b/loopx/capabilities/agent_turn_recall/cli.py index 1e5351c88e..f8a7905554 100644 --- a/loopx/capabilities/agent_turn_recall/cli.py +++ b/loopx/capabilities/agent_turn_recall/cli.py @@ -244,13 +244,31 @@ def handle_agent_turn_recall_command( "experiment": experiment_status, } else: + try: + read_checkpoints = _read_authority_checkpoints(config, args.goal_id) + except RuntimeError: + # This failure precedes the provider; do not fabricate a + # zero-call receipt for errors after recall has begun. + payload = { + "ok": False, + "schema_version": AGENT_TURN_RECALL_SCHEMA_VERSION, + "status": "runtime_unavailable", + "reason_code": "automatic_recall_runtime_failed", + "goal_id": args.goal_id, + "agent_id": args.agent_id, + "provider_call_count": 0, + "grants_new_action_authority": False, + "quota_spend_performed": False, + "external_writes_performed": False, + "suppress_external_sinks": True, + } + print_payload(payload, output_format(args), _render) + return 2 payload = run_agent_turn_recall( config, situation, observed_at=datetime.now(timezone.utc).isoformat(), - read_authority_checkpoints=_read_authority_checkpoints( - config, args.goal_id - ), + read_authority_checkpoints=read_checkpoints, ) | { "goal_id": args.goal_id, "agent_id": args.agent_id, diff --git a/tests/capabilities/test_agent_turn_recall.py b/tests/capabilities/test_agent_turn_recall.py index ffaa433f65..ca8d2ccc4e 100644 --- a/tests/capabilities/test_agent_turn_recall.py +++ b/tests/capabilities/test_agent_turn_recall.py @@ -2,6 +2,7 @@ import hashlib import json +import argparse from pathlib import Path from typing import Any @@ -15,6 +16,8 @@ _write_receipt, ) from loopx.capabilities.agent_turn_recall import runtime as recall_runtime +from loopx.capabilities.agent_turn_recall import cli as recall_cli +from loopx.capabilities.reward_memory import read_authority from loopx.capabilities.agent_turn_recall.runtime import ( agent_turn_recall_receipt_path, run_configured_agent_turn_recall, @@ -545,6 +548,71 @@ def test_configured_turn_recall_injects_content_and_deduplicates( assert provider.calls == 1 +def test_checkpoint_transport_failure_preserves_turn_and_recovers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + config = normalized_config(tmp_path) + status = {"ok": True, "status": "available", "available": True} + monkeypatch.setattr(recall_runtime, "resolve_reward_memory_experiment", lambda **_kwargs: (status, config)) + monkeypatch.setattr(recall_runtime, "_goal_repo", lambda *_args, **_kwargs: tmp_path) + provider = RecallProvider() + original_transport = read_authority.effect_runtime_result + + def unavailable(operation: str, _params: Any) -> Any: + assert operation == "reward_memory.read_authority.surface_checkpoints" + raise RuntimeError("private runtime startup detail") + + monkeypatch.setattr(read_authority, "effect_runtime_result", unavailable) + kwargs = dict(registry_path=tmp_path / "registry.json", goal_id="goal", agent_id="pilot", + quota_decision=quota_decision(), turn_instance_id="turn-recovery", execute=True, provider=provider) + result = recall_runtime.run_configured_agent_turn_recall_fail_open(**kwargs) + assert result["ok"] and result["fail_open"] + assert result["status"] == "runtime_unavailable" and result["context"] is None + assert result["provider_call_count"] == provider.calls == 0 + assert not result["provider_failure_is_user_gate"] and not result["quota_spend_performed"] + receipt = agent_turn_recall_receipt_path(tmp_path, goal_id="goal", agent_id="pilot") + assert not receipt.exists() + assert "private runtime" not in json.dumps(result) + monkeypatch.setattr(read_authority, "effect_runtime_result", original_transport) + recovered = recall_runtime.run_configured_agent_turn_recall_fail_open(**kwargs) + assert recovered["status"] == "not_available" + assert recovered["provider_call_count"] == provider.calls == 1 + assert recovered["same_turn_receipt_written"] and receipt.exists() + + +def test_explicit_cli_checkpoint_transport_failure_returns_safe_packet( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + config = normalized_config(tmp_path) + monkeypatch.setattr(recall_cli, "resolve_reward_memory_experiment", lambda **_kwargs: ({"status": "available"}, config)) + monkeypatch.setattr(recall_cli, "_goal_repo", lambda *_args, **_kwargs: tmp_path) + turn = "turn-cli-failure" + decision = quota_decision() | {"goal_id": "goal", "agent_identity": {"agent_id": "pilot"}, + "heartbeat_receipt": {"turn_instance_id": turn, "status": "committed"}} + monkeypatch.setattr(recall_cli, "_quota_decision", lambda *_args: decision) + + def unavailable(operation: str, _params: Any) -> Any: + assert operation == "reward_memory.read_authority.surface_checkpoints" + raise RuntimeError("private runtime startup detail") + + monkeypatch.setattr(read_authority, "effect_runtime_result", unavailable) + monkeypatch.setattr(recall_cli, "run_agent_turn_recall", lambda *_args, **_kwargs: pytest.fail("provider boundary reached")) + payloads: list[dict[str, Any]] = [] + result = recall_cli.handle_agent_turn_recall_command( + argparse.Namespace(command="agent-turn-recall", goal_id="goal", agent_id="pilot", turn_instance_id=turn, + quota_decision_json="unused", session_ref=None, force_refresh=False, execute=True), + registry_path=tmp_path / "registry.json", output_format=lambda *_args: "json", + print_payload=lambda payload, *_args: payloads.append(payload), + ) + assert result == 2 and len(payloads) == 1 + payload = payloads[0] + assert not payload["ok"] and payload["status"] == "runtime_unavailable" + assert payload["reason_code"] == "automatic_recall_runtime_failed" + assert payload["provider_call_count"] == 0 and not payload["quota_spend_performed"] + assert "private runtime" not in json.dumps(payload) + assert not agent_turn_recall_receipt_path(tmp_path, goal_id="goal", agent_id="pilot").exists() + + def test_expiry_survives_candidate_review_and_activation(tmp_path: Path) -> None: config = normalized_config(tmp_path) corpus = resolve_reward_memory_surface_config(config, SURFACE)["corpus"] diff --git a/tests/capabilities/test_reward_memory_decision.py b/tests/capabilities/test_reward_memory_decision.py index 61a8dc7269..2ff3e8764b 100644 --- a/tests/capabilities/test_reward_memory_decision.py +++ b/tests/capabilities/test_reward_memory_decision.py @@ -227,6 +227,24 @@ def test_turn_checkpoint_wrapper_retains_original_scope_and_source(tmp_path): ) == expected +def test_new_corpus_does_not_expand_an_existing_surface_without_owner_binding(tmp_path): + from loopx.capabilities.reward_memory import build_reward_memory_surface_read_authority_checkpoints + + config, _, _ = context(tmp_path, two_corpora=True) + overlay = config["corpora"].pop("overlay") + config["surfaces"][SURFACE]["corpus_ids"] = ["primary"] + before = build_reward_memory_surface_read_authority_checkpoints( + config, SURFACE, verified=True, source_ref="policy:original:review") + config["corpora"]["overlay"] = overlay + assert build_reward_memory_surface_read_authority_checkpoints( + config, SURFACE, verified=True, source_ref="policy:original:review") == before + config["surfaces"][SURFACE]["corpus_ids"].append("overlay") + after = build_reward_memory_surface_read_authority_checkpoints( + config, SURFACE, verified=False, source_ref="policy:original:review") + assert list(after) == ["primary", "overlay"] + assert all(checkpoint["verified"] is False for checkpoint in after.values()) + + @pytest.mark.parametrize("age", [True, -1, 0.5, "1"]) def test_invalid_age_is_not_coerced_even_with_a_missing_checkpoint(tmp_path, age): config, arguments, records = context(tmp_path) From a87508553dfba995437151c213d5f496b6666b99 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:31:47 +0800 Subject: [PATCH 4/4] chore(registry): refresh the agent_turn_recall IO line reference Delegating reward_memory_turn_read_authority_checkpoints to the shared read_authority adapter added an import line above _goal_repo, moving its load_registry call from line 40 to 41. Regenerate the checked-in census with the owning generator so test_project_registry_io_census stays current on the pull-request path. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/semantics/project_registry_io_manifest_v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 6f1987660a..8662e2d95b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -103,7 +103,7 @@ }, { "site": "loopx/capabilities/agent_turn_recall/runtime.py::._goal_repo::codec_read:load_registry#1", - "line": 40, + "line": 41, "column": 31, "kind": "codec_read", "api": "load_registry",