From f4e233736d0c3d6fb372503e525a12d109dd14af Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:59:32 +0800 Subject: [PATCH 1/2] fix(explore): keep paged outcomes available for method selection Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/explore/README.md | 7 ++ loopx/capabilities/explore/README.zh-CN.md | 5 ++ .../capabilities/explore_result_page.ts | 4 +- .../capabilities/explore_turn_context.ts | 8 +- .../control_plane/work_items/stride_policy.ts | 6 +- ...est_stride_comparison_followthrough_cli.py | 82 +++++++++++++++++++ .../explore_turn_context.test.ts | 51 ++++++++++++ 7 files changed, 156 insertions(+), 7 deletions(-) diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 61b0c5c2ad..77206ca021 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -698,6 +698,13 @@ 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. +Each comparison card retains every linked outcome on that result page, up to +the same 20-result bound. Expanding `--result-limit` therefore also makes the +fourth and later displayed outcomes available to `selected_candidate`; writeback +replays the same page and exact source digest. The default three-result view +and the separate number-of-cards limit remain unchanged. A larger page grants +no execution permission and does not certify the quality of a selection. + 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/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 58023d9449..92ca6c1c2b 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -200,6 +200,11 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次 合法写回。变化只涉及启用后的证据捕获及显式 Todo 关联编辑;普通工作与 能力关闭时的 hook 行为保持不变。完整附件与作用域规则见[英文契约](README.md#results-from-ordinary-work-writeback)。 +每张 comparison card 保留该结果页中全部已关联结论,复用最多20条的分页上限。 +显式扩大 `--result-limit` 后,第4条及之后显示的结论也可绑定到 +`selected_candidate`;写回仍重放相同分页并核对精确来源摘要。默认三条视图与 +独立的卡片数量上限保持不变。较大的结果页不授予执行权限,也不证明选题质量。 + ## 可选 Todo 分支规划 `loopx explore todo-branch-plan` 是面向探索目标的窄 opt-in harness:一次尝试多个看起来都合理的下一个 todo。它使用 CPU 分支预测类比 + DSpark 启发调度器:对 open agent todos 排序,估计分支置信度与预期证据单位,选择置信度调度的验证前缀,选一个 `primary` 分支加安全的 `speculative` 分支,拒绝声明写作用域与已选分支重叠的分支。 diff --git a/loopx/control_plane/capabilities/explore_result_page.ts b/loopx/control_plane/capabilities/explore_result_page.ts index 3d2973cec6..4e626a2a8e 100644 --- a/loopx/control_plane/capabilities/explore_result_page.ts +++ b/loopx/control_plane/capabilities/explore_result_page.ts @@ -4,6 +4,8 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +export const EXPLORE_RESULT_PAGE_MAX = 20; + 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"); @@ -14,7 +16,7 @@ export function normalizeExploreResultPages(value: unknown): JsonObject[] { 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) { + !Number.isSafeInteger(row.limit) || Number(row.limit) < 1 || Number(row.limit) > EXPLORE_RESULT_PAGE_MAX) { 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}; diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 9922104033..7b66813b50 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -6,7 +6,7 @@ import {requireInteger, requireJsonObject, requireNonEmptyString, requireStringA import {PLANNING_TODO_ID} from "../work_items/planning_relations.ts"; import {EXPLORE_WRITEBACK_SUMMARY_LIMIT, exploreResultWritebackAffordance} from "./explore_result_writeback.ts"; -import {normalizeExploreResultPages} from "./explore_result_page.ts"; +import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "./explore_result_page.ts"; import {projectExploreMeasurementFeedback} from "./explore_measurements.ts"; function rows(value: unknown): JsonObject[] { @@ -112,7 +112,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal]; const resultLimit = requireInteger(params.result_limit ?? 3, "result_limit"); const resultOffset = requireInteger(params.result_offset ?? 0, "result_offset"); - if (resultLimit < 1 || resultLimit > 20 || resultOffset < 0) { + if (resultLimit < 1 || resultLimit > EXPLORE_RESULT_PAGE_MAX || resultOffset < 0) { throw new EffectRuntimeRequestError("Use result_limit 1..20 and a nonnegative result_offset"); } const resultNode = params.result_node == null ? null : requireNonEmptyString(params.result_node, "result_node"); @@ -134,7 +134,9 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { const audit = requireJsonObject(branch.typed_evidence_audit, "Explore evidence audit"); const linked = new Set(requireStringArray(audit.requested_node_refs ?? [], "Explore node refs") .filter(ref => nodeById.has(ref))); - const outcomes = page.filter(row => linked.has(String(row.node_id))).slice(0, 3); + // The explicit page already bounds these results. A second three-row + // cap would display findings that the same page cannot bind at writeback. + const outcomes = page.filter(row => linked.has(String(row.node_id))); return outcomes.length ? [{todo_id: branch.todo_id, outcomes}] : []; }); if (!linkedCandidates.length) return {}; diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index 622e8364b2..45ad761290 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -6,7 +6,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"; +import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "../capabilities/explore_result_page.ts"; import {PLANNING_TODO_ID} from "./planning_relations.ts"; export function normalizeStrideStrategy(value: unknown): string { @@ -108,8 +108,8 @@ function comparisonCandidates(value: unknown): JsonObject[] { return value.map(value => { const card = requireJsonObject(value, "comparison candidate"); exactFields(card, ["todo_id", "outcomes"], "comparison candidate"); - if (!Array.isArray(card.outcomes) || !card.outcomes.length || card.outcomes.length > 3) { - throw new EffectRuntimeRequestError("comparison candidate requires at most three scoped outcomes"); + if (!Array.isArray(card.outcomes) || !card.outcomes.length || card.outcomes.length > EXPLORE_RESULT_PAGE_MAX) { + throw new EffectRuntimeRequestError("comparison candidate requires one to twenty scoped outcomes from its result page"); } const outcomes = card.outcomes.map(value => { const outcome = requireJsonObject(value, "comparison outcome"); diff --git a/tests/control_plane/test_stride_comparison_followthrough_cli.py b/tests/control_plane/test_stride_comparison_followthrough_cli.py index 9ea2bde6f7..2b29c304ad 100644 --- a/tests/control_plane/test_stride_comparison_followthrough_cli.py +++ b/tests/control_plane/test_stride_comparison_followthrough_cli.py @@ -147,3 +147,85 @@ def review(value, turn_id=turn, *, expected=0): refused = review(next_packet, next_turn, expected=1) assert "declared work" in refused["error"] and not refused.get("appended"), refused assert index.read_bytes() == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_wide_result_page_replays_every_linked_outcome_at_writeback(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + monkeypatch.setenv("PYTHONPATH", str(fixture.REPO_ROOT)) + project, runtime, registry = fixture._write_fixture(tmp_path) + monkeypatch.chdir(project) + fixture._initialize_fixture_authority(project, runtime, provider=provider) + + def cli(*args, expected=0): + code, result = fixture._run_cli(registry, runtime, *args, cwd=project) + assert code == expected, result + return result + + identity = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID) + cli("configure-goal", "--goal-id", fixture.GOAL_ID, "--explore-mode", "planning", + "--execution-replan-after-turns", "1", "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + training = project / "training.json" + training.write_text(json.dumps([{"x": 2, "y": 3, "observed": 6}])) + request = {"project": str(project), "registry": str(registry), "runtime": str(runtime), + "goal_id": fixture.GOAL_ID, "agent_id": fixture.AGENT_ID, "todo_id": fixture.TODO_ID, + "objective": "Use the exact scoped measurement that informs the next experiment.", + "training": str(training), "budget": 25, "seed": 17} + actor.probe(request, "retained") + for index in range(4): + measurements = project / f"measurement-{index}.json" + measurements.write_text(json.dumps([{"metric": "quality", "value": index, + "unit": "points", "higher_is_better": True, + "comparison_scope": "validation:owned-v1", "input_revision": "owned-input-v1", + "evaluation_ref": "evaluator:owned-v1", "candidate_ref": f"candidate:scope-{index}", + "evidence_ref": f"measured:scope-{index}"}])) + cli("explore", "finding", *identity, "--finding-id", f"wide-result-{index}", + "--node", "comparison-retained", "--title", f"Observed input scope {index}", + "--summary", f"Only the recorded input scope {index}; other inputs remain unknown.", + "--status", "confirmed", "--tag", "writeback-result", "--evidence-ref", f"measured:scope-{index}", + "--measurements-json", str(measurements)) + turn = "wide-page-review" + cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project), "--turn-instance-id", turn) + guard = cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project), + "--turn-instance-id", turn, "--todo-id", fixture.TODO_ID) + control = guard["replan_action_packet"]["stride_control"] + page = cli("explore", "turn-context", *identity, "--result-limit", "20") + [card] = [row for row in page["harness"]["comparison_candidates"] if row["todo_id"] == fixture.TODO_ID] + assert len(page["graph"]["writeback_results"]) == len(card["outcomes"]) == 5 + # Choose a quantified outcome omitted by the default three-row view. + visible = {row["finding_id"]: row for row in page["graph"]["writeback_results"]} + default_refs = {row["finding_id"] for row in control["explore_basis"]["comparison_candidates"][0]["outcomes"]} + selected = {"todo_id": fixture.TODO_ID, **next(row for row in reversed(card["outcomes"]) + if row["finding_id"] not in default_refs and visible[row["finding_id"]].get("measurements"))} + measurement = visible[selected["finding_id"]]["measurements"][0] + assert measurement["evidence_ref"] == visible[selected["finding_id"]]["evidence_refs"][0] + [feedback] = page["graph"]["measurement_feedback"] + assert feedback["measured_count"] == 4 + assert feedback["best"]["candidate_ref"] == "candidate:scope-3" + decision = deepcopy(control["minimal_example"]) + decision["explore_review"] = {"revision": control["explore_basis"]["revision"], + "disposition": "used", "considered_refs": [selected["finding_id"]], + "selected_candidate": selected, "reason": "This exact measured scope informs the next experiment.", + "result_pages": [{k: page["graph"]["result_page"][k] for k in ("revision", "offset", "limit")}]} + packet = actor.vision(request, selected["finding_id"], outcome="replan") + packet.update(stride_decision=decision, todo_delta=["retain:" + fixture.TODO_ID]) + path = project / "wide-review.json" + command = ("refresh-state", *identity, "--todo-id", fixture.TODO_ID, "--turn-instance-id", turn, + "--classification", "scoped_page_review", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--agent-vision-json", str(path), + "--no-global-sync", "--suppress-external-sinks") + bad = deepcopy(packet) + bad["stride_decision"]["explore_review"]["selected_candidate"]["source_sha256"] = "f" * 64 + path.write_text(json.dumps(bad)) + index = runtime / "goals" / fixture.GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + refused = cli(*command, expected=1) + assert "exact scoped result" in refused["error"] and index.read_bytes() == before + path.write_text(json.dumps(packet)) + accepted = cli(*command) + persisted = json.loads(Path(accepted["json_path"]).read_text()) + assert persisted["agent_vision"]["stride_decision"]["explore_review"]["selected_candidate"] == selected + assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] + readback = cli("explore", "turn-context", *identity, "--result-limit", "20") + assert readback["graph"]["measurement_feedback"] == [feedback] diff --git a/tests/control_plane_ts/explore_turn_context.test.ts b/tests/control_plane_ts/explore_turn_context.test.ts index 620e7c8b4e..bfe70f84f0 100644 --- a/tests/control_plane_ts/explore_turn_context.test.ts +++ b/tests/control_plane_ts/explore_turn_context.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {projectStrideExploreBasis, strideReviewMismatch} from "../../loopx/control_plane/work_items/stride_policy.ts"; import {projectExploreTurnContext} from "../../loopx/control_plane/capabilities/explore_turn_context.ts"; test("next-turn evidence exposes current question state and relation changes invalidate old pages", () => { @@ -80,3 +81,53 @@ for (const includeDigests of [false, true]) { } }); } + +for (const limit of [3, 4, 20]) { + test(`every scoped outcome on an explicitly requested page remains selectable: ${limit}`, () => { + const findings = Array.from({length: 20}, (_, index) => ({finding_id: `scoped-${index}`, + node_id: "question", finding: `Measured input family ${index}`, status: "confirmed", + summary: `Applicable to input family ${index} only.`, tags: ["writeback-result"], + evidence_refs: [`result:scoped-${index}`], + measurements: [{metric: "quality", value: index, unit: "points", higher_is_better: true, + comparison_scope: "validation:owned-v1", input_revision: "owned-input-v1", + evaluation_ref: "evaluator:owned-v1", candidate_ref: `candidate:scoped-${index}`, + evidence_ref: `result:scoped-${index}`}]})); + const request = {goal_id: "research", agent_id: "worker", route: ["loopx"], + graph_enabled: true, harness_gate: {enabled: true}, result_limit: limit, + projection: {nodes: [{node_id: "question", status: "exploring"}], findings}, + plan: {selected_branches: [{todo_id: "todo_method", actionable_open: true, + typed_evidence_audit: {requested_node_refs: ["question"]}}]}}; + const source = projectExploreTurnContext(request); + const graph = source.graph as JsonObject, harness = source.harness as JsonObject; + const card = harness.comparison_candidates[0]; + // Quantitative feedback still uses all sources, independently of page size. + assert.equal(graph.measurement_feedback[0].measured_count, 20); + assert.equal(graph.measurement_feedback[0].best.value, 19); + assert.equal(graph.measurement_feedback[0].best.candidate_ref, "candidate:scoped-19"); + // The result page and candidate card must describe the same read scope. + assert.equal(graph.writeback_results.length, limit); + assert.deepEqual(card.outcomes.map((row: JsonObject) => row.finding_id), + findings.slice(0, limit).map(row => row.finding_id)); + const last = findings[limit - 1]; + const selected = {todo_id: "todo_method", node_id: last.node_id, finding_id: last.finding_id, + source_sha256: createHash("sha256").update(JSON.stringify(last)).digest("hex")}; + const basis = projectStrideExploreBasis(source, "research", "worker")!; + const decision = {explore_review: {revision: basis.revision, disposition: "used", + considered_refs: [last.finding_id], selected_candidate: selected}}; + assert.equal(strideReviewMismatch(decision, {explore_basis: basis}, undefined, ["retain:todo_method"]), null); + assert.match(String(strideReviewMismatch({explore_review: {...decision.explore_review, + selected_candidate: {...selected, source_sha256: "f".repeat(64)}}}, + {explore_basis: basis}, undefined, ["retain:todo_method"])), /exact scoped result/); + assert.match(String(strideReviewMismatch(decision, {explore_basis: basis}, undefined, [])), /retained/); + // Page replay must expose exactly the same choices at ordinary writeback. + const page = {revision: graph.result_page.revision, offset: 0, limit}; + const replay = projectExploreTurnContext({...request, review_pages: [page]}); + assert.deepEqual(replay.review_pages[0].comparison_candidates, harness.comparison_candidates); + const tooMany = {...source, harness: {...harness, comparison_candidates: [ + {...card, outcomes: Array.from({length: 21}, () => card.outcomes[0])}]}}; + assert.throws(() => projectStrideExploreBasis(tooMany, "research", "worker"), /scoped outcomes/); + const evidenceOnly = projectExploreTurnContext({...request, harness_gate: {enabled: false}}); + assert.equal(evidenceOnly.harness, null); + assert.equal(Object.hasOwn(evidenceOnly.graph, "comparison_candidates"), false); + }); +} From b228bdd08a42053f22fb70aaa3b51f984aa849bc Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:51:13 +0800 Subject: [PATCH 2/2] docs(explore): align comparison selection with expanded result pages Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../protocols/goal-vision-replan-contract-v0.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 1c86f45b5f..de35e5dea1 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -692,8 +692,10 @@ not improved model decisions or net efficiency. With Explore planning and `evidence_conditioned_v1` enabled, the existing turn-context result page can include `comparison_candidates`. Each card links -an actionable, own-or-unclaimed canonical Todo to up to three current results -through that Todo's explicit Explore node references. An outcome contains +an actionable, own-or-unclaimed canonical Todo to its current results on that +page through explicit Explore node references. The default page has three +results; an explicitly expanded page retains every linked result, up to the +existing 20-result limit. An outcome contains `node_id`, `finding_id`, and `source_sha256`, the digest of the full scoped result. These links survive into `stride_control.explore_basis`; an Agent can compare the actual result contents before planning further work. Planner rank @@ -769,7 +771,8 @@ beside linked Todo state. 错源、已变化的结果或没有保留对应工作均不能借别的后继结果混过核验。上述已启用 的周期或停滞复核均不能用单独的后继 ACK 代替当前决策;其他义务和关闭策略时 保留原出口边界,后继 ACK 本身也不证明比较选择已被接受或执行。分页仍沿用 -既有坐标与最多 20 项的上限,同一结果关联过多 Todo 时明确显示省略数,不能声称 +既有坐标与最多 20 项的上限;默认页为三条,显式扩大后每张卡片保留该页全部关联 +结果。同一结果关联过多 Todo 时明确显示省略数,不能声称 无限候选均可达。这是有来源的规划意图,不是调度命令;合法 B 任务可以执行, 但不能凭 B 的结果声称所选 A 已执行。实际采用、机制差异和原验收仍需真实证据。 受控实验比较停留原式、局部调参与结构语法搜索,评分调用预算相同;它证明原