Skip to content

Commit 3c10a78

Browse files
committed
fix(explore): keep paged outcomes available for method selection
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 4e6baa5 commit 3c10a78

6 files changed

Lines changed: 127 additions & 7 deletions

File tree

‎loopx/capabilities/explore/README.md‎

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

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

‎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

1111
function rows(value: unknown): JsonObject[] {
1212
return Array.isArray(value) ? value.map(item => requireJsonObject(item, "Explore row")) : [];
@@ -111,7 +111,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
111111
const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal];
112112
const resultLimit = requireInteger(params.result_limit ?? 3, "result_limit");
113113
const resultOffset = requireInteger(params.result_offset ?? 0, "result_offset");
114-
if (resultLimit < 1 || resultLimit > 20 || resultOffset < 0) {
114+
if (resultLimit < 1 || resultLimit > EXPLORE_RESULT_PAGE_MAX || resultOffset < 0) {
115115
throw new EffectRuntimeRequestError("Use result_limit 1..20 and a nonnegative result_offset");
116116
}
117117
const resultNode = params.result_node == null ? null : requireNonEmptyString(params.result_node, "result_node");
@@ -130,7 +130,9 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
130130
const audit = requireJsonObject(branch.typed_evidence_audit, "Explore evidence audit");
131131
const linked = new Set(requireStringArray(audit.requested_node_refs ?? [], "Explore node refs")
132132
.filter(ref => nodeById.has(ref)));
133-
const outcomes = page.filter(row => linked.has(String(row.node_id))).slice(0, 3);
133+
// The explicit page already bounds these results. A second three-row
134+
// cap would display findings that the same page cannot bind at writeback.
135+
const outcomes = page.filter(row => linked.has(String(row.node_id)));
134136
return outcomes.length ? [{todo_id: branch.todo_id, outcomes}] : [];
135137
});
136138
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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,70 @@ 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+
cli("explore", "finding", *identity, "--finding-id", f"wide-result-{index}",
178+
"--node", "comparison-retained", "--title", f"Observed input scope {index}",
179+
"--summary", f"Only the recorded input scope {index}; other inputs remain unknown.",
180+
"--status", "confirmed", "--tag", "writeback-result", "--evidence-ref", f"measured:scope-{index}")
181+
turn = "wide-page-review"
182+
cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project), "--turn-instance-id", turn)
183+
guard = cli("quota", "should-run", *identity, "--codex-app", "--scan-path", str(project),
184+
"--turn-instance-id", turn, "--todo-id", fixture.TODO_ID)
185+
control = guard["replan_action_packet"]["stride_control"]
186+
page = cli("explore", "turn-context", *identity, "--result-limit", "20")
187+
[card] = [row for row in page["harness"]["comparison_candidates"] if row["todo_id"] == fixture.TODO_ID]
188+
assert len(page["graph"]["writeback_results"]) == len(card["outcomes"]) == 5
189+
# Choose the last displayed outcome; the default three-row view omits it.
190+
selected = {"todo_id": fixture.TODO_ID, **card["outcomes"][-1]}
191+
assert selected["finding_id"] not in {row["finding_id"] for row in
192+
control["explore_basis"]["comparison_candidates"][0]["outcomes"]}
193+
decision = deepcopy(control["minimal_example"])
194+
decision["explore_review"] = {"revision": control["explore_basis"]["revision"],
195+
"disposition": "used", "considered_refs": [selected["finding_id"]],
196+
"selected_candidate": selected, "reason": "This exact measured scope informs the next experiment.",
197+
"result_pages": [{k: page["graph"]["result_page"][k] for k in ("revision", "offset", "limit")}]}
198+
packet = actor.vision(request, selected["finding_id"], outcome="replan")
199+
packet.update(stride_decision=decision, todo_delta=["retain:" + fixture.TODO_ID])
200+
path = project / "wide-review.json"
201+
command = ("refresh-state", *identity, "--todo-id", fixture.TODO_ID, "--turn-instance-id", turn,
202+
"--classification", "scoped_page_review", "--delivery-batch-scale", "implementation",
203+
"--delivery-outcome", "outcome_progress", "--agent-vision-json", str(path),
204+
"--no-global-sync", "--suppress-external-sinks")
205+
bad = deepcopy(packet)
206+
bad["stride_decision"]["explore_review"]["selected_candidate"]["source_sha256"] = "f" * 64
207+
path.write_text(json.dumps(bad))
208+
index = runtime / "goals" / fixture.GOAL_ID / "runs/index.jsonl"
209+
before = index.read_bytes()
210+
refused = cli(*command, expected=1)
211+
assert "exact scoped result" in refused["error"] and index.read_bytes() == before
212+
path.write_text(json.dumps(packet))
213+
accepted = cli(*command)
214+
persisted = json.loads(Path(accepted["json_path"]).read_text())
215+
assert persisted["agent_vision"]["stride_decision"]["explore_review"]["selected_candidate"] == selected
216+
assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"]

