Skip to content

Commit f182500

Browse files
authored
fix(explore): retain selectable outcomes on expanded result pages (#6982)
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent d566521 commit f182500

8 files changed

Lines changed: 162 additions & 10 deletions

File tree

‎docs/reference/protocols/goal-vision-replan-contract-v0.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,8 +692,10 @@ not improved model decisions or net efficiency.
692692

693693
With Explore planning and `evidence_conditioned_v1` enabled, the existing
694694
turn-context result page can include `comparison_candidates`. Each card links
695-
an actionable, own-or-unclaimed canonical Todo to up to three current results
696-
through that Todo's explicit Explore node references. An outcome contains
695+
an actionable, own-or-unclaimed canonical Todo to its current results on that
696+
page through explicit Explore node references. The default page has three
697+
results; an explicitly expanded page retains every linked result, up to the
698+
existing 20-result limit. An outcome contains
697699
`node_id`, `finding_id`, and `source_sha256`, the digest of the full scoped
698700
result. These links survive into `stride_control.explore_basis`; an Agent can
699701
compare the actual result contents before planning further work. Planner rank
@@ -769,7 +771,8 @@ beside linked Todo state.
769771
错源、已变化的结果或没有保留对应工作均不能借别的后继结果混过核验。上述已启用
770772
的周期或停滞复核均不能用单独的后继 ACK 代替当前决策;其他义务和关闭策略时
771773
保留原出口边界,后继 ACK 本身也不证明比较选择已被接受或执行。分页仍沿用
772-
既有坐标与最多 20 项的上限,同一结果关联过多 Todo 时明确显示省略数,不能声称
774+
既有坐标与最多 20 项的上限;默认页为三条,显式扩大后每张卡片保留该页全部关联
775+
结果。同一结果关联过多 Todo 时明确显示省略数,不能声称
773776
无限候选均可达。这是有来源的规划意图,不是调度命令;合法 B 任务可以执行,
774777
但不能凭 B 的结果声称所选 A 已执行。实际采用、机制差异和原验收仍需真实证据。
775778
受控实验比较停留原式、局部调参与结构语法搜索,评分调用预算相同;它证明原

‎loopx/capabilities/explore/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,13 @@ permission. Node-filtered and planner-detail reads are not supported by this
698698
citation path. See the [stride contract](../../../docs/reference/protocols/goal-vision-replan-contract-v0.md#structured-stride-control)
699699
for the existing decision, budget and original-Turn recovery rules.
700700

701+
Each comparison card retains every linked outcome on that result page, up to
702+
the same 20-result bound. Expanding `--result-limit` therefore also makes the
703+
fourth and later displayed outcomes available to `selected_candidate`; writeback
704+
replays the same page and exact source digest. The default three-result view
705+
and the separate number-of-cards limit remain unchanged. A larger page grants
706+
no execution permission and does not certify the quality of a selection.
707+
701708
Planning context also keeps a bounded `typed_evidence_audit` on suggested
702709
branches with explicit Todo/node links, and up to three existing exploring
703710
frontier nodes. Linked findings are resolved before the recent-history limit,

‎loopx/capabilities/explore/README.zh-CN.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ Todo 关联保留既有可恢复边界与 claim/lease 证明;恢复旧批次
200200
合法写回。变化只涉及启用后的证据捕获及显式 Todo 关联编辑;普通工作与
201201
能力关闭时的 hook 行为保持不变。完整附件与作用域规则见[英文契约](README.md#results-from-ordinary-work-writeback)。
202202

203+
每张 comparison card 保留该结果页中全部已关联结论,复用最多20条的分页上限。
204+
显式扩大 `--result-limit` 后,第4条及之后显示的结论也可绑定到
205+
`selected_candidate`;写回仍重放相同分页并核对精确来源摘要。默认三条视图与
206+
独立的卡片数量上限保持不变。较大的结果页不授予执行权限,也不证明选题质量。
207+
203208
## 可选 Todo 分支规划
204209

205210
`loopx explore todo-branch-plan` 是面向探索目标的窄 opt-in harness:一次尝试多个看起来都合理的下一个 todo。它使用 CPU 分支预测类比 + DSpark 启发调度器:对 open agent todos 排序,估计分支置信度与预期证据单位,选择置信度调度的验证前缀,选一个 `primary` 分支加安全的 `speculative` 分支,拒绝声明写作用域与已选分支重叠的分支。

‎loopx/control_plane/capabilities/explore_result_page.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
44
import {requireJsonObject} from "../runtime_decode.ts";
55
import {BARE_SHA256_PATTERN} from "../content_digest.ts";
66

7+
export const EXPLORE_RESULT_PAGE_MAX = 20;
8+
79
export function normalizeExploreResultPages(value: unknown): JsonObject[] {
810
if (!Array.isArray(value) || !value.length || value.length > 3) {
911
throw new EffectRuntimeRequestError("explore_review.result_pages requires one to three distinct result pages");
@@ -14,7 +16,7 @@ export function normalizeExploreResultPages(value: unknown): JsonObject[] {
1416
if (Object.keys(row).some(key => !["revision", "offset", "limit"].includes(key)) ||
1517
typeof row.revision !== "string" || !BARE_SHA256_PATTERN.test(row.revision) ||
1618
!Number.isSafeInteger(row.offset) || Number(row.offset) < 0 ||
17-
!Number.isSafeInteger(row.limit) || Number(row.limit) < 1 || Number(row.limit) > 20) {
19+
!Number.isSafeInteger(row.limit) || Number(row.limit) < 1 || Number(row.limit) > EXPLORE_RESULT_PAGE_MAX) {
1820
throw new EffectRuntimeRequestError("Explore result page requires revision, nonnegative offset and limit 1..20; node-filtered pages are unsupported");
1921
}
2022
const page = {revision: row.revision, offset: row.offset, limit: row.limit};

‎loopx/control_plane/capabilities/explore_turn_context.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {requireInteger, requireJsonObject, requireNonEmptyString, requireStringA
66
import {PLANNING_TODO_ID} from "../work_items/planning_relations.ts";
77

88
import {EXPLORE_WRITEBACK_SUMMARY_LIMIT, exploreResultWritebackAffordance} from "./explore_result_writeback.ts";
9-
import {normalizeExploreResultPages} from "./explore_result_page.ts";
9+
import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "./explore_result_page.ts";
1010
import {projectExploreMeasurementFeedback} from "./explore_measurements.ts";
1111

1212
function rows(value: unknown): JsonObject[] {
@@ -112,7 +112,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
112112
const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal];
113113
const resultLimit = requireInteger(params.result_limit ?? 3, "result_limit");
114114
const resultOffset = requireInteger(params.result_offset ?? 0, "result_offset");
115-
if (resultLimit < 1 || resultLimit > 20 || resultOffset < 0) {
115+
if (resultLimit < 1 || resultLimit > EXPLORE_RESULT_PAGE_MAX || resultOffset < 0) {
116116
throw new EffectRuntimeRequestError("Use result_limit 1..20 and a nonnegative result_offset");
117117
}
118118
const resultNode = params.result_node == null ? null : requireNonEmptyString(params.result_node, "result_node");
@@ -134,7 +134,9 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
134134
const audit = requireJsonObject(branch.typed_evidence_audit, "Explore evidence audit");
135135
const linked = new Set(requireStringArray(audit.requested_node_refs ?? [], "Explore node refs")
136136
.filter(ref => nodeById.has(ref)));
137-
const outcomes = page.filter(row => linked.has(String(row.node_id))).slice(0, 3);
137+
// The explicit page already bounds these results. A second three-row
138+
// cap would display findings that the same page cannot bind at writeback.
139+
const outcomes = page.filter(row => linked.has(String(row.node_id)));
138140
return outcomes.length ? [{todo_id: branch.todo_id, outcomes}] : [];
139141
});
140142
if (!linkedCandidates.length) return {};

‎loopx/control_plane/work_items/stride_policy.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import decisionSchema from "../stride_decision_schema.json" with {type: "json"};
66
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
77
import type {JsonObject} from "../effect_program.ts";
88
import {requireJsonObject, requireNonEmptyString, requireStringLiteral} from "../runtime_decode.ts";
9-
import {normalizeExploreResultPages} from "../capabilities/explore_result_page.ts";
9+
import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "../capabilities/explore_result_page.ts";
1010
import {PLANNING_TODO_ID} from "./planning_relations.ts";
1111

1212
export function normalizeStrideStrategy(value: unknown): string {
@@ -108,8 +108,8 @@ function comparisonCandidates(value: unknown): JsonObject[] {
108108
return value.map(value => {
109109
const card = requireJsonObject(value, "comparison candidate");
110110
exactFields(card, ["todo_id", "outcomes"], "comparison candidate");
111-
if (!Array.isArray(card.outcomes) || !card.outcomes.length || card.outcomes.length > 3) {
112-
throw new EffectRuntimeRequestError("comparison candidate requires at most three scoped outcomes");
111+
if (!Array.isArray(card.outcomes) || !card.outcomes.length || card.outcomes.length > EXPLORE_RESULT_PAGE_MAX) {
112+
throw new EffectRuntimeRequestError("comparison candidate requires one to twenty scoped outcomes from its result page");
113113
}
114114
const outcomes = card.outcomes.map(value => {
115115
const outcome = requireJsonObject(value, "comparison outcome");

‎tests/control_plane/test_stride_comparison_followthrough_cli.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,85 @@ def review(value, turn_id=turn, *, expected=0):
147147
refused = review(next_packet, next_turn, expected=1)
148148
assert "declared work" in refused["error"] and not refused.get("appended"), refused
149149
assert index.read_bytes() == before
150+
151+
152+
@pytest.mark.parametrize("provider", ["file", "sqlite"])
153+
def test_wide_result_page_replays_every_linked_outcome_at_writeback(tmp_path, monkeypatch, provider):
154+
isolate_sqlite_runtime(tmp_path, monkeypatch)
155+
monkeypatch.setenv("LOOPX_USAGE_PING", "0")
156+
monkeypatch.setenv("PYTHONPATH", str(fixture.REPO_ROOT))
157+
project, runtime, registry = fixture._write_fixture(tmp_path)
158+
monkeypatch.chdir(project)
159+
fixture._initialize_fixture_authority(project, runtime, provider=provider)
160+
161+
def cli(*args, expected=0):
162+
code, result = fixture._run_cli(registry, runtime, *args, cwd=project)
163+
assert code == expected, result
164+
return result
165+
166+
identity = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID)
167+
cli("configure-goal", "--goal-id", fixture.GOAL_ID, "--explore-mode", "planning",
168+
"--execution-replan-after-turns", "1", "--execution-stride-strategy", "evidence_conditioned_v1", "--execute")
169+
training = project / "training.json"
170+
training.write_text(json.dumps([{"x": 2, "y": 3, "observed": 6}]))
171+
request = {"project": str(project), "registry": str(registry), "runtime": str(runtime),
172+
"goal_id": fixture.GOAL_ID, "agent_id": fixture.AGENT_ID, "todo_id": fixture.TODO_ID,
173+
"objective": "Use the exact scoped measurement that informs the next experiment.",
174+
"training": str(training), "budget": 25, "seed": 17}
175+
actor.probe(request, "retained")
176+
for index in range(4):
177+
measurements = project / f"measurement-{index}.json"
178+
measurements.write_text(json.dumps([{"metric": "quality", "value": index,
179+
"unit": "points", "higher_is_better": True,
180+
"comparison_scope": "validation:owned-v1", "input_revision": "owned-input-v1",
181+
"evaluation_ref": "evaluator:owned-v1", "candidate_ref": f"candidate:scope-{index}",
182+
"evidence_ref": f"measured:scope-{index}"}]))
183+
cli("explore", "finding", *identity, "--finding-id", f"wide-result-{index}",
184+
"--node", "comparison-retained", "--title", f"Observed input scope {index}",
185+
"--summary", f"Only the recorded input scope {index}; other inputs remain unknown.",
186+
"--status", "confirmed", "--tag", "writeback-result", "--evidence-ref", f"measured:scope-{index}",
187+
"--measurements-json", str(measurements))
188+
turn = "wide-page-review"
189+
cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project), "--turn-instance-id", turn)
190+
guard = cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project),
191+
"--turn-instance-id", turn, "--todo-id", fixture.TODO_ID)
192+
control = guard["replan_action_packet"]["stride_control"]
193+
page = cli("explore", "turn-context", *identity, "--result-limit", "20")
194+
[card] = [row for row in page["harness"]["comparison_candidates"] if row["todo_id"] == fixture.TODO_ID]
195+
assert len(page["graph"]["writeback_results"]) == len(card["outcomes"]) == 5
196+
# Choose a quantified outcome omitted by the default three-row view.
197+
visible = {row["finding_id"]: row for row in page["graph"]["writeback_results"]}
198+
default_refs = {row["finding_id"] for row in control["explore_basis"]["comparison_candidates"][0]["outcomes"]}
199+
selected = {"todo_id": fixture.TODO_ID, **next(row for row in reversed(card["outcomes"])
200+
if row["finding_id"] not in default_refs and visible[row["finding_id"]].get("measurements"))}
201+
measurement = visible[selected["finding_id"]]["measurements"][0]
202+
assert measurement["evidence_ref"] == visible[selected["finding_id"]]["evidence_refs"][0]
203+
[feedback] = page["graph"]["measurement_feedback"]
204+
assert feedback["measured_count"] == 4
205+
assert feedback["best"]["candidate_ref"] == "candidate:scope-3"
206+
decision = deepcopy(control["minimal_example"])
207+
decision["explore_review"] = {"revision": control["explore_basis"]["revision"],
208+
"disposition": "used", "considered_refs": [selected["finding_id"]],
209+
"selected_candidate": selected, "reason": "This exact measured scope informs the next experiment.",
210+
"result_pages": [{k: page["graph"]["result_page"][k] for k in ("revision", "offset", "limit")}]}
211+
packet = actor.vision(request, selected["finding_id"], outcome="replan")
212+
packet.update(stride_decision=decision, todo_delta=["retain:" + fixture.TODO_ID])
213+
path = project / "wide-review.json"
214+
command = ("refresh-state", *identity, "--todo-id", fixture.TODO_ID, "--turn-instance-id", turn,
215+
"--classification", "scoped_page_review", "--delivery-batch-scale", "implementation",
216+
"--delivery-outcome", "outcome_progress", "--agent-vision-json", str(path),
217+
"--no-global-sync", "--suppress-external-sinks")
218+
bad = deepcopy(packet)
219+
bad["stride_decision"]["explore_review"]["selected_candidate"]["source_sha256"] = "f" * 64
220+
path.write_text(json.dumps(bad))
221+
index = runtime / "goals" / fixture.GOAL_ID / "runs/index.jsonl"
222+
before = index.read_bytes()
223+
refused = cli(*command, expected=1)
224+
assert "exact scoped result" in refused["error"] and index.read_bytes() == before
225+
path.write_text(json.dumps(packet))
226+
accepted = cli(*command)
227+
persisted = json.loads(Path(accepted["json_path"]).read_text())
228+
assert persisted["agent_vision"]["stride_decision"]["explore_review"]["selected_candidate"] == selected
229+
assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"]
230+
readback = cli("explore", "turn-context", *identity, "--result-limit", "20")
231+
assert readback["graph"]["measurement_feedback"] == [feedback]

‎tests/control_plane_ts/explore_turn_context.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
22
import {createHash} from "node:crypto";
33
import test from "node:test";
44
import type {JsonObject} from "../../loopx/control_plane/effect_program.ts";
5+
import {projectStrideExploreBasis, strideReviewMismatch} from "../../loopx/control_plane/work_items/stride_policy.ts";
56
import {projectExploreTurnContext} from "../../loopx/control_plane/capabilities/explore_turn_context.ts";
67

78
test("next-turn evidence exposes current question state and relation changes invalidate old pages", () => {
@@ -80,3 +81,53 @@ for (const includeDigests of [false, true]) {
8081
}
8182
});
8283
}
84+
85+
for (const limit of [3, 4, 20]) {
86+
test(`every scoped outcome on an explicitly requested page remains selectable: ${limit}`, () => {
87+
const findings = Array.from({length: 20}, (_, index) => ({finding_id: `scoped-${index}`,
88+
node_id: "question", finding: `Measured input family ${index}`, status: "confirmed",
89+
summary: `Applicable to input family ${index} only.`, tags: ["writeback-result"],
90+
evidence_refs: [`result:scoped-${index}`],
91+
measurements: [{metric: "quality", value: index, unit: "points", higher_is_better: true,
92+
comparison_scope: "validation:owned-v1", input_revision: "owned-input-v1",
93+
evaluation_ref: "evaluator:owned-v1", candidate_ref: `candidate:scoped-${index}`,
94+
evidence_ref: `result:scoped-${index}`}]}));
95+
const request = {goal_id: "research", agent_id: "worker", route: ["loopx"],
96+
graph_enabled: true, harness_gate: {enabled: true}, result_limit: limit,
97+
projection: {nodes: [{node_id: "question", status: "exploring"}], findings},
98+
plan: {selected_branches: [{todo_id: "todo_method", actionable_open: true,
99+
typed_evidence_audit: {requested_node_refs: ["question"]}}]}};
100+
const source = projectExploreTurnContext(request);
101+
const graph = source.graph as JsonObject, harness = source.harness as JsonObject;
102+
const card = harness.comparison_candidates[0];
103+
// Quantitative feedback still uses all sources, independently of page size.
104+
assert.equal(graph.measurement_feedback[0].measured_count, 20);
105+
assert.equal(graph.measurement_feedback[0].best.value, 19);
106+
assert.equal(graph.measurement_feedback[0].best.candidate_ref, "candidate:scoped-19");
107+
// The result page and candidate card must describe the same read scope.
108+
assert.equal(graph.writeback_results.length, limit);
109+
assert.deepEqual(card.outcomes.map((row: JsonObject) => row.finding_id),
110+
findings.slice(0, limit).map(row => row.finding_id));
111+
const last = findings[limit - 1];
112+
const selected = {todo_id: "todo_method", node_id: last.node_id, finding_id: last.finding_id,
113+
source_sha256: createHash("sha256").update(JSON.stringify(last)).digest("hex")};
114+
const basis = projectStrideExploreBasis(source, "research", "worker")!;
115+
const decision = {explore_review: {revision: basis.revision, disposition: "used",
116+
considered_refs: [last.finding_id], selected_candidate: selected}};
117+
assert.equal(strideReviewMismatch(decision, {explore_basis: basis}, undefined, ["retain:todo_method"]), null);
118+
assert.match(String(strideReviewMismatch({explore_review: {...decision.explore_review,
119+
selected_candidate: {...selected, source_sha256: "f".repeat(64)}}},
120+
{explore_basis: basis}, undefined, ["retain:todo_method"])), /exact scoped result/);
121+
assert.match(String(strideReviewMismatch(decision, {explore_basis: basis}, undefined, [])), /retained/);
122+
// Page replay must expose exactly the same choices at ordinary writeback.
123+
const page = {revision: graph.result_page.revision, offset: 0, limit};
124+
const replay = projectExploreTurnContext({...request, review_pages: [page]});
125+
assert.deepEqual(replay.review_pages[0].comparison_candidates, harness.comparison_candidates);
126+
const tooMany = {...source, harness: {...harness, comparison_candidates: [
127+
{...card, outcomes: Array.from({length: 21}, () => card.outcomes[0])}]}};
128+
assert.throws(() => projectStrideExploreBasis(tooMany, "research", "worker"), /scoped outcomes/);
129+
const evidenceOnly = projectExploreTurnContext({...request, harness_gate: {enabled: false}});
130+
assert.equal(evidenceOnly.harness, null);
131+
assert.equal(Object.hasOwn(evidenceOnly.graph, "comparison_candidates"), false);
132+
});
133+
}

0 commit comments

Comments
 (0)