From b7c7dae8c0385f41b5dffd23a903489e19cc6203 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:30:26 +0800 Subject: [PATCH] fix(context): deliver complete current preferences in initial reads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 11 +++++++- .../quota/progressive_context.ts | 6 +++-- .../references/repair-patterns.md | 1 + tests/control_plane/test_agent_preferences.py | 26 +++++++++++++++++++ .../control_plane/test_context_first_read.py | 22 ++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 0b0c79e153..c6e6fb49f6 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -179,6 +179,11 @@ An enabled native **full** reference for a recognized decision can include `first_read_command`. It batches current admission/stop facts, the selected task, whole Goal/Todo narrative, source availability/revisions and read routes. Detailed history and optional authoring recipes remain expandable from the same capture. +The preference source's complete `current` view also stays in the initial read: +corrections, retirement/expiry and empty replacements retain their provenance, +revision and advisory authority. This captured pre-work view does not replace the +preference owner's fresh exact-scope read before a later preference-dependent +external action, and neither delivery nor a preference grants authority. The existing bounded `unfinished_delegations` projection and `read_complete` flag also remain visible. Adoption or a queued return does not establish completion. Pending peer results, counts, omitted items and unavailable @@ -217,7 +222,11 @@ activation defaults, saved files, Delta and full-read receipts are unchanged. 中文:已启用原生传输的完整引用可给出 `first_read_command`,合并当前准入、停止条件、 任务和完整 Goal/Todo 正文,以及来源状态、版本与读取入口;历史和可选格式说明仍在 同一捕获中按需展开。它是起始选读,不代表已读全部必要来源;依赖具体验收、证据或 -执行/结算契约前仍须完整读取。未知 schema、过小引用预算和 warm delta 保留既有路线, +执行/结算契约前仍须完整读取。 +偏好来源的完整 `current` 视图也直接呈现:纠偏、退役/过期和空视图替换均保留 +原来源、版本及 advisory 权限边界。本次捕获的工作前视图不能替代偏好 owner 要求的 +后续偏好相关外部动作前精确作用域新读;偏好及其交付均不授予权限。 +未知 schema、过小引用预算和 warm delta 保留既有路线, 关闭原生传输的输出不变;完整恢复和原 SHA 校验均保留。只读的 `--initial-read` 不与 显式选读或索引混用;续页保留短选项和固定的选读摘要,profile 改变则拒绝混读。 首读也给出现有验收材料地址和当前结算义务。仅明确 `required=false` 的附件延后完整 diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 5cadc85043..71010ab27d 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -234,8 +234,10 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: // incomplete observations visible without expanding full request bodies. include(...source, "content", "read_complete"); include(...source, "content", "unfinished_delegations"); - include(...source, "content", "current", "revision"); - include(...source, "content", "current", "instruction"); + // The preference owner already supplies the complete current scoped view, + // including corrections, retirement, provenance and advisory authority. + // Its revision/instructions alone cannot fulfill that pre-work delivery. + include(...source, "content", "current"); include(...source, "content", "outcome_context"); const sourceRow = object(_source), content = object(sourceRow?.content); const requirements = object(object(content?.goal_acceptance_contract)?.outcome_requirements); diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 1c1b7174ca..8444da34f6 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `initial_preference_view_omission` | A fulfilled preference hook leaves corrections, retirement or advisory instructions absent from the recommended initial read. | The preference owner's complete current scoped view, actual selected reader/native input, empty and unavailable states, and same-capture continuation/recovery. | A hand-picked projection uses incomplete or stale child field names after the owner has already delivered the current view. | Reuse the complete owned current view in the existing initial selector; retain provenance, advisory authority, exact-scope freshness and immutable capture checks. Cover real File/SQLite lifecycle reads, empty replacements, unknown values and native consumer readback. Preserve changed-profile refusal and fresh recovery; disclose restored bytes as information cost, not compression or model benefit. | | `immutable_reader_parent_alias` | A warm native view refuses an unchanged reader reference after a verified capture read normalizes its parent path. | Cold descriptor bytes, warm baseline path and full digest, parent aliases versus distinct leaf names, and actual CLI recovery after compaction. | The reader accepts normal parent aliases, but immutable publication compares their literal path spellings. | Compare only equivalent parent spellings at the existing IO owner, retaining the exact leaf and every other metadata value; never rewrite the descriptor. Preserve full-digest, selection, permission and final-symlink checks. Cover both publication orders, distinct files and hardlinks, cold/warm native publication and registry-free current recovery; keep transport repair separate from retention and net-benefit claims. | | `todo_read_source_authority_label` | A canonical Todo read reports one provider in `authority_read` but labels the public result or projection source as File. | The same read's canonical provider revision, public source, envelope sources and Markdown across full, filtered, exact and compact views. | The shared formatter retained a provider-specific constant after provider routing moved to the canonical reader. | Reuse the existing canonical source authority for presentation and provenance. Exercise real CLI File/SQLite reads, missing results and source failure/recovery; preserve filtering, read-only behavior and fail-closed routing. A display correction does not select, migrate or qualify a provider. | | `vision_history_null_stride_decision` | Reusing the current Agent's complete Vision from `history` in `refresh-state --agent-vision-json` fails with `stride_decision must be an object`. | Compare the authored packet, compact history and status readback; retain the original operation identity and verify that rejection appended nothing. | The compact index represents an absent optional decision as JSON null while the write codec accepts only an omitted field. | Normalize null and omission as absence in the existing typed stride owner, shared by read and write codecs. Keep malformed proposals rejected and enabled stride reviews dependent on a current valid decision. Prove real File/SQLite history-to-writeback recovery, retained cumulative intent, unchanged-state refusals and off/on policy boundaries; do not rewrite historical receipts or count absence as a decision. | diff --git a/tests/control_plane/test_agent_preferences.py b/tests/control_plane/test_agent_preferences.py index 2ae14bda1b..4ca7afffad 100644 --- a/tests/control_plane/test_agent_preferences.py +++ b/tests/control_plane/test_agent_preferences.py @@ -13,6 +13,29 @@ ) +def _assert_initial_preferences(payload, directory, expected): + from loopx.cli_commands.quota_capture import native_context_capture_view + from tests.control_plane.test_context_first_read import read_selection + + directory.mkdir(mode=0o700) + sources = payload["interaction_contract"]["agent_channel"]["work_context"]["sources"] + index = next(i for i, row in enumerate(sources) if row.get("kind") == "agent_preferences") + view = native_context_capture_view(payload, directory) + original = Path(view["recovery"]["path"]).read_bytes() + _, values = read_selection(view["first_read_command"]) + prefix = f"/interaction_contract/agent_channel/work_context/sources/{index}/content/current" + for field, value in expected.items(): + wanted = prefix + "/" + field.replace("~", "~0").replace("/", "~1") + roots = [root for root in values if wanted == root or wanted.startswith(root + "/")] + assert roots, f"Initial read omitted current preference field: {field}" + root = max(roots, key=len) + actual = values[root] + for key in wanted[len(root):].split("/")[1:]: + actual = actual[key.replace("~1", "/").replace("~0", "~")] + assert actual == value + assert Path(view["recovery"]["path"]).read_bytes() == original + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_inactive_preferences_are_silent_and_participating_hook_delivers_current_view(tmp_path, monkeypatch, provider): nested = tmp_path / ("workspace dir" * 12) @@ -64,6 +87,7 @@ def call(action, *args): assert any(x["kind"] == "agent_preferences" for x in reads), quota["turn_start_capability_hook_dispatch"] assert "designated-reviewer" in json.dumps(quota) assert quota["interaction_contract"]["agent_channel"]["required_reads"] == [] + _assert_initial_preferences(quota, tmp_path / "active-first-read", fresh["current"]) assert quota["capability_gate"]["action"] == off["capability_gate"]["action"] rc, plan = _run_cli(registry, runtime, "turn", "plan", *scope) assert rc == 0, plan @@ -115,6 +139,7 @@ def call(action, *args): contents = next(x["content"] for x in next_quota["interaction_contract"]["agent_channel"]["work_context"]["sources"] if x.get("kind") == "agent_preferences") assert contents["current"]["items"][0]["statement"] == "Do not delegate review." + _assert_initial_preferences(next_quota, tmp_path / "corrected-first-read", action_view["current"]) rc, replay = call("remember", *args, "--execute") assert rc == 0 and replay["status"] == "replayed", replay assert replay["current"]["items"][0]["statement"] == "Do not delegate review." @@ -130,6 +155,7 @@ def call(action, *args): if x.get("kind") == "agent_preferences") assert contents["current"]["items"][0]["state"] == "retired" assert contents["current"]["items"][0]["statement"] is None + _assert_initial_preferences(quota, tmp_path / "retired-first-read", later["current"]) rc, history = call("history") assert rc == 0 and len(history["events"]) == 3, history alias = tmp_path / "global-registry.json" diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index d457584425..bc452d3ab6 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -163,6 +163,28 @@ def test_unknown_settlement_plan_is_not_compacted(tmp_path, decision): assert values["/interaction_contract/cli_channel/settlement_plan"] == plan +@pytest.mark.parametrize("current", [None, {}, { + "schema_version": "agent_preferences_v1", "authority": "advisory_only", + "revision": "empty-current", "instructions": "Replace prior scoped context, including an empty view.", + "items": [], "future_context": {"a/b~c": [None, False, 0, "Keep this complete current value."]}, +}]) +def test_initial_read_preserves_empty_and_unknown_current_preference_values(tmp_path, decision, current): + source = {"source": "turn_start_capability_hook", "kind": "agent_preferences", + "content": {"ok": current is not None, "status": "read" if current is not None else "unavailable", + "current": current}} + sources = decision["interaction_contract"]["agent_channel"]["work_context"]["sources"] + sources.append(source) + before = json.loads(json.dumps(decision)) + view = native_context_capture_view(decision, tmp_path) + page, values = read_selection(view["first_read_command"]) + pointer = f"/interaction_contract/agent_channel/work_context/sources/{len(sources) - 1}/content/current" + assert values[pointer] == current + assert not any(key.startswith(pointer + "/") for key in values) # No duplicate delivered descendants. + raw = Path(view["recovery"]["path"]).read_bytes() + assert json.loads(raw) == before == decision + assert capture_page_range(page, raw=raw, digest=page["sha256"]) is None + + def test_initial_read_exposes_existing_outcome_addresses_without_loading_all_criteria(tmp_path, decision): pointer = "/interaction_contract/agent_channel/work_context/sources/0/content/goal_acceptance_contract/outcome_requirements/outcomes/0" outcome = {"id": "current-work", "supporting_material_pointer": pointer,