diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index b79a0268ea..b8d57541ca 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -634,6 +634,13 @@ Explore contributes references into its existing scoped findings/planner and omitted-result reads; the proposal binds the revision and considered findings with an applicability/disposition reason. Typed validation rejects missing reviews, wrong decision/finding references and stale or unavailable bases. +Revision-bound global result pages can extend considered findings through the +optional v2 `explore_review.result_pages` coordinates. The existing Explore +owner replays at most three pages from its current source before writeback; +these facts do not change the admitted context/basis identity. Changed source +requires rereading within the original Turn. Page coordinates persist with the +decision into subsequent review; node-filtered/planner detail routes remain +outside this bounded citation path. No separate receipt store is introduced. These are provenance and proposal-shape obligations, not automatic proof that research was executed or useful. Ordinary Todo/Next Action carries admitted work. diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index d0647756a9..32bc27e73a 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -560,6 +560,12 @@ revision、考虑的 finding 与适用/暂缓等理由,不建新图或第二 重启并发基线。这类运行用于观察路线采用与迭代收益,不单独证明因果净收益或 promotion 资格。需要相应结论时,再执行匹配接续与重复验证。 +全局结果分页可通过 v2 的可选 `explore_review.result_pages` 页面坐标补充 +`considered_refs`。既有 Explore owner 在写回前从当前来源重放最多三页,不改变 +原上下文/证据基础身份;来源变化要求在原 Turn 内重新读取和评估。页面坐标随 +决策进入后续复盘,不新增回执库。按节点筛选和 planner 详情仍不属于此引用入口, +来源核验也不证明模型采用或研究收益。 + ### 7.7 研究状态分支与匹配接续 可恢复研究点包含**会话位置、研究证据、代码/候选产物与观测机会**。单独源码 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 5313088ca8..7922e69835 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -483,6 +483,24 @@ stale revisions and declaring unavailable evidence as empty are rejected. A changed evidence basis requires reassessment under the original Turn identity, not another quota debit. Disabled Explore adds no Explore assessment duty. +For a considered finding omitted from the bounded basis, follow the global +`result_page.next_command` (or increase its page size), then add +`explore_review.result_pages: [{"revision": "", "offset": 3, "limit": 3}]`. +Copy these three coordinates from each page actually read; keep +`explore_review.revision` bound to the original Explore basis. At most three +distinct pages and three considered findings fit the existing Vision budget. +Writeback replays the requested slices of the current, strictly validated +Explore source and admits only findings present in those pages. The page +revision binds the ordered full result contents and Goal/reader scope; it is +not the bounded basis revision. Same-Goal peer findings remain reusable. +Changed or unavailable source, forged membership and node-filtered pages are +refused. Reread and reassess under the original Turn and obligation. This +optional v2 field is retained in decision/history readback; ordinary reads stay +read-only and no receipt store, claim, spawn or extra quota debit is created. +Source replay verifies the declared citation, not that the model understood or +adopted it. Existing v2 decisions without pages and historical v1 reads retain +their behavior. Other detail routes are not supplemental citation sources. + The typed owner validates shape, references and declared provenance before the fresh Vision path exit. When an enabled Goal reaches a periodic review, every accepted exit must also record a current `agent_vision.stride_decision`: a new @@ -536,6 +554,13 @@ not improved model decisions or net efficiency. 集成、验证和回读,不扩大权限、延迟结算或捆绑独立承诺。下次复盘核查质量与完整 边际成本;合成准入链路只能证明协议采用,不能证明模型决策或净效率改善。 +补读全局结果分页后,可在 `explore_review.result_pages` 中附上实际读取页面的 +`revision`、`offset`、`limit`,最多三页;`explore_review.revision` 仍引用原有界 +证据版本。提交时从当前可信来源重放页面并核对 `considered_refs`,支持同一 Goal +其他 Agent 产出的证据。来源变化、不可用、伪造成员或按节点筛选的页面会被拒绝, +应在原 Turn 和原复盘义务下补读并重新评估。读取不写入回执或扣额度,分页引用随 +决策保留到历史和下次复盘;来源核验不证明模型理解、采用或研究收益。 + ### Path Delta A vision packet may include a top-level `path_delta` object; `goal_path_delta_v0` diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 69ae96cb6d..3bf06a8f2b 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -590,6 +590,17 @@ ordering. These reads do not alter planner scores, graph state, claims or quota. The dashboard's saved-evidence reader also pages through history; the CLI options above control the agent context, not dashboard page size. +When the stride strategy is enabled, findings read from a global result page +can be cited in `explore_review.considered_refs` by also copying that page's +`revision`, `offset` and `limit` into `explore_review.result_pages` (up to three +pages). Keep the separately delivered `explore_review.revision`; the two +revision fields have different scopes. Writeback resolves membership from the +same current source, including results authored by peers in this Goal, and +rejects changed/unavailable pages. This adds no read-side writes or execution +permission. Node-filtered and planner-detail reads are not supported by this +citation path. See the [stride contract](../../../docs/reference/protocols/goal-vision-replan-contract-v0.md#structured-stride-control) +for the existing decision, budget and original-Turn recovery rules. + Planning context also keeps a bounded `typed_evidence_audit` on suggested branches with explicit Todo/node links, and up to three existing exploring frontier nodes. Linked findings are resolved before the recent-history limit, diff --git a/loopx/capabilities/explore/turn_context.py b/loopx/capabilities/explore/turn_context.py index a290dd29b4..9902f01044 100644 --- a/loopx/capabilities/explore/turn_context.py +++ b/loopx/capabilities/explore/turn_context.py @@ -39,7 +39,8 @@ def _policy(registry_path, goal_id): return goal, graph, gate -def read_replan_explore_context(*, source_status: Mapping, goal_id: str, agent_id: str | None): +def read_replan_explore_context(*, source_status: Mapping, goal_id: str, agent_id: str | None, + review_pages=None): """Outer read adapter; the replan owner invokes it only for enabled reviews. Unavailable IO is distinct from an empty or disabled evidence source. No @@ -50,7 +51,8 @@ def read_replan_explore_context(*, source_status: Mapping, goal_id: str, agent_i try: return explore_turn_context( registry_path=Path(source_status["registry"]), runtime_root=Path(source_status["runtime_root"]), - goal_id=goal_id, agent_id=agent_id, include_finding_digests=True) + goal_id=goal_id, agent_id=agent_id, include_finding_digests=True, + **({"review_pages": review_pages} if review_pages is not None else {})) except (OSError, ValueError, RuntimeError): return {"ok": False, "goal_id": goal_id, "agent_id": agent_id} @@ -60,6 +62,7 @@ def explore_turn_context( result_limit: int = 3, result_offset: int = 0, result_node: str | None = None, result_revision: str | None = None, include_finding_digests: bool = False, + review_pages=None, ): if type(include_finding_digests) is not bool: raise ValueError("include_finding_digests must be an explicit boolean") @@ -119,6 +122,7 @@ def explore_turn_context( "result_node": result_node, "result_revision": result_revision, **({"include_finding_digests": True} if include_finding_digests else {}), + **({"review_pages": review_pages} if review_pages is not None else {}), }, ) diff --git a/loopx/control_plane/capabilities/explore_result_page.ts b/loopx/control_plane/capabilities/explore_result_page.ts new file mode 100644 index 0000000000..3d2973cec6 --- /dev/null +++ b/loopx/control_plane/capabilities/explore_result_page.ts @@ -0,0 +1,26 @@ +/** Bounded descriptors for replaying existing global Explore result pages. */ +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; + +export function normalizeExploreResultPages(value: unknown): JsonObject[] { + if (!Array.isArray(value) || !value.length || value.length > 3) { + throw new EffectRuntimeRequestError("explore_review.result_pages requires one to three distinct result pages"); + } + const seen = new Set(); + return value.map(item => { + const row = requireJsonObject(item, "Explore result page"); + if (Object.keys(row).some(key => !["revision", "offset", "limit"].includes(key)) || + typeof row.revision !== "string" || !BARE_SHA256_PATTERN.test(row.revision) || + !Number.isSafeInteger(row.offset) || Number(row.offset) < 0 || + !Number.isSafeInteger(row.limit) || Number(row.limit) < 1 || Number(row.limit) > 20) { + throw new EffectRuntimeRequestError("Explore result page requires revision, nonnegative offset and limit 1..20; node-filtered pages are unsupported"); + } + const page = {revision: row.revision, offset: row.offset, limit: row.limit}; + const key = JSON.stringify(page); + if (seen.has(key)) throw new EffectRuntimeRequestError("Explore result pages must be distinct"); + seen.add(key); + return page; + }); +} diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 6b7149c5a5..e27bc1d41a 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -5,6 +5,7 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireInteger, requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; import {EXPLORE_WRITEBACK_SUMMARY_LIMIT, exploreResultWritebackAffordance} from "./explore_result_writeback.ts"; +import {normalizeExploreResultPages} from "./explore_result_page.ts"; function rows(value: unknown): JsonObject[] { return Array.isArray(value) ? value.map(item => requireJsonObject(item, "Explore row")) : []; @@ -117,11 +118,23 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { throw new EffectRuntimeRequestError("Continuation requires result_revision from the previous page"); } const visibleResults = resultRows.slice(resultOffset, resultOffset + resultLimit); + // Write-time replay uses this same source snapshot and ordering. The caller + // supplies only page coordinates, never the findings admitted by that page. + const reviewPages = params.review_pages === undefined ? null : normalizeExploreResultPages(params.review_pages).flatMap(page => { + if (!graph || resultNode !== null || page.revision !== resultRevision) { + // A stale query is not an unavailable source. Retain the current bounded + // context/read route; the replan owner refuses any unresolved citation. + return []; + } + return [{...page, finding_refs: resultRows.slice(Number(page.offset), Number(page.offset) + Number(page.limit)) + .map(row => row.finding_id)}]; + }); const remainingResults = Math.max(0, resultRows.length - resultOffset - visibleResults.length); const resultRead = (...args: string[]) => command("turn-context", "--agent-id", agent, ...args); const writeback = exploreResultWritebackAffordance(); return { ok: true, goal_id: goal, agent_id: agent, graph_enabled: graph, harness_enabled: harness, + ...(reviewPages ? {review_pages: reviewPages} : {}), graph: graph ? { counts: projection.counts ?? {}, recent_nodes: nodes.slice(0, 3).map(row => compact(row, ["node_id", "title", "status", "blocked_reason"])), diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index efdfe2f890..42b090892e 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -386,6 +386,12 @@ export function projectReplanContext(value: unknown): JsonObject { if (strideStrategy !== "off") context.stride_strategy = strideStrategy; if (followthrough) context.decision_followthrough = followthrough; if (exploreBasis) context.explore_basis = exploreBasis; + // Source-replayed supplemental pages qualify a proposal, not a new admission + // context. They must not change the original context or bounded basis IDs. + if (exploreBasis?.availability === "available" && request.explore_context != null) { + const source = requireJsonObject(request.explore_context, "Explore stride source"); + if (source.review_pages !== undefined) context.explore_result_pages = source.review_pages; + } if (omitted.length) context.omitted_evidence = { distinct_observation_count: omitted.length, newest_at: omitted[0].row.generated_at, oldest_at: omitted.at(-1)!.first, diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index 334c629ed9..282b649f5e 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -5,6 +5,7 @@ import decisionSchema from "../stride_decision_schema.json" with {type: "json"}; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject, requireNonEmptyString, requireStringLiteral} from "../runtime_decode.ts"; +import {normalizeExploreResultPages} from "../capabilities/explore_result_page.ts"; export function normalizeStrideStrategy(value: unknown): string { if (value === undefined) return schema.default; @@ -133,7 +134,7 @@ export function normalizeStrideDecision(value: unknown, bounded: BoundedText = t } if (input.explore_review != null) { const review = requireJsonObject(input.explore_review, "stride_decision.explore_review"); - exactFields(review, ["revision", "disposition", "considered_refs", "reason"], "explore_review"); + exactFields(review, ["revision", "disposition", "considered_refs", "reason", ...(!historical ? ["result_pages"] : [])], "explore_review"); if (review.revision !== null && !/^[a-f0-9]{64}$/.test(requireNonEmptyString(review.revision, "explore_review.revision"))) throw new EffectRuntimeRequestError("explore_review requires a delivered revision or null when unavailable"); requireStringLiteral(review.disposition, decisionSchema.explore_dispositions, "explore_review.disposition"); const considered = refs(review.considered_refs, "explore_review.considered_refs"); @@ -141,6 +142,14 @@ export function normalizeStrideDecision(value: unknown, bounded: BoundedText = t output.explore_review = {...review, considered_refs: considered, reason: checked("explore_review.reason", review.reason, decisionSchema.review_text_limit)}; for (const key of ["revision", "disposition"]) usage[`stride_decision.explore_review.${key}`] = String(review[key] ?? "").length; usage["stride_decision.explore_review.considered_refs"] = considered.join("").length; + if (Object.hasOwn(review, "result_pages")) { + if (review.disposition === "unavailable" || review.revision === null || !considered.length) { + throw new EffectRuntimeRequestError("Explore result pages require available evidence and considered references"); + } + const pages = normalizeExploreResultPages(review.result_pages); + (output.explore_review as JsonObject).result_pages = pages; + usage["stride_decision.explore_review.result_pages"] = JSON.stringify(pages).length; + } } return [output, usage]; } @@ -214,6 +223,7 @@ export function projectStrideExploreBasis(value: unknown, goal: string, agent: s omitted_finding_refs: Math.max(0, byId.size - 6), result_page: graph?.result_page ?? null, rule: "Use the existing Explore view for scope/applicability, counterevidence and omitted-result reads. " + + "For considered findings outside this list, copy revision/offset/limit from up to three global result pages you read into explore_review.result_pages; writeback replays them against the source. Keep explore_review.revision unchanged. " + "A planner selection or recorded finding does not certify execution, utility, refutation of other inputs, or spawn authority."}; } @@ -241,6 +251,15 @@ export function strideReviewMismatch(decision: JsonObject, context: JsonObject): } if (explore.disposition === "unavailable") return "Available Explore evidence cannot be declared unreadable."; const allowed = new Set((basis.finding_refs as JsonObject[]).map(row => row.finding_id)); + const deliveredPages = Array.isArray(context.explore_result_pages) ? context.explore_result_pages as JsonObject[] : []; + for (const page of (explore.result_pages ?? []) as JsonObject[]) { + const resolved = deliveredPages.find(row => row.revision === page.revision && + row.offset === page.offset && row.limit === page.limit); + if (page.revision !== (basis.result_page as JsonObject | null)?.revision || !resolved) { + return "Explore result pages require current source replay; reread global pages under the original Turn."; + } + for (const ref of resolved.finding_refs as string[]) allowed.add(ref); + } if (considered.some(ref => !allowed.has(ref))) return "Explore review cites an undelivered or foreign finding."; return null; } diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index e77e763c21..444972b126 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1083,7 +1083,9 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): return read_replan_explore_context( source_status={"registry": str(registry_path), "runtime_root": str(runtime_root)}, - goal_id=read_goal_id, agent_id=read_agent_id) + goal_id=read_goal_id, agent_id=read_agent_id, + review_pages=((agent_vision or {}).get("stride_decision") or {}).get( + "explore_review", {}).get("result_pages")) replan_qualification = qualify_refresh_replan_writeback( replan_explore_reader=read_explore_context, diff --git a/tests/control_plane/test_long_chain_projected_closeout.py b/tests/control_plane/test_long_chain_projected_closeout.py index 35f3a0e5f6..9c5e002baf 100644 --- a/tests/control_plane/test_long_chain_projected_closeout.py +++ b/tests/control_plane/test_long_chain_projected_closeout.py @@ -1,6 +1,7 @@ """Follow the long-chain packet through one real, durable CLI Turn (#4667).""" from __future__ import annotations +import hashlib import json import subprocess import sys @@ -69,10 +70,10 @@ def test_managed_turn_counts_advancement_without_monitor_backlog( assert _spend_run_count(runtime) == 0 -@pytest.mark.parametrize("explore_enabled", [False, True]) +@pytest.mark.parametrize("explore_enabled", [False, True, "paged"]) @pytest.mark.parametrize("action", ["switch", "grow"]) def test_projected_vision_replan_settles_without_a_meta_successor( - tmp_path: Path, explore_enabled: bool, action: str, + tmp_path: Path, explore_enabled: bool | str, action: str, ) -> None: project, runtime, registry = _write_fixture(tmp_path) _configure_selected_todo_replan_fixture(project, registry) @@ -102,6 +103,14 @@ def test_projected_vision_replan_settles_without_a_meta_successor( goal_id=GOAL_ID, agent_id=AGENT_ID, node_id="route-counterexample", finding_id="finding-negative", title="Scoped counterexample", status="refuted", summary="This negative result applies to the prior inputs only.")) + if explore_enabled == "paged": + # Same-Goal peer findings are reusable by the reviewing Agent. + for index in range(1, 8): + append_explore_result_event(log, build_explore_finding_event( + goal_id=GOAL_ID, agent_id="research-peer", node_id="route-counterexample", + finding_id=f"finding-{index}", title=f"Scoped peer result {index}", + status="confirmed", summary=f"Applies only to synthetic input {index}.", + tags=["writeback-result"], recorded_at=f"2026-01-01T00:00:0{index}Z")) # Fourteen commitments plus a shared candidate is not this lane's long chain. last_id = "todo_chain_000000000014" source = source.replace( @@ -237,6 +246,20 @@ def guard(turn): "considered_refs": ["finding-negative"], "reason": "Counterexample applies to prior inputs; compare the changed route.", } + if explore_enabled == "paged": + command = basis["result_page"]["next_command"] + page = json.loads(subprocess.run( + [sys.executable, "-m", "loopx.cli", *command[1:]], + cwd=project, capture_output=True, text=True, check=True, + ).stdout)["graph"] + assert [row["finding_id"] for row in page["writeback_results"]] == [ + "finding-4", "finding-3", "finding-2"] + assert "finding-4" not in {row["finding_id"] for row in basis["finding_refs"]} + assert page["result_page"]["revision"] == basis["result_page"]["revision"] + valid_packet["stride_decision"]["explore_review"].update( + disposition="used", considered_refs=["finding-4"], + result_pages=[{key: page["result_page"][key] for key in ("revision", "offset", "limit")}], + ) decision.write_text(json.dumps(valid_packet)) legacy_packet = {key: value for key, value in valid_packet.items() if key != "stride_decision"} decision.write_text(json.dumps(legacy_packet)) @@ -249,6 +272,8 @@ def guard(turn): old_decision = old_packet["stride_decision"] old_decision["schema_version"] = "stride_decision_v1" old_decision.pop("action") + if explore_enabled == "paged": + old_decision["explore_review"].pop("result_pages") old_decision["axis_reasons"] = {key: axis["reason"] for key, axis in old_decision["axes"].items()} old_decision["axes"] = {"work_slice": "shrink", "route_distance": "grow", "validation_scope": "hold"} decision.write_text(json.dumps(old_packet)) @@ -257,6 +282,57 @@ def guard(turn): assert rc == 1 assert "requires agent_vision.stride_decision" in refused["error"] assert _spend_run_count(runtime) == 0 + if explore_enabled == "paged": + def source_files(): + return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for base in (project, runtime / "goals" / GOAL_ID) + for path in base.rglob("*") + if path.is_file() and path.suffix in {".json", ".jsonl", ".md"}} + + def refuse(packet, reason): + decision.write_text(json.dumps(packet)) + unchanged = source_files() + rc, refusal = _run_cli(registry, runtime, + *_projected_cli_args(refresh, turn_instance_id=TURN_ID), cwd=project) + assert rc == 1 and reason in refusal["error"], refusal + assert source_files() == unchanged + assert _spend_run_count(runtime) == 0 + + for mismatch in ("missing-page", "wrong-page", "foreign-finding", "forged-membership"): + invalid = json.loads(json.dumps(valid_packet)) + review = invalid["stride_decision"]["explore_review"] + if mismatch == "missing-page": + review.pop("result_pages") + elif mismatch == "wrong-page": + review["result_pages"][0]["offset"] = 0 + elif mismatch == "foreign-finding": + review["considered_refs"] = ["foreign-finding"] + else: + review["result_pages"][0]["finding_refs"] = ["finding-4"] + refuse(invalid, "result page requires" if mismatch == "forged-membership" else "undelivered") + + # A change outside the first page must invalidate the old page binding. + append_explore_result_event(log, build_explore_finding_event( + goal_id=GOAL_ID, agent_id="research-peer", node_id="route-counterexample", + finding_id="finding-4", title="Scoped peer result 4", status="confirmed", + summary="Revised applicability: synthetic input 4 with the corrected precondition.", + tags=["writeback-result"], recorded_at="2026-01-01T00:00:04Z")) + refuse(valid_packet, "result pages") + current = guard(TURN_ID) + assert current["heartbeat_receipt"]["settlement_identity"] == before["heartbeat_receipt"]["settlement_identity"] + current_control = current["replan_action_packet"]["stride_control"] + assert current_control["reference_obligation_id"] == control["reference_obligation_id"] + valid_packet["stride_decision"]["reference_context_id"] = current_control["reference_context_id"] + valid_packet["stride_decision"]["explore_review"]["revision"] = current_control["explore_basis"]["revision"] + command = current_control["explore_basis"]["result_page"]["next_command"] + unchanged = source_files() + reread = json.loads(subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], + cwd=project, capture_output=True, text=True, check=True).stdout)["graph"] + assert source_files() == unchanged + assert reread["writeback_results"][0]["finding_id"] == "finding-4" + assert "corrected precondition" in reread["writeback_results"][0]["summary"] + valid_packet["stride_decision"]["explore_review"]["result_pages"] = [ + {key: reread["result_page"][key] for key in ("revision", "offset", "limit")}] decision.write_text(json.dumps(valid_packet)) refreshed = call(*_projected_cli_args(refresh, turn_instance_id=TURN_ID)) assert refreshed["vision_checkpoint"]["satisfied"] is True diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 5706271115..66dc6d1a2a 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -92,6 +92,7 @@ const DECLARED_UNFOLDABLE: Record = { const CANONICAL_CONSUMERS = [ "control_plane/agents/supervisor_event_append.ts", "control_plane/capabilities/change_quality_validation.ts", + "control_plane/capabilities/explore_result_page.ts", "control_plane/capabilities/external_evidence.ts", "control_plane/capabilities/goal_capability_organization.ts", "control_plane/collaboration/chat_mode.ts", diff --git a/tests/control_plane_ts/stride_explore_pagination.test.ts b/tests/control_plane_ts/stride_explore_pagination.test.ts new file mode 100644 index 0000000000..adbe539065 --- /dev/null +++ b/tests/control_plane_ts/stride_explore_pagination.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {projectExploreTurnContext} from "../../loopx/control_plane/capabilities/explore_turn_context.ts"; +import {projectReplanContext} from "../../loopx/control_plane/work_items/replan_context.ts"; +import {normalizeStrideDecision, strideControlContract, strideReviewMismatch} from "../../loopx/control_plane/work_items/stride_policy.ts"; +import {projectReplanSemantics} from "../../loopx/control_plane/work_items/replan_semantics.ts"; +import {buildVisionCheckpoint, VISION_REFRESH_REQUEST_SCHEMA} from "../../loopx/control_plane/goals/vision_checkpoint.ts"; + +const goal = "research", agent = "reviewer", obligationId = "replan-" + "a".repeat(16); +const findings = Array.from({length: 7}, (_, i) => ({finding_id: `finding-${7 - i}`, node_id: "route-a", + finding: "Scoped peer result", status: "confirmed", agent_id: "peer", tags: ["writeback-result"], + summary: `Applies only to synthetic input ${7 - i}.`, evidence_refs: [`evidence-${7 - i}`]})); +const input = {goal_id: goal, agent_id: agent, route: ["loopx"], graph_enabled: true, + harness_gate: {enabled: false}, plan: {}, projection: {nodes: [], findings}, include_finding_digests: true}; +const first = projectExploreTurnContext(input); +const page = {revision: ((first.graph as JsonObject).result_page as JsonObject).revision, offset: 3, limit: 3}; +function context(source: JsonObject): JsonObject { + return projectReplanContext({operation: "project", goal_id: goal, agent_id: agent, rows: [], goal_facts: {}, + obligation: {obligation_id: obligationId}, stride_strategy: "evidence_conditioned_v1", explore_context: source}); +} +const original = context(first); +function decision(pages: unknown = [page]): JsonObject { + return {...strideControlContract(original, obligationId)!.minimal_example as JsonObject, + explore_review: {revision: (original.explore_basis as JsonObject).revision, disposition: "used", + considered_refs: ["finding-4"], reason: "The peer observation applies to the proposed comparison.", + result_pages: pages}}; +} +function qualify(ctx: JsonObject, proposal: JsonObject): JsonObject { + const prepared = buildVisionCheckpoint({schema_version: VISION_REFRESH_REQUEST_SCHEMA, phase: "prepare", + goal_id: goal, agent_id: agent, merge_patch: false, existing_agent_vision: null, + require_path_delta_for_durable_change: false, agent_vision_packet: { + schema_version: "goal_vision_replan_contract_v0", agent_id: agent, + vision_patch: {acceptance_summary: "Compare the scoped alternatives."}, stride_decision: proposal, + path_delta: {schema_version: "goal_path_delta_v0", outcome: "replan", prior_assumption: "Route needs comparison.", + observed_reality: "Peer result covers the same inputs.", changed: ["Compare the alternate route."], evidence_refs: ["finding-4"]}}}); + return projectReplanSemantics({operation: "qualify", obligation: {obligation_id: obligationId, + triggers: [{kind: "periodic_review_due"}], replan_context: ctx}, agent_vision: prepared.agent_vision}); +} + +test("source-replayed pagination extends references without changing admission IDs or certifying utility", () => { + const replayed = context(projectExploreTurnContext({...input, review_pages: [page]})); + const {explore_result_pages: pages, ...unchanged} = replayed; + assert.deepEqual(unchanged, original); + assert.deepEqual(pages, [{...page, finding_refs: ["finding-4", "finding-3", "finding-2"]}]); + assert.equal(qualify(original, decision()).accepted, false, "caller-supplied page coordinates are not source proof"); + assert.equal(qualify(replayed, decision()).accepted, true); + assert.equal(qualify(replayed, {...decision(), reference_context_id: "replan-context-" + "b".repeat(24)}).accepted, false); + assert.equal(qualify(replayed, {...decision(), reference_obligation_id: "replan-" + "b".repeat(16)}).accepted, false); + const normalized = normalizeStrideDecision(decision())[0]!; + const withoutPages = {...normalized, explore_review: {...normalized.explore_review as JsonObject}}; + delete (withoutPages.explore_review as JsonObject).result_pages; + assert.match(strideReviewMismatch(withoutPages, replayed)!, /undelivered/); + for (const ref of ["finding-1", "foreign-finding"]) { + assert.equal(qualify(replayed, {...decision(), explore_review: { + ...decision().explore_review as JsonObject, considered_refs: [ref]}}).accepted, false); + } +}); + +test("supplemental citations keep available evidence distinct from unreadable or disabled sources", () => { + const proposal = normalizeStrideDecision(decision())[0]!; + assert.match(strideReviewMismatch(proposal, {} as JsonObject)!, /scoped basis/); + assert.equal(qualify(context({ok: false, goal_id: goal, agent_id: agent}), proposal).accepted, false); + assert.deepEqual(projectExploreTurnContext({...input, graph_enabled: false, review_pages: [page]}).review_pages, []); + const withoutPages = {...proposal, explore_review: {...proposal.explore_review as JsonObject}}; + delete (withoutPages.explore_review as JsonObject).result_pages; + const off = projectReplanContext({operation: "project", goal_id: goal, agent_id: agent, rows: [], + goal_facts: {}, obligation: null, stride_strategy: "off", explore_context: first}); + assert.equal(off.explore_basis, undefined); + assert.equal(off.explore_result_pages, undefined); + const [, before] = normalizeStrideDecision(withoutPages); + const [, after] = normalizeStrideDecision(proposal); + assert.equal(Object.values(after).reduce((a,b) => a+b, 0) - Object.values(before).reduce((a,b) => a+b, 0), + JSON.stringify([page]).length, "pages consume the existing Vision budget"); +}); + +test("page replay detects source drift, different consumers and node-filtered snapshots", () => { + for (const patch of [ + {goal_id: "other-goal"}, {agent_id: "other-reader"}, {result_node: "route-a"}, + {projection: {nodes: [], findings: findings.map(row => row.finding_id === "finding-4" + ? {...row, summary: "Different scope despite unchanged ID, title and status."} : row)}}, + {projection: {nodes: [], findings: [...findings, {...findings[0], finding_id: "older-result"}]}}, + ]) assert.deepEqual(projectExploreTurnContext({...input, ...patch, review_pages: [page]}).review_pages, []); + const stale = context(projectExploreTurnContext({...input, review_pages: [{...page, revision: "0".repeat(64)}]})); + assert.equal((stale.explore_basis as JsonObject).availability, "available"); + assert.equal(qualify(stale, decision([{...page, revision: "0".repeat(64)}])).accepted, false); + assert.equal(stale.context_id, original.context_id, "a bad query does not change the delivered source"); + assert.deepEqual(projectExploreTurnContext({...input, review_pages: [page]}).review_pages, + projectExploreTurnContext({...input, review_pages: [page], projection: {...input.projection, + nodes: [{node_id: "unrelated", title: "Unrelated node"}]}}).review_pages); + const expanded = {...page, offset: 0, limit: 7}; + assert.equal(qualify(context(projectExploreTurnContext({...input, review_pages: [expanded]})), decision([expanded])).accepted, true); +}); + +test("page descriptors reject forged membership, malformed bounds and unbounded metadata", () => { + for (const pages of [null, [], [page, page], [page, {...page, offset: 0}, {...page, offset: 6}, {...page, offset: 9}], + [{...page, revision: [page.revision]}], [{...page, revision: "invalid"}], [{...page, offset: -1}], + [{...page, offset: 0.5}], [{...page, offset: Number.MAX_SAFE_INTEGER + 1}], + [{...page, offset: "3"}], [{...page, limit: true}], [{...page, limit: 0}], [{...page, limit: 21}], + [{...page, finding_refs: ["foreign-finding"]}], [{...page, node_id: "route-a"}]]) { + assert.throws(() => normalizeStrideDecision(decision(pages))); + assert.throws(() => projectExploreTurnContext({...input, review_pages: pages})); + } + for (const patch of [{revision: null}, {disposition: "unavailable"}, {considered_refs: []}]) { + assert.throws(() => normalizeStrideDecision({...decision(), explore_review: { + ...decision().explore_review as JsonObject, ...patch}})); + } +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index abc9ac3f10..4ac6719842 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -169,6 +169,7 @@ "tests/control_plane_ts/turn_journal_effects.test.ts", "tests/control_plane_ts/chat_turn_acceptance.test.ts", "tests/control_plane_ts/vision_checkpoint.test.ts", + "tests/control_plane_ts/stride_explore_pagination.test.ts", "tests/control_plane_ts/checkpoint_read_context.test.ts", "tests/control_plane_ts/checkpoint_provider_head.test.ts", "tests/control_plane_ts/checkpoint_commit_probe.ts",