Skip to content
Open
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 @@ -658,6 +658,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,
Expand Down
4 changes: 3 additions & 1 deletion loopx/control_plane/capabilities/explore_result_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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};
Expand Down
8 changes: 5 additions & 3 deletions loopx/control_plane/capabilities/explore_turn_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

function rows(value: unknown): JsonObject[] {
return Array.isArray(value) ? value.map(item => requireJsonObject(item, "Explore row")) : [];
Expand Down Expand Up @@ -111,7 +111,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");
Expand All @@ -130,7 +130,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 {};
Expand Down
6 changes: 3 additions & 3 deletions loopx/control_plane/work_items/stride_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
67 changes: 67 additions & 0 deletions tests/control_plane/test_stride_comparison_followthrough_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,70 @@ 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):
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}")
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 the last displayed outcome; the default three-row view omits it.
selected = {"todo_id": fixture.TODO_ID, **card["outcomes"][-1]}
assert selected["finding_id"] not in {row["finding_id"] for row in
control["explore_basis"]["comparison_candidates"][0]["outcomes"]}
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"]
42 changes: 42 additions & 0 deletions tests/control_plane_ts/explore_turn_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -80,3 +81,44 @@ 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"]}));
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];
// 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);
});
}
Loading