From d80ae34299a2afc2d03327afbdb41c6b91276633 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:25:00 +0800 Subject: [PATCH] fix(explore): preserve current-task evidence order across planner links 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_turn_context.ts | 12 +++-- .../control_plane/test_explore_task_scope.py | 51 +++++++++++++++++++ .../explore_turn_context.test.ts | 40 +++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 2f1fc9107c..939fa3cd0b 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -694,6 +694,13 @@ or quota, and neither mode is enabled by supplying a task id. The dashboard's saved-evidence reader also pages through history; the CLI options above control the agent context, not dashboard page size. +Within the current task's linked questions, results retain source recency after +the reserved counterexamples; measurement groups retain their own append-order +recency. A question shared with a different planned task gets no extra priority. +Planner ordering applies only outside the current task's links. When diagnosing +buried evidence, compare the ordinary task page with the node-filtered read: +successful lookup alone does not show that the next agent saw or used it. + 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 diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index a0fc64a7c0..155c687a40 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -211,6 +211,11 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次 每个相关问题仍优先保留一条范围反证;其它结果继续分页可读,缺少关联不代表没有证据。 该读取不启用能力、不修改规划分数、任务准入、claim/lease、配额或图谱。 +当前任务关联的问题内部,结果在保留范围反证后按原来源新旧排序,量化组保持自身的 +追加顺序;某个问题同时关联另一条计划任务,不会因此获得额外优先级。规划器排序只 +适用于当前任务关联之外的结果。排查证据被埋没时,应比较普通任务页与按问题筛选的 +读取;查询成功本身不证明下一轮 agent 已看到或采用该证据。 + 任务范围的 `result_page` 多携带 `todo_id`,后续命令也保留它。引用该页时,将它和 `revision/offset/limit` 一同复制到原 `explore_review.result_pages`;主 review revision 仍引用原有全局 basis。写回从同一可信来源重放任务关联与精确页面,不替换已准入的 diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 290821ca7a..e8da7ee22e 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -61,7 +61,7 @@ function branchContext(row: JsonObject, findingSources?: Map } /** Keep the latest scoped counterexample per linked question before its other * findings; recency alone must not supersede evidence on different inputs. */ -function prioritizeLinkedResults(results: JsonObject[], refs: Set): JsonObject[] { +function prioritizeLinkedResults(results: JsonObject[], refs: Set, fallbackResults = results): JsonObject[] { const linked = results.filter(row => refs.has(String(row.node_id ?? ""))); const refuted = new Set(); const counterexamples = linked.filter(row => { @@ -72,7 +72,7 @@ function prioritizeLinkedResults(results: JsonObject[], refs: Set): Json }); const reserved = new Set(counterexamples); return [...counterexamples, ...linked.filter(row => !reserved.has(row)), - ...results.filter(row => !refs.has(String(row.node_id ?? "")))]; + ...fallbackResults.filter(row => !refs.has(String(row.node_id ?? "")))]; } export function projectExploreTurnContext(params: JsonObject): JsonObject { const goal = requireNonEmptyString(params.goal_id, "goal_id"); @@ -118,7 +118,10 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { // Explicitly bound work can differ from this independent planner. Prioritize // its canonical links without changing candidates, scores or admission. const plannerResults = prioritizeLinkedResults(attachedResults, selectedRefs); - const writebackResults = focusedRefs.size ? prioritizeLinkedResults(plannerResults, focusedRefs) : plannerResults; + // Shared links must not inherit another task's planner priority. Use source + // recency within the focus and retain planner ordering only outside it. + const writebackResults = focusedRefs.size + ? prioritizeLinkedResults(attachedResults, focusedRefs, plannerResults) : plannerResults; 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"); @@ -129,7 +132,8 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { const measurementFeedback = projectExploreMeasurementFeedback(findings) .filter(item => resultNode === null || item.node_id === resultNode) .sort((a, b) => Number(focusedRefs.has(b.node_id)) - Number(focusedRefs.has(a.node_id)) - || Number(selectedRefs.has(b.node_id)) - Number(selectedRefs.has(a.node_id))); + || (focusedRefs.has(a.node_id) ? 0 + : Number(selectedRefs.has(b.node_id)) - Number(selectedRefs.has(a.node_id)))); const resultRows = resultNode == null ? writebackResults : writebackResults.filter(row => row.node_id === resultNode); const nodeById = new Map(nodes.map(node => [node.node_id, node])); // Bind current Todo links to the results on this page. Include the full diff --git a/tests/control_plane/test_explore_task_scope.py b/tests/control_plane/test_explore_task_scope.py index 908b28b28b..a14f56ff87 100644 --- a/tests/control_plane/test_explore_task_scope.py +++ b/tests/control_plane/test_explore_task_scope.py @@ -85,3 +85,54 @@ def cli(*args, expected=0): stale = cli(*next_args[next_args.index("explore"):], expected=1) assert "changed" in stale["error"] and log.read_bytes() == source cli("explore", "turn-context", *identity, "--todo-id", "todo_missing", expected=1) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_shared_planner_question_does_not_bury_new_current_task_evidence(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + project, runtime, registry = fixture._write_fixture(tmp_path) + monkeypatch.chdir(project) + fixture._initialize_fixture_authority(project, runtime, provider=provider) + + def cli(*args): + code, packet = fixture._run_cli(registry, runtime, *args, cwd=project) + assert code == 0, json.dumps(packet) + return packet + + identity = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID) + cli("configure-goal", "--goal-id", fixture.GOAL_ID, "--explore-mode", "planning", "--execute") + cli("todo", "update", *identity, "--todo-id", fixture.TODO_ID, "--priority", "P2", + "--explore-result-node-ref", "current", "--explore-result-node-ref", "shared") + for i in range(4): + other = cli("todo", "add", "--goal-id", fixture.GOAL_ID, "--role", "agent", "--priority", "P1", + "--claimed-by", fixture.AGENT_ID, "--text", f"Validate separate method {i}.", + "--task-class", "advancement_task", "--action-kind", "validate", "--target-key", f"other-{i}") + cli("todo", "update", *identity, "--todo-id", other["todo_id"], "--explore-result-node-ref", "shared") + log = explore_result_log_path(runtime, fixture.GOAL_ID) + for node in ["current", "shared"]: + append_explore_result_event(log, build_explore_node_event( + goal_id=fixture.GOAL_ID, agent_id=fixture.AGENT_ID, node_id=node, + title=node, node_kind="question", status="exploring")) + for i, node in enumerate(["shared", "shared", "current"]): + evidence = f"result:observation-{i}" + append_explore_result_event(log, build_explore_finding_event( + goal_id=fixture.GOAL_ID, agent_id=fixture.AGENT_ID, node_id=node, + finding_id=f"observation-{i}", title=f"Observation {i}", status="confirmed", + summary="Applicable to the named input only.", evidence_refs=[evidence], + tags=["writeback-result"], recorded_at=f"2026-01-0{i + 1}T00:00:00Z", + measurements=[dict(metric="quality", value=i, unit="points", higher_is_better=True, + comparison_scope=f"validation:{node}", input_revision="inputs-v1", + evaluation_ref="evaluator:v1", candidate_ref=f"candidate:{i}", evidence_ref=evidence)])) + original_log = log.read_bytes() + unscoped = cli("explore", "turn-context", *identity, "--result-limit", "1") + assert unscoped["graph"]["writeback_results"][0]["node_id"] == "shared" + scoped = cli("explore", "turn-context", *identity, "--todo-id", fixture.TODO_ID, "--result-limit", "1") + assert scoped["graph"]["writeback_results"][0]["finding_id"] == "observation-2" + assert scoped["graph"]["measurement_feedback"][0]["node_id"] == "current" + assert scoped["harness"]["selected_branches"] == unscoped["harness"]["selected_branches"] + next_args = scoped["graph"]["result_page"]["next_command"] + after = cli(*next_args[next_args.index("explore"):]) + assert after["graph"]["writeback_results"][0]["finding_id"] == "observation-1" + assert after["graph"]["measurement_feedback"][0]["node_id"] == "shared" + assert log.read_bytes() == original_log diff --git a/tests/control_plane_ts/explore_turn_context.test.ts b/tests/control_plane_ts/explore_turn_context.test.ts index fd071588ba..f49f500ed3 100644 --- a/tests/control_plane_ts/explore_turn_context.test.ts +++ b/tests/control_plane_ts/explore_turn_context.test.ts @@ -190,3 +190,43 @@ test("task focus rejects missing, foreign and terminal sources and is inert when const off = {...request, graph_enabled: false}; assert.deepEqual(projectExploreTurnContext(off), projectExploreTurnContext({...off, todo_id: null})); }); + +for (const counterexample of [false, true]) { + for (const field of ["writeback_results", "measurement_feedback"]) { + test(`overlapping planner links cannot reorder current task ${field}, refutation=${counterexample}`, () => { + const finding = (id: string, node: string, status = "confirmed") => ({finding_id: id, + node_id: node, status, finding: id, tags: ["writeback-result"], + measurements: [{metric: "quality", value: 5, unit: "points", higher_is_better: true, + comparison_scope: `validation:${node}`, input_revision: "inputs-v1", evaluation_ref: "eval:v1", + candidate_ref: `candidate:${id}`, evidence_ref: `result:${id}`} ]}); + const findings = [finding("current-new", "current"), finding("shared-old", "shared"), + ...(counterexample ? [finding("current-negative", "current", "refuted")] : []), + finding("planner-only", "other"), finding("unrelated", "unrelated")]; + const request = {goal_id: "research", agent_id: "worker", route: ["loopx"], graph_enabled: true, + harness_gate: {enabled: true}, result_limit: 1, + projection: {nodes: [], findings}, todo_id: "todo_current", + focus_todo: {todo_id: "todo_current", role: "agent", status: "open", claimed_by: "worker", + explore_result_node_refs: ["current", "shared"]}}; + const expected = field === "measurement_feedback" ? ["current", "shared", "other", "unrelated"] + : [...(counterexample ? ["current-negative"] : []), "current-new", "shared-old", "planner-only", "unrelated"]; + for (const plannedRefs of [["other"], ["shared", "other"]]) { + const params = {...request, plan: {selected_branches: [{todo_id: "todo_planned", actionable_open: true, + typed_evidence_audit: {requested_node_refs: plannedRefs}}]}}; + const first = projectExploreTurnContext(params); + const all = projectExploreTurnContext({...params, result_limit: 20}); + const values = (graph: JsonObject) => graph[field].map((row: JsonObject) => + field === "measurement_feedback" ? row.node_id : row.finding_id); + assert.deepEqual(values(first.graph as JsonObject), expected.slice(0, 1)); + assert.deepEqual(values(all.graph as JsonObject), expected); + assert.deepEqual((first.harness as JsonObject).selected_branches, + (projectExploreTurnContext({...params, todo_id: null}).harness as JsonObject).selected_branches); + if (field === "writeback_results") { + const page = {todo_id: request.todo_id, revision: (first.graph as JsonObject).result_page.revision, + offset: 1, limit: 1}; + const replay = projectExploreTurnContext({...params, review_pages: [page]}); + assert.deepEqual(replay.review_pages[0].finding_refs, expected.slice(1, 2)); + } + } + }); + } +}