Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions loopx/capabilities/explore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions loopx/capabilities/explore/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次
每个相关问题仍优先保留一条范围反证;其它结果继续分页可读,缺少关联不代表没有证据。
该读取不启用能力、不修改规划分数、任务准入、claim/lease、配额或图谱。

当前任务关联的问题内部,结果在保留范围反证后按原来源新旧排序,量化组保持自身的
追加顺序;某个问题同时关联另一条计划任务,不会因此获得额外优先级。规划器排序只
适用于当前任务关联之外的结果。排查证据被埋没时,应比较普通任务页与按问题筛选的
读取;查询成功本身不证明下一轮 agent 已看到或采用该证据。

任务范围的 `result_page` 多携带 `todo_id`,后续命令也保留它。引用该页时,将它和
`revision/offset/limit` 一同复制到原 `explore_review.result_pages`;主 review revision
仍引用原有全局 basis。写回从同一可信来源重放任务关联与精确页面,不替换已准入的
Expand Down
12 changes: 8 additions & 4 deletions loopx/control_plane/capabilities/explore_turn_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function branchContext(row: JsonObject, findingSources?: Map<string, JsonObject>
}
/** 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<string>): JsonObject[] {
function prioritizeLinkedResults(results: JsonObject[], refs: Set<string>, fallbackResults = results): JsonObject[] {
const linked = results.filter(row => refs.has(String(row.node_id ?? "")));
const refuted = new Set<string>();
const counterexamples = linked.filter(row => {
Expand All @@ -72,7 +72,7 @@ function prioritizeLinkedResults(results: JsonObject[], refs: Set<string>): 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");
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/control_plane/test_explore_task_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions tests/control_plane_ts/explore_turn_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
});
}
}
Loading