‎tests/control_plane_ts/explore_turn_context.test.ts‎

Lines changed: 42 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,44 @@ 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+
const request = {goal_id: "research", agent_id: "worker", route: ["loopx"],
91+
graph_enabled: true, harness_gate: {enabled: true}, result_limit: limit,
92+
projection: {nodes: [{node_id: "question", status: "exploring"}], findings},
93+
plan: {selected_branches: [{todo_id: "todo_method", actionable_open: true,
94+
typed_evidence_audit: {requested_node_refs: ["question"]}}]}};
95+
const source = projectExploreTurnContext(request);
96+
const graph = source.graph as JsonObject, harness = source.harness as JsonObject;
97+
const card = harness.comparison_candidates[0];
98+
// The result page and candidate card must describe the same read scope.
99+
assert.equal(graph.writeback_results.length, limit);
100+
assert.deepEqual(card.outcomes.map((row: JsonObject) => row.finding_id),
101+
findings.slice(0, limit).map(row => row.finding_id));
102+
const last = findings[limit - 1];
103+
const selected = {todo_id: "todo_method", node_id: last.node_id, finding_id: last.finding_id,
104+
source_sha256: createHash("sha256").update(JSON.stringify(last)).digest("hex")};
105+
const basis = projectStrideExploreBasis(source, "research", "worker")!;
106+
const decision = {explore_review: {revision: basis.revision, disposition: "used",
107+
considered_refs: [last.finding_id], selected_candidate: selected}};
108+
assert.equal(strideReviewMismatch(decision, {explore_basis: basis}, undefined, ["retain:todo_method"]), null);
109+
assert.match(String(strideReviewMismatch({explore_review: {...decision.explore_review,
110+
selected_candidate: {...selected, source_sha256: "f".repeat(64)}}},
111+
{explore_basis: basis}, undefined, ["retain:todo_method"])), /exact scoped result/);
112+
assert.match(String(strideReviewMismatch(decision, {explore_basis: basis}, undefined, [])), /retained/);
113+
// Page replay must expose exactly the same choices at ordinary writeback.
114+
const page = {revision: graph.result_page.revision, offset: 0, limit};
115+
const replay = projectExploreTurnContext({...request, review_pages: [page]});
116+
assert.deepEqual(replay.review_pages[0].comparison_candidates, harness.comparison_candidates);
117+
const tooMany = {...source, harness: {...harness, comparison_candidates: [
118+
{...card, outcomes: Array.from({length: 21}, () => card.outcomes[0])}]}};
119+
assert.throws(() => projectStrideExploreBasis(tooMany, "research", "worker"), /scoped outcomes/);
120+
const evidenceOnly = projectExploreTurnContext({...request, harness_gate: {enabled: false}});
121+
assert.equal(evidenceOnly.harness, null);
122+
assert.equal(Object.hasOwn(evidenceOnly.graph, "comparison_candidates"), false);
123+
});
124+
}

0 commit comments

Comments
 (0)