From fefb624cb761059093d7d14d952ea65b382c7c24 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:35:08 +0800 Subject: [PATCH] fix(vision): accept absent stride decisions from history Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../control_plane/work_items/stride_policy.ts | 5 +-- .../references/repair-patterns.md | 1 + tests/control_plane/test_vision_budget_cli.py | 34 +++++++++++++++++-- tests/control_plane_ts/stride_policy.test.ts | 25 ++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index 45438bc5fc..637eadbabd 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -91,7 +91,9 @@ function comparisonCandidates(value: unknown): JsonObject[] { /** Agent interpretations are structured claims, not certified evidence or authority. */ export function normalizeStrideDecision(value: unknown, bounded: BoundedText = text): [JsonObject | null, Record] { - if (value === undefined) return [null, {}]; + // Compact run history records absent optional decisions as JSON null. + // Absence is not a proposal and cannot satisfy an enabled stride review. + if (value == null) return [null, {}]; const input = requireJsonObject(value, "stride_decision"); // Off-mode transport and historical readback retain both earlier v1 shapes. // An enabled fresh review requires the current concrete-plan schema. @@ -194,7 +196,6 @@ export function normalizeStrideDecision(value: unknown, bounded: BoundedText = t /** Historical read codec only, never a selectable strategy or new proposal. */ export function normalizeRecordedStrideDecision(value: unknown): JsonObject | null { - if (value == null) return null; return normalizeStrideDecision(value)[0]; } function normalizeHistoricalStrideDecision(value: unknown, bounded: BoundedText): [JsonObject, Record] { diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 7d8de58837..d7c3e71d60 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 | | --- | --- | --- | --- | --- | +| `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. | | `async_command_completion_metadata_lost` | A code cell appears complete with empty stdout, then the agent searches saved files or reissues a stateful command without checking whether its child is still running. | The original tool result's running handle, terminal exit code and stderr; caller projection code; the same command's completion; original operation/capture identity. Keep empty success, empty failure and a pending command distinct. | The caller forwarded only stdout and dropped the command tool's completion metadata; a completed wrapper was mistaken for a completed child. Empty output alone cannot establish this cause in a historical trace whose inner result is unavailable. | Preserve the tool result envelope, retain a running handle and collect the same execution's terminal result. If the handle was lost, follow original-receipt or digest-bound capture recovery before any retry. Keep admission/lease/settlement fences and original identities; qualify with pending, silent-success and silent-failure controls. See targeted diagnostics; no timeout, quota, packet or authority change is implied. | | `same_packet_json_type_alias` | Quota sharing replaces a distinct Vision or authoring observation with a reference because Boolean/number or signed-zero values compare equal in the host language. | Original captured values, projected body/ref, exact scalar types and zero signs, equal-content/key-order controls, actual CLI recovery and unchanged receipt/spend counts. | Language-level value equality was used as lossless JSON identity, while another sharing path already retained original serialized types. | Reuse one local serialization identity at the existing compatibility owner; share only complete equal JSON bodies and retain unique values/instructions. Preserve exact references, source input and existing reader/settlement ownership. Cover ordinary/native File/SQLite reads and digest refusal, and count transport rather than adding a late full-payload RPC. Keep installed/model adoption and whole-C1 benefit separate. | | `original_conversation_cli_goal_instance_drift` | `manager-inbox original-conversation` accepts an explicit `--goal-instance-id` but returns success for a stale instance, or follows the currently registered generation instead. | The physical CLI argv, selected registry profile, explicit versus omitted instance, current Goal reference, and real HTTP/source/queue/return readback in disposable File/SQLite state. | The CLI branch dropped the selector before the existing collaboration owner; current-instance inference concealed the missing caller reference. An unsupported source profile is a separate boundary, not proof that forwarding alone enables it. | Validate and forward the explicit Goal reference through the existing owner. Preserve omitted-selector compatibility and keep stale-instance, source/grant and audience checks authoritative. Cover real CLI discovery, request and control, unchanged inferred discovery, stale-instance rejection without queued work, and original recipient/result return; do not promote profiles or infer grants to make a live request pass. | diff --git a/tests/control_plane/test_vision_budget_cli.py b/tests/control_plane/test_vision_budget_cli.py index 185af9870b..14b7ccbed5 100644 --- a/tests/control_plane/test_vision_budget_cli.py +++ b/tests/control_plane/test_vision_budget_cli.py @@ -5,6 +5,8 @@ import importlib.util import json from pathlib import Path +import subprocess +import sys import pytest @@ -13,8 +15,9 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("complete_prior", [False, True]) +@pytest.mark.parametrize("reader", ["status", "history"]) def test_projected_autonomous_example_updates_evidence_without_changing_goal( - tmp_path, monkeypatch, provider, complete_prior, + tmp_path, monkeypatch, provider, complete_prior, reader, ): from canonical_authority_fixture import isolate_sqlite_runtime @@ -54,8 +57,18 @@ def test_projected_autonomous_example_updates_evidence_without_changing_goal( if complete_prior: # Follow the returned authoring procedure using the complete current # Agent packet from a separate read, not fields reconstructed by a model. - current = fixture.run_status(registry, runtime) - goal = next(g for g in current["run_history"]["goals"] if g["id"] == fixture.GOAL_ID) + if reader == "status": + history = fixture.run_status(registry, runtime)["run_history"] + else: + # Compact history is a public readback too. Its index retains null + # for absent optional decisions, unlike the full status projection. + result = subprocess.run([ + sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "history", + "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, + ], cwd=fixture.REPO_ROOT, capture_output=True, text=True, check=True) + history = fixture.payload(result) + goal = next(g for g in history["goals"] if g["id"] == fixture.GOAL_ID) current_packet = copy.deepcopy(goal["latest_runs"][0]["agent_vision"]) assert current_packet["agent_id"] == fixture.AGENT_ID current_packet["vision_patch"].update(packet["vision_patch"]) @@ -68,6 +81,7 @@ def test_projected_autonomous_example_updates_evidence_without_changing_goal( assert result.returncode == 0, accepted.get("error") or accepted assert accepted["ok"] and accepted["appended"] assert accepted["agent_vision"]["path_delta"]["outcome"] == "replan" + assert "stride_decision" not in accepted["agent_vision"] assert original_goal in state.read_text() # A separate CLI read must preserve every existing field, not merely echo @@ -105,6 +119,20 @@ def test_projected_autonomous_example_updates_evidence_without_changing_goal( assert retained["agent_vision"]["path_delta"]["outcome"] == disposition assert original_goal in state.read_text() + if complete_prior and reader == "history": + before = index.read_bytes() + for invalid in (False, 0, "", [], {}): + malformed = copy.deepcopy(packet) + malformed["stride_decision"] = invalid + fixture.write_json(document, malformed) + rejected = fixture.run_cli(registry, runtime, vision_path=document, + check=False, dry_run=False, autonomous_replan_recorded=False) + failure = fixture.payload(rejected) + assert rejected.returncode == 1 + assert "stride_decision" in failure["error"] + assert failure["appended"] is False + assert index.read_bytes() == before + @pytest.mark.parametrize("character", ["x", "界"]) def test_full_budget_roundtrips_without_erasing_replan_or_partial_writes(tmp_path, character): diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index 19227e131a..ae30a9dca4 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -23,6 +23,31 @@ const request = {schema_version: VISION_REFRESH_REQUEST_SCHEMA, phase: "prepare" agent_id: "agent-a", merge_patch: false, existing_agent_vision: null, require_path_delta_for_durable_change: false, agent_vision_packet: packet}; +test("an absent recorded decision roundtrips without satisfying an enabled stride review", () => { + for (const value of [undefined, null]) { + const vision = buildVisionCheckpoint({...request, + agent_vision_packet: {...packet, stride_decision: value}}).agent_vision as Record; + assert.equal(Object.hasOwn(vision, "stride_decision"), false); + for (const kind of ["vision_acceptance_gap", ...decisionReviewTriggers]) { + const obligation = {obligation_id: proposal.reference_obligation_id, + triggers: [{kind}], replan_context: { + stride_strategy: "evidence_conditioned_v1", context_id: contextId}}; + const observation_delta = {delta_kinds: ["new_surface"], evidence_novel: true}; + const missing = projectReplanSemantics({operation: "qualify", obligation, + observation_delta, agent_vision: vision}); + assert.equal(missing.accepted, false, kind); + assert.equal(missing.reason_code, "stride_decision_required", kind); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: { + ...obligation, replan_context: {stride_strategy: "off"}}, + observation_delta, agent_vision: vision}).accepted, true, kind); + } + } + for (const value of [false, 0, "", [], {}]) { + assert.throws(() => buildVisionCheckpoint({...request, + agent_vision_packet: {...packet, stride_decision: value}})); + } +}); + test("previous-outcome claims need subsequent evidence; old comparison evidence remains usable", () => { const previous = "replan-decision-" + "b".repeat(24); const context = {evidence: [{evidence_ref: "evidence:before"}, {evidence_ref: "evidence:after"}],