From 468eb0370b096dc373e7429911e8efaff29dad82 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:36 +0800 Subject: [PATCH 1/3] fix(planning): carry the original Goal into the planning horizon Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../protocols/quota-planning-horizon-v0.md | 32 ++++++- .../control_plane/quota/should_run_packet.py | 1 + .../work_items/action_portfolio.py | 8 ++ .../work_items/action_portfolio.ts | 1 + .../work_items/planning_horizon.ts | 5 +- .../work_items/replan_context.ts | 9 +- .../work_items/replan_context_codec.py | 6 +- .../test_quota_planning_horizon.py | 94 ++++++++++++++++++- .../control_plane_ts/planning_horizon.test.ts | 22 +++++ 9 files changed, 167 insertions(+), 11 deletions(-) diff --git a/docs/reference/protocols/quota-planning-horizon-v0.md b/docs/reference/protocols/quota-planning-horizon-v0.md index 651e40874d..08a06a070c 100644 --- a/docs/reference/protocols/quota-planning-horizon-v0.md +++ b/docs/reference/protocols/quota-planning-horizon-v0.md @@ -3,9 +3,10 @@ `quota_planning_horizon_v0` is the bounded, read-only context that lets an agent reason beyond one locally selected Todo without inlining LoopX's full Todo store or task graph. It is present on the default `quota should-run` path -only when the current selected work has typed lineage, waiting/blocking, -scheduled-observation, or goal-acceptance context. Flat runnable alternatives -remain solely in `action_portfolio`, avoiding a duplicate hot-path view. +when a planning choice is available. It carries the current `core_goal` before +work selection, including for a single local Todo or flat backlog. Older +snapshot-only callers that supply no Goal facts retain the strategic-context +condition. Flat runnable alternatives remain owned by `action_portfolio`. The problem it addresses is narrower than scheduling. A selected regression gate can be locally runnable while the strategic path also contains a facts @@ -35,6 +36,21 @@ not silently replace the selected Todo. authorities. Planning-horizon items are context until the agent performs the existing explicit selection/re-entry flow. +The Goal lens reuses the replan source reader and TypeScript `projectCoreGoal` +projection: active-state Objective first, registered objective as fallback, +explicit missing state, and the existing separately scoped acceptance contract. +It does not persist another Goal, promote an Agent vision or Todo into the +original objective, add a strategy switch, or change action eligibility. When +available, `core_goal.read_action` reads the same complete Goal state, with the +source path shell-quoted. A corrected source is read on the next planning +projection; no copied objective needs synchronization. + +This intentionally extends default planning context. Existing Todo selection, +claim/lease, admission, replan precedence and settlement authority remain +unchanged. Goal text is scoped decision input; public export remains a separate +boundary. The complete original objective is not shortened to the Todo display +budget. Transport-size warnings remain visible rather than discarding its tail. + ## Shape ```json @@ -44,6 +60,12 @@ not silently replace the selected Todo. "goal_id": "loopx-meta", "agent_id": "codex-main-control", "selected_todo_id": "todo_regression_gate", + "core_goal": { + "objective": "Deliver the complete accepted outcome.", + "objective_source": "active_state", + "objective_missing": false, + "acceptance_contract": null + }, "selection_contract": { "selected_todo_authority": "$.selected_todo", "action_choice_authority": "$.action_portfolio", @@ -179,6 +201,10 @@ A conforming implementation must prove: - portfolio, horizon, and agent-Todo detail consume the same typed inventory; - task graph consumes the same canonical Todo selector, plus terminal evidence rows, without acquiring agent-scoped selection or claim authority; +- the same current Goal reaches preselection horizon and replan, with original + trailing constraints and source readback intact; +- missing, corrected and fallback sources stay explicit; scoped acceptance and + latest Agent vision do not replace the original Goal; - the horizon never changes `selected_todo` or action-portfolio eligibility; - runnable unclaimed work preserves `claim_required_before_work=true`; - typed successor/resume/route relations survive the default compact path; diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index f81bda1c3b..ed42b24f22 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -810,6 +810,7 @@ def _planning_projections( capability_gate=prepared.capability_gate, blocked_priority_fallback=prepared.blocked_priority_fallback, goal_frontier_projection=prepared.goal_frontier_projection, + source_status=prepared.status_payload, replan_selection_context=( { "has_turn_identity": bool(turn_instance_id), diff --git a/loopx/control_plane/work_items/action_portfolio.py b/loopx/control_plane/work_items/action_portfolio.py index f17e05cb47..3baea88f87 100644 --- a/loopx/control_plane/work_items/action_portfolio.py +++ b/loopx/control_plane/work_items/action_portfolio.py @@ -54,6 +54,7 @@ def build_quota_planning_packet( blocked_priority_fallback: Mapping[str, Any] | None, goal_frontier_projection: Mapping[str, Any] | None, replan_selection_context: Mapping[str, Any] | None = None, + source_status: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Project every requested planning lens through one TypeScript request.""" @@ -70,6 +71,12 @@ def build_quota_planning_packet( ) if inventory_request is None: return {} + from .replan_context_codec import goal_context_facts + + goal_facts = ( + goal_context_facts(source_status, goal_id, (agent_todo_summary or {}).get("goal_acceptance_contract")) + if source_status and source_status.get("registry") else None + ) try: projected = effect_runtime_result( "work_item.action_portfolio.project", @@ -81,6 +88,7 @@ def build_quota_planning_packet( "acceptance_gaps": _frontier_acceptance_gaps( goal_frontier_projection ), + **({"goal_facts": goal_facts} if goal_facts is not None else {}), **({"replan_selection_context": dict(replan_selection_context)} if replan_selection_context is not None else {}), }, diff --git a/loopx/control_plane/work_items/action_portfolio.ts b/loopx/control_plane/work_items/action_portfolio.ts index 436787f60a..12f18cc34a 100644 --- a/loopx/control_plane/work_items/action_portfolio.ts +++ b/loopx/control_plane/work_items/action_portfolio.ts @@ -324,6 +324,7 @@ function projectQuotaPlanningPacket(request: JsonObject): JsonObject { schema_version: PLANNING_HORIZON_REQUEST_SCHEMA_VERSION, planning_inventory: inventory, acceptance_gaps: request.acceptance_gaps, + ...(request.goal_facts === undefined ? {} : {goal_facts: request.goal_facts}), }); if (horizon !== null) projected.planning_horizon = horizon; } diff --git a/loopx/control_plane/work_items/planning_horizon.ts b/loopx/control_plane/work_items/planning_horizon.ts index 8f612afcd7..33891a9ef8 100644 --- a/loopx/control_plane/work_items/planning_horizon.ts +++ b/loopx/control_plane/work_items/planning_horizon.ts @@ -13,6 +13,7 @@ import { import { relationKey, todoRef, type PlanningInventoryRelation } from "./planning_relations.ts"; import type { JsonObject } from "../effect_program.ts"; +import { projectCoreGoal } from "./replan_context.ts"; export const PLANNING_HORIZON_SCHEMA_VERSION = "quota_planning_horizon_v0"; export const PLANNING_HORIZON_REQUEST_SCHEMA_VERSION = @@ -242,7 +243,8 @@ export function projectQuotaPlanningHorizon(value: unknown): JsonObject | null { item.todo_id !== selected.todo_id && strategicContextStates.has(item.planning_state) ); - if (!addsStrategicContext) { + const core = request.goal_facts === undefined ? null : projectCoreGoal(request.goal_facts); + if (!addsStrategicContext && core === null) { return null; } return { @@ -251,6 +253,7 @@ export function projectQuotaPlanningHorizon(value: unknown): JsonObject | null { goal_id: goalId, agent_id: agentId, selected_todo_id: selected.todo_id, + ...(core === null ? {} : {core_goal: core}), selection_contract: { selected_todo_authority: "$.selected_todo", action_choice_authority: "$.action_portfolio", diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index 17c61ceefe..df185b0a0d 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -91,7 +91,7 @@ function evidenceFactsKey(row: JsonObject): string { return digest(facts); } -function coreGoal(value: unknown): JsonObject { +export function projectCoreGoal(value: unknown): JsonObject { const facts = value == null ? {} : requireJsonObject(value, "goal_facts"); const active = facts.active_state_objective; const registered = facts.registry_objective; @@ -102,7 +102,10 @@ function coreGoal(value: unknown): JsonObject { const acceptance = facts.acceptance_contract == null ? null : requireJsonObject(facts.acceptance_contract, "goal_acceptance_contract"); return {objective, objective_source: active ? "active_state" : registered ? "registry" : null, - objective_missing: objective === null, acceptance_contract: acceptance}; + objective_missing: objective === null, acceptance_contract: acceptance, + ...(facts.goal_read_action == null ? {} : { + read_action: requireNonEmptyString(facts.goal_read_action, "goal_facts.goal_read_action"), + })}; } /** Qualify recorded bindings using the settlement owner, without settling work. @@ -388,7 +391,7 @@ export function projectReplanContext(value: unknown): JsonObject { const selectedCoverage = new Set(chosen.flatMap(({row}) => row.progress_observation ? [String((row.progress_observation as JsonObject).fingerprint)] : [])); const ledger = [...coverage].filter(([key]) => selectedCoverage.has(key)).map(([, value]) => value); - const core = coreGoal(request.goal_facts); + const core = projectCoreGoal(request.goal_facts); const strideStrategy = normalizeStrideStrategy(request.stride_strategy); const exploreBasis = strideStrategy !== "off" ? projectStrideExploreBasis(request.explore_context, goal, agent) : null; const followthrough = agent && strideStrategy !== "off" diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py index 38839fb3e2..59db6ff756 100644 --- a/loopx/control_plane/work_items/replan_context_codec.py +++ b/loopx/control_plane/work_items/replan_context_codec.py @@ -106,7 +106,7 @@ def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, age return rows -def _goal_facts(status: Mapping[str, Any] | None, goal_id: str, acceptance: Mapping[str, Any] | None) -> dict[str, Any]: +def goal_context_facts(status: Mapping[str, Any] | None, goal_id: str, acceptance: Mapping[str, Any] | None) -> dict[str, Any]: """Read existing Goal sources; source precedence belongs to TypeScript.""" def goal_text(value: Any) -> str | None: if value is not None and not isinstance(value, str): @@ -126,6 +126,8 @@ def goal_text(value: Any) -> str | None: facts["goal_ref"] = {"goal_id": goal_id, "goal_instance_id": goal["goal_instance_id"]} facts["registry_objective"] = goal_text(goal.get("objective")) path = goal_state_path(goal) + if path: + facts["goal_read_action"] = "cat -- " + shlex.quote(str(path.resolve())) if path and path.exists(): state = path.read_text(encoding="utf-8") metadata, _ = split_state_frontmatter(state) @@ -173,7 +175,7 @@ def project_replan_context( **({"explore_context": explore_context} if explore_context is not None else {}), "evidence_ref": evidence_ref, "read_prefix": shlex.join(prefix), - "goal_facts": _goal_facts(source_status, goal_id, goal_acceptance_contract) if evidence_ref is None else {}, + "goal_facts": goal_context_facts(source_status, goal_id, goal_acceptance_contract) if evidence_ref is None else {}, "from_full_index": bool(source_status and source_status.get("registry") and source_status.get("runtime_root")), }, method="work_item.replan_context") except EffectRuntimeRejected as exc: diff --git a/tests/control_plane/test_quota_planning_horizon.py b/tests/control_plane/test_quota_planning_horizon.py index d4020c78e6..f71f1bfa8c 100644 --- a/tests/control_plane/test_quota_planning_horizon.py +++ b/tests/control_plane/test_quota_planning_horizon.py @@ -1,6 +1,11 @@ from __future__ import annotations from collections.abc import Mapping +import json +from pathlib import Path +import shlex +import subprocess +import pytest from typing import Any from pytest import MonkeyPatch @@ -93,7 +98,7 @@ def test_default_quota_and_turn_envelope_expose_one_bounded_planning_horizon() - assert envelope["compaction"]["envelope_json_bytes"] <= 8_192 -def test_single_selected_todo_does_not_add_a_redundant_horizon() -> None: +def test_snapshot_without_goal_sources_does_not_add_a_redundant_horizon() -> None: selected = quota_todo_item( todo_id="todo_only_work", index=1, @@ -121,7 +126,7 @@ def test_single_selected_todo_does_not_add_a_redundant_horizon() -> None: assert "planning_horizon" not in packet -def test_flat_runnable_backlog_stays_in_action_portfolio_without_horizon() -> None: +def test_snapshot_flat_backlog_stays_in_action_portfolio_without_horizon() -> None: items = [ quota_todo_item( todo_id=f"todo_flat_{index:03d}", @@ -153,6 +158,91 @@ def test_flat_runnable_backlog_stays_in_action_portfolio_without_horizon() -> No assert "planning_horizon" not in packet +def test_preselection_horizon_and_replan_share_current_goal_sources(tmp_path: Path) -> None: + from loopx.control_plane.work_items.replan_context_codec import project_replan_context + + project = tmp_path / "project with spaces" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + objective = "Deliver the complete useful outcome. " + "Retain original constraints. " * 20 + "Final priority: accuracy." + state.write_text("# Goal\n\n## Objective\n> " + objective + "\n\n## Agent Todo\n- Read the local report.\n") + runtime = tmp_path / "runtime" + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL_ID, "repo": str(project), "state_file": state.name, + "objective": "Registered fallback", "status": "active-read-only", "domain": "fixture", + }]})) + status = planning_horizon_strategic_context_status() + status.update(registry=str(registry), runtime_root=str(runtime)) + def read_packet(): + return build_quota_should_run(status, goal_id=GOAL_ID, agent_id=AGENT_ID, + turn_instance_id="turn-goal-horizon") + + original = read_packet() + horizon = original["planning_horizon"]["core_goal"] + assert horizon["objective"] == objective + assert horizon["objective_source"] == "active_state" + assert horizon["objective_missing"] is False + assert horizon == project_replan_context( + goal_id=GOAL_ID, agent_id=AGENT_ID, runs=(), source_status=status, + )["core_goal"] + readback = subprocess.run(shlex.split(horizon["read_action"]), capture_output=True, text=True, check=True) + assert readback.stdout == state.read_text() + assert original["planning_horizon"]["selection_contract"]["horizon_changes_selection"] is False + envelope = build_turn_envelope(original) + assert envelope["action"]["planning_horizon"]["core_goal"] == horizon + + state.write_text("# Goal\n\n## Objective\n> Current owner-corrected objective.\n") + corrected = read_packet() + assert corrected["planning_horizon"]["core_goal"]["objective"] == "Current owner-corrected objective." + assert corrected["selected_todo"] == original["selected_todo"] + assert corrected.get("action_portfolio") == original.get("action_portfolio") + assert corrected["should_run"] == original["should_run"] + assert corrected["interaction_contract"] == original["interaction_contract"] + assert quota_action_signature_document(corrected) != quota_action_signature_document(original) + state.write_text("# Goal\n") + fallback = read_packet() + assert fallback["planning_horizon"]["core_goal"]["objective"] == "Registered fallback" + assert fallback["planning_horizon"]["core_goal"]["objective_source"] == "registry" + registry_data = json.loads(registry.read_text()) + registry_data["goals"][0].pop("objective") + registry.write_text(json.dumps(registry_data)) + missing = read_packet() + assert missing["planning_horizon"]["core_goal"]["objective_missing"] is True + assert missing["planning_horizon"]["core_goal"]["objective"] is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_cli_exposes_goal_before_selection_without_granting_delivery(tmp_path, monkeypatch, provider): + from test_replan_successor_guard_reentry import _fixture, AGENT, GOAL + + call, runtime, index = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) + index.write_text("") + for number in range(2): + call("todo", "add", "--goal-id", GOAL, "--role", "agent", "--claimed-by", AGENT, + "--text", f"Validate independent source {number}.", "--task-class", "advancement_task", + "--action-kind", "validate") + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT) + before = call(*args, "--turn-instance-id", "turn-goal-selection") + core = before["planning_horizon"]["core_goal"] + assert core["objective"] == "Deliver the independently accepted source outcome." + assert core["objective_source"] == "active_state" + assert before["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert before["interaction_contract"]["cli_channel"]["selection_required"] is True + assert "settlement_identity" not in before["heartbeat_receipt"] + state = tmp_path / "project/ACTIVE_GOAL_STATE.md" + assert subprocess.check_output(shlex.split(core["read_action"]), text=True) == state.read_text() + assert build_turn_envelope(before)["action"]["planning_horizon"]["core_goal"] == core + state.write_text(state.read_text().replace( + "Deliver the independently accepted source outcome.", "Deliver the corrected whole outcome.")) + corrected = call(*args, "--turn-instance-id", "turn-goal-corrected") + assert corrected["planning_horizon"]["core_goal"]["objective"] == "Deliver the corrected whole outcome." + assert corrected["action_portfolio"] == before["action_portfolio"] + assert corrected["selected_todo"] == before["selected_todo"] + assert corrected["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert not index.read_text() + + def test_agent_todo_detail_expands_claim_semantics_from_the_shared_inventory() -> None: selected = quota_todo_item( todo_id="todo_selected_detail", diff --git a/tests/control_plane_ts/planning_horizon.test.ts b/tests/control_plane_ts/planning_horizon.test.ts index d744288462..812daa2b93 100644 --- a/tests/control_plane_ts/planning_horizon.test.ts +++ b/tests/control_plane_ts/planning_horizon.test.ts @@ -58,9 +58,31 @@ function request(overrides: Record = {}) { source_context_todo_count: legacy.source_context_todo_count, }), acceptance_gaps: legacy.acceptance_gaps, + ...(overrides.goal_facts === undefined ? {} : {goal_facts: overrides.goal_facts}), }; } +test("one local task still carries the original Goal and separately scoped acceptance", () => { + const objective = "Deliver the whole accepted outcome. ".repeat(20) + "Preserve the final constraint."; + const result = projectQuotaPlanningHorizon(request({goal_facts: { + active_state_objective: objective, + registry_objective: "Older registered objective", + goal_read_action: "cat -- 'source with spaces/ACTIVE_GOAL_STATE.md'", + acceptance_contract: {objective: "Validate this slice only", scope: {kind: "selected_work"}}, + }})); + assert.deepEqual(result?.core_goal, { + objective, objective_source: "active_state", objective_missing: false, + read_action: "cat -- 'source with spaces/ACTIVE_GOAL_STATE.md'", + acceptance_contract: {objective: "Validate this slice only", scope: {kind: "selected_work"}}, + }); + assert.equal(result?.selected_todo_id, "todo_regression_gate"); + assert.equal((result?.selection_contract as Record).horizon_changes_selection, false); + assert.throws(() => projectQuotaPlanningHorizon(request({goal_facts: {active_state_objective: 42}}))); + assert.deepEqual(projectQuotaPlanningHorizon(request({goal_facts: {}}))?.core_goal, { + objective: null, objective_source: null, objective_missing: true, acceptance_contract: null, + }); +}); + test("planning horizon preserves the typed strategic chain around local selected work", () => { const facts = todo("todo_facts_source", 1, "P0", { status: "deferred", From 57e909e694b66e34784241839a350bad6d763531 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:53:08 +0800 Subject: [PATCH 2/3] test(planning): use shared Goal reader in integrated evidence check Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/control_plane/test_replan_context_evidence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/control_plane/test_replan_context_evidence.py b/tests/control_plane/test_replan_context_evidence.py index 62dcfd4d7a..bcd03ff1b0 100644 --- a/tests/control_plane/test_replan_context_evidence.py +++ b/tests/control_plane/test_replan_context_evidence.py @@ -92,12 +92,12 @@ def test_goal_and_evidence_paths_survive_scoped_replan( def test_scoped_goal_keeps_original_text_without_public_filtering(tmp_path: Path) -> None: - from loopx.control_plane.work_items.replan_context_codec import _goal_facts + from loopx.control_plane.work_items.replan_context_codec import goal_context_facts registry = tmp_path / "registry.json" registry.write_text(json.dumps({"goals": [{"id": "evidence-goal", "objective": "Inspect access_key=synthetic-secret before continuing."}]})) - facts = _goal_facts({"registry": str(registry)}, "evidence-goal", None) + facts = goal_context_facts({"registry": str(registry)}, "evidence-goal", None) assert facts["registry_objective"] == "Inspect access_key=synthetic-secret before continuing." From ee0e20d0da7e83db5dbd3636a26fd20926114689 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:42:19 +0800 Subject: [PATCH 3/3] feat(planning): make horizon Goal context an explicit shared opt-in Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capability-localization.ts | 2 + .../goal-capability-settings.tsx | 4 +- benchmark/edgebench/README.md | 20 +++++++++ benchmark/edgebench/export_report.py | 8 ++++ benchmark/edgebench/run.py | 7 +++ benchmark/runtime/harbor.py | 17 +++++++- benchmark/runtime/sforge.py | 8 ++++ benchmark/tests/test_edgebench_report.py | 29 +++++++++++++ benchmark/tests/test_sforge_runtime.py | 21 +++++++-- benchmark/tests/test_shared_codex_runtime.py | 31 +++++++++++++ benchmark/tests/test_task_entry.py | 7 ++- .../protocols/quota-planning-horizon-v0.md | 43 ++++++++++++++++--- .../replan-cadence.mjs | 32 +++++++++++++- loopx/capabilities/configuration_ui.py | 8 ++-- .../todo_replan_cadence/goal_configuration.py | 2 +- .../todo_replan_cadence/machine_defaults.py | 10 ++++- loopx/chat_goal_configuration_api.py | 17 +++++--- loopx/cli_commands/registry_admin.py | 2 + .../cli_commands/registry_admin_configure.py | 9 ++++ loopx/configure_goal.py | 4 ++ .../control_plane/quota/should_run_packet.py | 1 + .../work_items/action_portfolio.py | 5 ++- loopx/execution_profile.py | 17 +++++++- ...est_machine_configuration_goal_defaults.py | 39 +++++++++++++++++ .../test_quota_planning_horizon.py | 18 ++++++++ tests/test_chat_goal_configuration_api.py | 1 + 26 files changed, 335 insertions(+), 27 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 9ef4ef10ab..74bc1c4ccb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -159,6 +159,7 @@ const fieldCopy: Record = { executor_model: { label: "Model", description: "Optional model for the selected executor. Leave blank to keep the executor's own default." }, executor_reasoning_effort: { label: "Reasoning effort", description: "Optional reasoning effort for the selected executor. Leave blank to keep the executor's own default." }, count_unit: { label: "Review after", description: "Work Turns require accepted settlement; polls and retries do not count.", options: { completed_todos: "Completed Todos", effective_turns: "Settled work Turns" } }, + planning_horizon_goal: { label: "Show Goal in planning horizon", description: "Default off. Read the full original Goal and acceptance before choosing work. Does not change replan, stride or execution authority." }, stride_strategy: { label: "Stride strategy", description: "Structured work scope, route and validation proposals are required for enabled fresh Vision reviews. Does not grant execution authority.", options: { off: "Off", evidence_conditioned_v1: "Evidence-conditioned · v1" } }, count: { label: "Number between reviews", description: "Settled work Turns: 1–6; completed Todos: 1–5. Goal overrides take precedence over device defaults." }, allowed_domains: { label: "Allowed responsibility domains", description: "Enter one bounded, public-safe domain per line." }, @@ -194,6 +195,7 @@ const fieldCopy: Record = { executor_model: { label: "模型", description: "所选执行器使用的模型,可留空;留空表示沿用执行器自身的默认模型。" }, executor_reasoning_effort: { label: "推理档位", description: "所选执行器使用的推理档位,可留空;留空表示沿用执行器自身的默认档位。" }, count_unit: { label: "复核计数依据", description: "有效工作 Turn 须完成结算;轮询和重复重试不计数。", options: { completed_todos: "已完成 Todo", effective_turns: "已结算工作 Turn" } }, + planning_horizon_goal: { label: "在规划视野中显示 Goal", description: "默认关闭。选择工作前读取完整原目标与验收要求,不改变复盘、步幅策略或执行权限。" }, stride_strategy: { label: "步幅策略", description: "启用后,新的 Vision 路线复盘须提交工作大小、路线及验证范围的结构化提案,不授予执行权限。", options: { off: "关闭", evidence_conditioned_v1: "依据证据调整 · v1" } }, count: { label: "两次复核间的数量", description: "已结算工作 Turn 为 1–6,已完成 Todo 为 1–5;Goal 显式设置优先于设备默认值。" }, allowed_domains: { label: "允许的职责域", description: "每行填写一个有边界、可公开的职责域。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index 24192a293c..73eda04803 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -379,8 +379,8 @@ function CapabilityCatalog({ callbacks, catalog, goalId, loading, notification,

: null} {selected.effective_value_policy === "goal_stride_over_live_review_cadence" ?

{locale === "zh-CN" - ? `复盘周期沿用${selected.machine_current ? "设备" : "产品"}默认;步幅策略由本 Goal 设置。` - : `Review interval follows ${selected.machine_current ? "device" : "product"} defaults; this Goal sets the stride strategy.`} + ? `复盘周期沿用${selected.machine_current ? "设备" : "产品"}默认;规划选项由本 Goal 设置。` + : `Review interval follows ${selected.machine_current ? "device" : "product"} defaults; this Goal sets the planning options.`}

: null} tuple[str, str, int]: def _replan_receipt(self) -> dict[str, int | str]: key, _, value = self._replan_configuration() return {key: value, **({"stride_strategy": self.stride_strategy} - if self.stride_strategy is not None else {})} + if self.stride_strategy is not None else {}), + **({"planning_horizon_goal": self.planning_horizon_goal} + if self.planning_horizon_goal is not None else {})} @staticmethod def name() -> str: @@ -422,6 +431,9 @@ async def _prepare_phase( key, option, expected = self._replan_configuration() stride_args = (["--execution-stride-strategy", self.stride_strategy] if self.stride_strategy is not None else []) + if self.planning_horizon_goal is not None: + stride_args.append("--execution-planning-horizon-goal" if self.planning_horizon_goal + else "--no-execution-planning-horizon-goal") pending = await environment.exec( command=f"test -e {_LOOPX_RUNTIME}/benchmark-pending-turn.json" ) @@ -502,6 +514,9 @@ async def _prepare_phase( raise RuntimeError( f"replan cadence readback mismatch for {key}: expected {expected}, got {configured!r}" ) + actual_horizon = configured_state.get("execution_profile", {}).get("planning_horizon_goal", False) + if actual_horizon != (self.planning_horizon_goal or False): + raise RuntimeError("planning_horizon_goal readback mismatch") strategy = configured_state.get("execution_profile", {}).get("stride_strategy", "off") if strategy != (self.stride_strategy or "off"): raise RuntimeError( diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index 0f6b139996..f410678ae1 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -95,6 +95,7 @@ def __init__(self, config, *, profile: str, cwd: str, replan_after_turns: int | None = None, replan_after_todos: int | None = None, stride_strategy: str | None = None, + planning_horizon_goal: bool | None = None, checkpoint_request_path: str | None = None, checkpoint_request: bytes | None = None, warm_start=None): @@ -143,6 +144,12 @@ def __init__(self, config, *, profile: str, cwd: str, if stride_strategy is not None: from loopx.control_plane.work_items.stride_policy_codec import normalize_stride_strategy stride_strategy = normalize_stride_strategy(stride_strategy) + if planning_horizon_goal is not None: + if not isinstance(planning_horizon_goal, bool): + raise ValueError("planning_horizon_goal must be a boolean") + if not profile.startswith("heartbeat-"): + raise ValueError("planning_horizon_goal requires a heartbeat profile") + self.planning_horizon_goal = planning_horizon_goal self.stride_strategy = stride_strategy self.checkpoint_request_path = None self.checkpoint_request = checkpoint_request @@ -265,6 +272,7 @@ def _install_worker(self, backend, handle, log_dir, logger): replan_after_turns=self.replan_after_turns, replan_after_todos=self.replan_after_todos, stride_strategy=self.stride_strategy, + planning_horizon_goal=self.planning_horizon_goal, checkpoint_request_path=self.checkpoint_request_path, checkpoint_request=self.checkpoint_request, task_objective=self.task_objective, diff --git a/benchmark/tests/test_edgebench_report.py b/benchmark/tests/test_edgebench_report.py index 97e1c965c2..31e39e5a7f 100644 --- a/benchmark/tests/test_edgebench_report.py +++ b/benchmark/tests/test_edgebench_report.py @@ -625,3 +625,32 @@ def build(repository): return data _publish_and_recheck(tmp_path, build) + + +@pytest.mark.parametrize("enabled", [None, False, True]) +def test_report_preserves_horizon_opt_in_and_historical_absence(trial, enabled): + runs, selection, output, _ = trial + for name in ("runtime-receipt", "worker-profile"): + path = runs / "run" / "case" / (name + ".json") + value = json.loads(path.read_text()) + if enabled is not None: + value["planning_horizon_goal"] = enabled + write(path, value) + export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") + config = json.loads((output / "settings/run.json").read_text()) + for field in ("runner", "worker_profile"): + assert config[field].get("planning_horizon_goal") is enabled + assert ("planning_horizon_goal" in config[field]) is (enabled is not None) + + +@pytest.mark.parametrize("receipt,profile", [(True, False), (True, None), ("true", "true"), (True, 1)]) +def test_report_rejects_unbound_horizon_setting(trial, receipt, profile): + runs, selection, output, _ = trial + for name, enabled in (("runtime-receipt", receipt), ("worker-profile", profile)): + path = runs / "run" / "case" / (name + ".json") + value = json.loads(path.read_text()) + if enabled is not None: + value["planning_horizon_goal"] = enabled + write(path, value) + with pytest.raises(ValueError, match="planning_horizon_goal"): + export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index cf91fbbe05..b5f05f6193 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -462,7 +462,8 @@ def test_planned_sforge_entry_rejects_unsupported_profiles(profile, monkeypatch) @pytest.mark.parametrize("entry", ["seeded-todo", "loopx-planned"]) -def test_sforge_command_preserves_native_timing_and_planning_boundary(tmp_path, monkeypatch, entry): +@pytest.mark.parametrize("horizon", [None, False, True]) +def test_sforge_command_preserves_native_timing_and_planning_boundary(tmp_path, monkeypatch, entry, horizon): pytest.importorskip("sforge") pytest.importorskip("harbor") from sforge.harness.config import SForgeConfig @@ -472,9 +473,11 @@ async def installed(self, environment): pass monkeypatch.setattr(BenchmarkCodex, "install", installed) worker = SForgeWorker(SForgeConfig(agent_model="fixture", agent_effort="xhigh"), - profile="heartbeat-explore", cwd="/task", task_entry=entry) + profile="heartbeat-explore", cwd="/task", task_entry=entry, planning_horizon_goal=horizon) worker.install_stop_hook(None, None, tmp_path, None) assert worker.runtime.execution.task_entry == entry + assert worker.runtime.planning_horizon_goal is horizon + assert worker.runtime.execution.turn_envelope is False worker.prepared = True # Bootstrap is separately covered through real CLI fixtures. first = worker.format_run_cmd("/task.md") resumed = worker.format_run_cmd("/task.md", resume=True) @@ -482,7 +485,10 @@ async def installed(self, environment): assert ("benchmark.runtime.sforge_entry" in first) is (entry == "loopx-planned") assert "test -f /opt/loopx-benchmark/control/phase-deadline ||" in first assert first.index("export LOOPX_PHASE_DEADLINE_EPOCH") < first.index("timeout --signal") - assert json.loads((tmp_path / "worker-profile.json").read_text())["task_entry"] == entry + profile = json.loads((tmp_path / "worker-profile.json").read_text()) + assert profile["task_entry"] == entry + assert profile.get("planning_horizon_goal") is horizon + assert ("planning_horizon_goal" in profile) is (horizon is not None) @pytest.mark.parametrize('profile', ['heartbeat-resume', 'heartbeat-explore']) @pytest.mark.parametrize('enabled', [False, True]) @pytest.mark.parametrize('cadence', [None, 2, 6]) @@ -656,6 +662,8 @@ def test_invalid_checkpoint_declaration_rejects_before_trial_registration(tmp_pa @pytest.mark.parametrize("cadence_args,field,count", [ ([], "replan_after_effective_turns", 6), + (["--planning-horizon-goal"], "replan_after_effective_turns", 6), + (["--no-planning-horizon-goal"], "replan_after_effective_turns", 6), (["--replan-after-turns", "2"], "replan_after_effective_turns", 2), (["--replan-after-todos", "3"], "replan_after_completed_todos", 3), ]) @@ -693,6 +701,9 @@ def stop_before_solver(**kwargs): assert kwargs["timeout"] == kwargs["config"].agent_timeout == expected assert kwargs["eval_interval"] == interval agent = kwargs["agent"] + horizon = (True if "--planning-horizon-goal" in cadence_args else + False if "--no-planning-horizon-goal" in cadence_args else None) + assert agent.planning_horizon_goal is horizon assert "total trial budget" not in agent.workspace_instructions assert agent.task_objective == "fixture task" assert agent.task_objective in agent.feedback_prompt @@ -713,6 +724,10 @@ def stop_before_solver(**kwargs): assert receipt["timeout_seconds"] == expected assert receipt["eval_interval"] == interval assert receipt[field] == count + horizon = (True if "--planning-horizon-goal" in cadence_args else + False if "--no-planning-horizon-goal" in cadence_args else None) + assert receipt.get("planning_horizon_goal") is horizon + assert ("planning_horizon_goal" in receipt) is (horizon is not None) other = ("replan_after_completed_todos" if field == "replan_after_effective_turns" else "replan_after_effective_turns") assert other not in receipt diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index ed41f587cd..792eefb418 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -935,3 +935,34 @@ def test_toolface_qualification_captures_first_request_and_rejects_empty(tmp_pat toolface.qualify_toolface( set(), codex_bin=fake, model="fixture-model", timeout_sec=30, extra_env={"FAKE_DIE": "1"}) + + +@pytest.mark.parametrize("requested,actual", [(True, False), (False, True), (None, True)]) +def test_horizon_setting_must_match_restored_goal_before_work(tmp_path, monkeypatch, requested, actual): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + agent = BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", planning_horizon_goal=requested) + async def prepared(*args, **kwargs): + return True + async def no_pending(**kwargs): + return SimpleNamespace(return_code=1) + async def wrong_setting(*args, **kwargs): + return {"after": {"execution_profile": { + "replan_after_effective_turns": 6, "planning_horizon_goal": actual, + }}} + monkeypatch.setattr(agent, "_registry_exists", prepared) + monkeypatch.setattr(agent, "_write_task_document", prepared) + monkeypatch.setattr(agent, "_loopx", wrong_setting) + with pytest.raises(RuntimeError, match="planning_horizon_goal readback mismatch"): + asyncio.run(agent._prepare_phase(SimpleNamespace(exec=no_pending), "Synthetic task", cwd=str(tmp_path))) + + +@pytest.mark.parametrize("settings", [ + {"planning_horizon_goal": "true"}, {"planning_horizon_goal": 1}, + {"execution_mode": "plain", "planning_horizon_goal": False}, +]) +def test_horizon_setting_rejects_non_boolean_and_non_loopx_mode(tmp_path, settings): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + with pytest.raises(ValueError, match="planning_horizon_goal"): + BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", **settings) diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index 8a72e5887d..921c47a9f9 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -698,6 +698,8 @@ def test_sforge_planning_failed_host_has_no_execution_handoff(planning_env): @pytest.mark.parametrize("settings,field,value", [ ({}, "replan_after_effective_turns", 6), + ({"planning_horizon_goal": True}, "replan_after_effective_turns", 6), + ({"planning_horizon_goal": False}, "replan_after_effective_turns", 6), ({"replan_after_turns": 2}, "replan_after_effective_turns", 2), ({"replan_after_todos": 3}, "replan_after_completed_todos", 3), ({"stride_strategy": "off"}, "replan_after_effective_turns", 6), @@ -745,5 +747,8 @@ async def no_pending(**kwargs): assert other not in profile strategy = settings.get("stride_strategy") assert profile.get("stride_strategy", "off") == (strategy or "off") - assert agent._replan_receipt() == {field: value, **({"stride_strategy": strategy} + horizon = settings.get("planning_horizon_goal") + assert profile.get("planning_horizon_goal", False) == (horizon or False) + assert agent._replan_receipt() == {field: value, + **({"planning_horizon_goal": horizon} if horizon is not None else {}), **({"stride_strategy": strategy} if strategy is not None else {})} diff --git a/docs/reference/protocols/quota-planning-horizon-v0.md b/docs/reference/protocols/quota-planning-horizon-v0.md index 08a06a070c..07432d40b8 100644 --- a/docs/reference/protocols/quota-planning-horizon-v0.md +++ b/docs/reference/protocols/quota-planning-horizon-v0.md @@ -3,10 +3,11 @@ `quota_planning_horizon_v0` is the bounded, read-only context that lets an agent reason beyond one locally selected Todo without inlining LoopX's full Todo store or task graph. It is present on the default `quota should-run` path -when a planning choice is available. It carries the current `core_goal` before -work selection, including for a single local Todo or flat backlog. Older -snapshot-only callers that supply no Goal facts retain the strategic-context -condition. Flat runnable alternatives remain owned by `action_portfolio`. +when a planning choice has strategic context. The optional `planning_horizon_goal` +setting is **off by default**. When enabled it carries the current `core_goal` +before work selection, including for a single local Todo or flat backlog. +When disabled, the existing strategic-context condition and projection are +unchanged. Flat runnable alternatives remain owned by `action_portfolio`. The problem it addresses is narrower than scheduling. A selected regression gate can be locally runnable while the strategic path also contains a facts @@ -40,17 +41,44 @@ The Goal lens reuses the replan source reader and TypeScript `projectCoreGoal` projection: active-state Objective first, registered objective as fallback, explicit missing state, and the existing separately scoped acceptance contract. It does not persist another Goal, promote an Agent vision or Todo into the -original objective, add a strategy switch, or change action eligibility. When +original objective, enable stride, or change action eligibility. When available, `core_goal.read_action` reads the same complete Goal state, with the source path shell-quoted. A corrected source is read on the next planning projection; no copied objective needs synchronization. -This intentionally extends default planning context. Existing Todo selection, +This extends planning context only after explicit opt-in. Existing Todo selection, claim/lease, admission, replan precedence and settlement authority remain unchanged. Goal text is scoped decision input; public export remains a separate boundary. The complete original objective is not shortened to the Todo display budget. Transport-size warnings remain visible rather than discarding its tail. +## Enable, Read Back And Disable + +The existing **Goal review and stride** capability editor in Settings → +Capabilities exposes **Show Goal in planning horizon** at machine and Goal scope. +Use its preview, apply and readback flow. Its optional boolean is stored in the +existing `execution_profile.planning_horizon_goal`; no new capability or Goal +store is introduced. The v2 machine review configuration accepts the same +optional boolean; omission preserves its previous shape and disabled default. +As with stride, a Goal with an explicit review cadence uses its own configuration; +a sparse Goal switch inherits the live interval. An explicit false overrides a +machine opt-in. Clearing a Goal override restores the applicable inherited value. + +```sh +loopx configure-goal --goal-id example --execution-planning-horizon-goal --execute +loopx configure-goal --goal-id example --format json +loopx quota should-run --goal-id example --agent-id researcher --format json +loopx configure-goal --goal-id example --no-execution-planning-horizon-goal --execute +``` + +The quota readback contains `planning_horizon.core_goal` only when this projection +is enabled and a preselection planning projection is applicable. It is not added +to receipt-bound replays or ineligible work merely to repeat the Goal. Disabling +skips the extra Goal-document read. Replan continues to read its existing Goal +context independently. TurnEnvelope, stride, execution permission and quota are +not enabled by this setting. Context/signature changes are observable input +changes, not evidence of improved model decisions. + ## Shape ```json @@ -201,7 +229,8 @@ A conforming implementation must prove: - portfolio, horizon, and agent-Todo detail consume the same typed inventory; - task graph consumes the same canonical Todo selector, plus terminal evidence rows, without acquiring agent-scoped selection or claim authority; -- the same current Goal reaches preselection horizon and replan, with original +- omitted/false settings retain the prior horizon and skip extra Goal reads; +- when enabled, the same current Goal reaches preselection horizon and replan, with original trailing constraints and source readback intact; - missing, corrected and fallback sources stay explicit; scoped acceptance and latest Agent vision do not replace the original Goal; diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index dd21e56826..2ca9a00192 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -127,7 +127,7 @@ export const replanCadenceScenario = { || converted.effective_configuration.configuration.count_unit !== "effective_turns") { throw new Error("JSON-to-form save substituted the product-default interval"); } - await page.getByText("复盘周期沿用设备默认;步幅策略由本 Goal 设置。", { exact: true }).waitFor(); + await page.getByText("复盘周期沿用设备默认;规划选项由本 Goal 设置。", { exact: true }).waitFor(); await page.screenshot({ path: resolve(outputDir, "replan-cadence-json-form.png"), animations: "disabled" }); if (JSON.stringify(converted.current) !== JSON.stringify({ stride_strategy: "evidence_conditioned_v1" })) { throw new Error("JSON-to-form strategy-only save pinned a cadence"); @@ -148,6 +148,33 @@ export const replanCadenceScenario = { await page.getByRole("combobox", { name: "目标 Goal", exact: true }).selectOption("multi-agent-projection"); await page.getByRole("navigation", { name: "Goal 能力目录" }).getByRole("button", { name: /Goal 复盘与步幅/ }).click(); } + // The same real configuration owner supports the independent horizon + // switch without pinning cadence or changing stride. Unchecked remains off. + const horizonGoal = page.getByLabel(/^在规划视野中显示 Goal/); + assert.equal(await horizonGoal.isChecked(), false); + await horizonGoal.check(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + await reopenGoalCadence(); + assert.equal(await horizonGoal.isChecked(), true); + let horizonReadback = await readGoalCadence(); + assert.equal(horizonReadback.current.planning_horizon_goal, true); + assert.equal(horizonReadback.current.stride_strategy, "evidence_conditioned_v1"); + assert.equal(horizonReadback.current.count, undefined); + await page.screenshot({ path: resolve(outputDir, "planning-horizon-goal-enabled.png"), animations: "disabled" }); + await horizonGoal.uncheck(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + await reopenGoalCadence(); + assert.equal(await horizonGoal.isChecked(), false); + horizonReadback = await readGoalCadence(); + assert.equal(horizonReadback.current.planning_horizon_goal, false); + // Restore the sparse pre-existing scenario before its cadence transitions. + await page.getByRole("button", { name: "编辑 JSON", exact: true }).click(); + await page.locator("#goal-configuration-json").fill(JSON.stringify({stride_strategy: "evidence_conditioned_v1"})); + await page.getByRole("button", { name: "返回表单", exact: true }).click(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); await changeMachineCount(4); if ((await readGoalCadence()).effective_configuration.configuration.count !== 4) { throw new Error("Strategy-only Goal did not follow a later machine policy"); @@ -187,6 +214,7 @@ export const replanCadenceScenario = { await delayedReadback.started; assert.equal(await count.isDisabled(), true, "A committed write must keep the editor closed until its readback settles"); assert.equal(await strategy.isDisabled(), true); + assert.equal(await horizonGoal.isDisabled(), true); assert.equal(await page.getByRole("button", { name: "编辑 JSON", exact: true }).isDisabled(), true); assert.equal(await page.getByRole("button", { name: "预览变更", exact: true }).isDisabled(), true); const goalCatalog = page.getByRole("navigation", { name: "Goal 能力目录" }); @@ -223,6 +251,8 @@ export const replanCadenceScenario = { await unit.focus(); await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); + assert.equal(await horizonGoal.evaluate((element) => element === document.activeElement), true, "Horizon switch is keyboard reachable"); + await page.keyboard.press("Tab"); if (!(await strategy.evaluate((element) => element === document.activeElement))) throw new Error("Strategy is not keyboard reachable"); await page.screenshot({ path: resolve(outputDir, "replan-cadence-goal-mobile.png"), animations: "disabled" }); if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)) throw new Error("Cadence editor overflowed"); diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index 40b211c309..47813f3359 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -115,6 +115,8 @@ def capability_configuration_editor( _field("count", "Review interval", "number", minimum=1, maximum=MAX_EFFECTIVE_TURN_REPLAN_THRESHOLD, required=True, description="Effective Turns: 1–6; completed Todos: 1–5. Effective Turns require accepted work settlement. Retries and observation-only polls do not count."), + _field("planning_horizon_goal", "Show Goal in planning horizon", "boolean", + description="Default off. Include the full original Goal and acceptance before choosing work; replan Goal reading, stride and execution authority are unchanged."), _field("stride_strategy", "Stride strategy", "select", options=list(stride_strategy_options()), description="Off retains ordinary review. Evidence-conditioned v1 independently reviews work-slice size, route changes and validation scope; fresh Vision path reviews require a structured proposal, without automatic execution."), @@ -618,7 +620,8 @@ def _attach_effective_configuration(entry: dict[str, Any]) -> None: if ( entry["capability_id"] == "todo_replan_cadence" and goal_override is not None - and set(goal_override) == {"stride_strategy"} + and bool(goal_override) + and set(goal_override) <= {"stride_strategy", "planning_horizon_goal"} ): from .todo_replan_cadence.machine_defaults import ( cadence_configuration_value, @@ -633,8 +636,7 @@ def _attach_effective_configuration(entry: dict[str, Any]) -> None: else default_replan_cadence_configuration() ) goal_override = { - "count_unit": inherited_cadence["count_unit"], - "count": inherited_cadence["count"], + **inherited_cadence, **goal_override, } entry["effective_value_policy"] = "goal_stride_over_live_review_cadence" diff --git a/loopx/capabilities/todo_replan_cadence/goal_configuration.py b/loopx/capabilities/todo_replan_cadence/goal_configuration.py index 4267f92751..2290e46489 100644 --- a/loopx/capabilities/todo_replan_cadence/goal_configuration.py +++ b/loopx/capabilities/todo_replan_cadence/goal_configuration.py @@ -12,7 +12,7 @@ def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: profile = goal.get("execution_profile") if not isinstance(profile, Mapping): return None - strategy = {"stride_strategy": profile["stride_strategy"]} if "stride_strategy" in profile else {} + strategy = {key: profile[key] for key in ("stride_strategy", "planning_horizon_goal") if key in profile} if "replan_after_effective_turns" in profile: return { "count_unit": "effective_turns", diff --git a/loopx/capabilities/todo_replan_cadence/machine_defaults.py b/loopx/capabilities/todo_replan_cadence/machine_defaults.py index e0b171e481..53b126b4f2 100644 --- a/loopx/capabilities/todo_replan_cadence/machine_defaults.py +++ b/loopx/capabilities/todo_replan_cadence/machine_defaults.py @@ -36,7 +36,7 @@ def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, "count_unit": ReplanCadenceUnit.COMPLETED_TODOS.value, "count": normalize_completed_todo_replan_threshold(raw["completed_todos"]), } - if set(raw) not in ({"count_unit", "count"}, {"count_unit", "count", "stride_strategy"}): + if not {"count_unit", "count"} <= set(raw) or set(raw) - {"count_unit", "count", "stride_strategy", "planning_horizon_goal"}: raise ValueError("review cadence requires count_unit and count") try: unit = ReplanCadenceUnit(raw["count_unit"]) @@ -53,6 +53,10 @@ def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, if "stride_strategy" in raw: from ...control_plane.work_items.stride_policy_codec import normalize_stride_strategy result["stride_strategy"] = normalize_stride_strategy(raw["stride_strategy"]) + if "planning_horizon_goal" in raw: + if not isinstance(raw["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + result["planning_horizon_goal"] = raw["planning_horizon_goal"] return result @@ -74,6 +78,8 @@ def normalize_todo_replan_cadence_machine_defaults( expected = {"schema_version", "count_unit", "count"} if version == TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2: expected.add("stride_strategy") + if "planning_horizon_goal" in raw: + expected.add("planning_horizon_goal") if set(raw) != expected: raise ValueError("review configuration fields do not match its schema version") return { @@ -177,6 +183,8 @@ def apply_todo_replan_cadence_machine_default( profile[field] = cadence["count"] if "stride_strategy" not in profile and cadence.get("stride_strategy", "off") != "off": profile["stride_strategy"] = cadence["stride_strategy"] + if "planning_horizon_goal" not in profile and "planning_horizon_goal" in cadence: + profile["planning_horizon_goal"] = cadence["planning_horizon_goal"] projected["execution_profile"] = profile return projected diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 8388c08dbe..d9a333c6bf 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -195,7 +195,7 @@ def _goal_capability_options( if capability_id == "periodic_report": return {"clear_periodic_report_configuration": True} if capability_id == "todo_replan_cadence": - return {"clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True} + return {"clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True, "clear_execution_planning_horizon_goal": True} if capability_id == "pull_request_review": return {"clear_pull_request_review_configuration": True} if capability_id == "change_quality_qualification": @@ -208,7 +208,7 @@ def _goal_capability_options( config = dict(configuration) allowed: dict[str, set[str]] = { "goal_capability_organization": {"mode", "discovery_budget_minutes", "max_trials"}, - "todo_replan_cadence": {"completed_todos", "count_unit", "count", "stride_strategy"}, + "todo_replan_cadence": {"completed_todos", "count_unit", "count", "stride_strategy", "planning_horizon_goal"}, "multi_subagent": { "enabled", "max_children", @@ -244,19 +244,26 @@ def _goal_capability_options( if capability_id == "todo_replan_cadence": from .capabilities.todo_replan_cadence.machine_defaults import normalize_replan_cadence_configuration from .control_plane.work_items.stride_policy_codec import normalize_stride_strategy - if set(config) == {"stride_strategy"}: + horizon = {"clear_execution_planning_horizon_goal": True} + if "planning_horizon_goal" in config: + if not isinstance(config["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + horizon = {"execution_planning_horizon_goal": config["planning_horizon_goal"]} + if config and set(config) <= {"stride_strategy", "planning_horizon_goal"}: # The capability editor submits the complete sparse value. Unlike # the CLI's single-field strategy patch, omission restores live # interval inheritance even when this Goal was previously pinned. return { - "execution_stride_strategy": normalize_stride_strategy(config["stride_strategy"]), + **horizon, + **({"execution_stride_strategy": normalize_stride_strategy(config["stride_strategy"])} + if "stride_strategy" in config else {}), "clear_execution_replan_after_turns": True, "clear_execution_replan_after_todos": True, } cadence = normalize_replan_cadence_configuration(config) option = ("execution_replan_after_turns" if cadence["count_unit"] == "effective_turns" else "execution_replan_after_todos") - return {option: cadence["count"], **({"execution_stride_strategy": cadence["stride_strategy"]} + return {option: cadence["count"], **horizon, **({"execution_stride_strategy": cadence["stride_strategy"]} if "stride_strategy" in cadence else {})} if capability_id == "periodic_report": return {"periodic_report_configuration": config} diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index d6ea55411a..01d93dc8ff 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -452,6 +452,8 @@ def handle_registry_admin_command( execution_turn_granularity=args.execution_turn_granularity, execution_replan_after_todos=args.execution_replan_after_todos, execution_replan_after_turns=args.execution_replan_after_turns, + execution_planning_horizon_goal=args.execution_planning_horizon_goal, + clear_execution_planning_horizon_goal=args.clear_execution_planning_horizon_goal, execution_stride_strategy=args.execution_stride_strategy, clear_execution_stride_strategy=args.clear_execution_stride_strategy, clear_execution_replan_after_turns=args.clear_execution_replan_after_turns, diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 06b7692340..48300f2bcb 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -70,6 +70,15 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N "--clear-execution-replan-after-turns", action="store_true", help="Remove the Goal effective-Turn override and restore device/default inheritance.", ) + from argparse import BooleanOptionalAction + configure_goal_parser.add_argument( + "--execution-planning-horizon-goal", action=BooleanOptionalAction, default=None, + help="Include the full same-source Goal in preselection planning horizons. Default off; no selection or execution authority change.", + ) + configure_goal_parser.add_argument( + "--clear-execution-planning-horizon-goal", action="store_true", + help="Remove the Goal horizon override and restore review-configuration inheritance.", + ) from ..control_plane.work_items.stride_policy_codec import stride_strategy_options configure_goal_parser.add_argument( "--execution-stride-strategy", choices=stride_strategy_options(), diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index c7db8e3676..24901ced0b 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -440,6 +440,8 @@ def configure_goal( execution_replan_after_turns: int | None = None, execution_stride_strategy: str | None = None, clear_execution_stride_strategy: bool = False, + execution_planning_horizon_goal: bool | None = None, + clear_execution_planning_horizon_goal: bool = False, clear_execution_replan_after_turns: bool = False, clear_execution_replan_after_todos: bool = False, self_repair_enabled: bool | None = None, @@ -833,6 +835,8 @@ def configure_goal( replan_after_completed_todos=execution_replan_after_todos, replan_after_effective_turns=execution_replan_after_turns, stride_strategy=execution_stride_strategy, clear_stride_strategy=clear_execution_stride_strategy, + planning_horizon_goal=execution_planning_horizon_goal, + clear_planning_horizon_goal=clear_execution_planning_horizon_goal, clear_replan_after_effective_turns=clear_execution_replan_after_turns, clear_replan_after_completed_todos=clear_execution_replan_after_todos, ) diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index ed42b24f22..d354005a94 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -811,6 +811,7 @@ def _planning_projections( blocked_priority_fallback=prepared.blocked_priority_fallback, goal_frontier_projection=prepared.goal_frontier_projection, source_status=prepared.status_payload, + planning_horizon_goal=(prepared.project_asset.get("execution_profile") or {}).get("planning_horizon_goal", False), replan_selection_context=( { "has_turn_identity": bool(turn_instance_id), diff --git a/loopx/control_plane/work_items/action_portfolio.py b/loopx/control_plane/work_items/action_portfolio.py index 3baea88f87..5ad65201a6 100644 --- a/loopx/control_plane/work_items/action_portfolio.py +++ b/loopx/control_plane/work_items/action_portfolio.py @@ -55,9 +55,12 @@ def build_quota_planning_packet( goal_frontier_projection: Mapping[str, Any] | None, replan_selection_context: Mapping[str, Any] | None = None, source_status: Mapping[str, Any] | None = None, + planning_horizon_goal: bool = False, ) -> dict[str, Any]: """Project every requested planning lens through one TypeScript request.""" + if not isinstance(planning_horizon_goal, bool): + raise ValueError("planning_horizon_goal must be a boolean") if not projection_enabled and not include_detail and replan_selection_context is None: return {} inventory_request = build_quota_planning_inventory_request( @@ -75,7 +78,7 @@ def build_quota_planning_packet( goal_facts = ( goal_context_facts(source_status, goal_id, (agent_todo_summary or {}).get("goal_acceptance_contract")) - if source_status and source_status.get("registry") else None + if planning_horizon_goal and source_status and source_status.get("registry") else None ) try: projected = effect_runtime_result( diff --git a/loopx/execution_profile.py b/loopx/execution_profile.py index e2beb5e700..bed86f2439 100644 --- a/loopx/execution_profile.py +++ b/loopx/execution_profile.py @@ -154,6 +154,11 @@ def compact_execution_profile(value: Any) -> dict[str, Any]: if not isinstance(value, dict): return profile + if "planning_horizon_goal" in value: + if not isinstance(value["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + profile["planning_horizon_goal"] = value["planning_horizon_goal"] + if "stride_strategy" in value: from .control_plane.work_items.stride_policy_codec import normalize_stride_strategy profile["stride_strategy"] = normalize_stride_strategy(value["stride_strategy"]) @@ -249,6 +254,8 @@ def apply_goal_execution_profile_change( clear_replan_after_effective_turns: bool = False, stride_strategy: str | None = None, clear_stride_strategy: bool = False, + planning_horizon_goal: bool | None = None, + clear_planning_horizon_goal: bool = False, ) -> None: if ( clear_replan_after_completed_todos @@ -264,10 +271,14 @@ def apply_goal_execution_profile_change( raise ValueError("choose one review cadence override or clear operation") if stride_strategy is not None and clear_stride_strategy: raise ValueError("choose a stride strategy override or clear operation") + if planning_horizon_goal is not None and clear_planning_horizon_goal: + raise ValueError("choose a planning horizon Goal override or clear operation") raw = goal.get("execution_profile") profile = dict(raw) if isinstance(raw, dict) else {} if ( - stride_strategy is None + planning_horizon_goal is None + and not clear_planning_horizon_goal + and stride_strategy is None and not clear_stride_strategy and turn_granularity is None and replan_after_completed_todos is None @@ -276,6 +287,10 @@ def apply_goal_execution_profile_change( and not (clear_replan_after_completed_todos and "replan_after_completed_todos" in profile) ): return + if clear_planning_horizon_goal: + profile.pop("planning_horizon_goal", None) + if planning_horizon_goal is not None: + profile["planning_horizon_goal"] = planning_horizon_goal if clear_stride_strategy: profile.pop("stride_strategy", None) if stride_strategy is not None: diff --git a/tests/capabilities/test_machine_configuration_goal_defaults.py b/tests/capabilities/test_machine_configuration_goal_defaults.py index 408f882e80..d9e886f2b7 100644 --- a/tests/capabilities/test_machine_configuration_goal_defaults.py +++ b/tests/capabilities/test_machine_configuration_goal_defaults.py @@ -400,6 +400,7 @@ def test_goal_stride_only_uses_live_review_interval(tmp_path: Path) -> None: "stride_strategy": "evidence_conditioned_v1", }) assert options == { + "clear_execution_planning_horizon_goal": True, "execution_stride_strategy": "evidence_conditioned_v1", "clear_execution_replan_after_turns": True, "clear_execution_replan_after_todos": True, @@ -482,3 +483,41 @@ def test_stride_configuration_rejects_unsupported_or_ambiguous_versions(schema_v "schema_version": schema_version, "count_unit": "effective_turns", "count": 6, "stride_strategy": strategy, }) + + +def test_horizon_goal_configuration_opt_in_inheritance_and_clear(tmp_path): + from loopx.chat_goal_configuration_api import _goal_capability_options + from loopx.capabilities.goal_inspection import inspect_goal_capabilities + + _repo, registry_path, runtime_root = _fixture(tmp_path) + owner = build_builtin_machine_configuration_registry() + configuration = _machine_configuration() + configuration["namespaces"]["todo_replan_cadence"] = { + "schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "effective_turns", "count": 4, "stride_strategy": "off", + "planning_horizon_goal": True, + } + assert "planning_horizon_goal" not in _history_goal(registry_path, runtime_root)["execution_profile"] + preview = configure_machine_configuration(runtime_root=runtime_root, registry=owner, configuration=configuration) + configure_machine_configuration(runtime_root=runtime_root, registry=owner, + configuration=configuration, execute=True, expected_plan_revision=preview["plan_revision"]) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["planning_horizon_goal"] is True + for enabled in (False, True): + options = _goal_capability_options("todo_replan_cadence", {"planning_horizon_goal": enabled}) + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, **options) + profile = _history_goal(registry_path, runtime_root)["execution_profile"] + assert profile["planning_horizon_goal"] is enabled + assert profile["replan_after_effective_turns"] == 4 + inspected = inspect_goal_capabilities(registry_path=registry_path, runtime_root=runtime_root, + goal_id=GOAL_ID)["configuration"] + entry = next(x for x in inspected["capability_catalog"]["capabilities"] if x["capability_id"] == "todo_replan_cadence") + assert entry["current"] == {"planning_horizon_goal": enabled} + assert entry["effective_configuration"]["configuration"]["planning_horizon_goal"] is enabled + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, + clear_execution_planning_horizon_goal=True) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["planning_horizon_goal"] is True + before = registry_path.read_bytes() + with pytest.raises(ValueError, match="planning_horizon_goal"): + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, + execution_planning_horizon_goal="true") + assert registry_path.read_bytes() == before diff --git a/tests/control_plane/test_quota_planning_horizon.py b/tests/control_plane/test_quota_planning_horizon.py index f71f1bfa8c..97afd36a14 100644 --- a/tests/control_plane/test_quota_planning_horizon.py +++ b/tests/control_plane/test_quota_planning_horizon.py @@ -174,6 +174,7 @@ def test_preselection_horizon_and_replan_share_current_goal_sources(tmp_path: Pa }]})) status = planning_horizon_strategic_context_status() status.update(registry=str(registry), runtime_root=str(runtime)) + status["attention_queue"]["items"][0]["project_asset"]["execution_profile"] = {"planning_horizon_goal": True} def read_packet(): return build_quota_should_run(status, goal_id=GOAL_ID, agent_id=AGENT_ID, turn_instance_id="turn-goal-horizon") @@ -218,6 +219,7 @@ def test_real_cli_exposes_goal_before_selection_without_granting_delivery(tmp_pa call, runtime, index = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) index.write_text("") + call("configure-goal", "--goal-id", GOAL, "--execution-planning-horizon-goal", "--execute") for number in range(2): call("todo", "add", "--goal-id", GOAL, "--role", "agent", "--claimed-by", AGENT, "--text", f"Validate independent source {number}.", "--task-class", "advancement_task", @@ -402,3 +404,19 @@ def test_task_graph_reads_deferred_nodes_from_the_shared_planning_source() -> No for todo_id in node["refs"].get("todo_ids", []) } assert deferred["todo_id"] in todo_ids + + +@pytest.mark.parametrize("enabled", [False, None]) +def test_disabled_goal_projection_does_not_read_goal_sources(monkeypatch, enabled): + from loopx.control_plane.work_items import replan_context_codec + def forbidden(*args, **kwargs): + raise AssertionError("disabled horizon must not load Goal documents") + monkeypatch.setattr(replan_context_codec, "goal_context_facts", forbidden) + status = planning_horizon_strategic_context_status() + status["registry"] = "/unreadable/registry.json" + if enabled is not None: + status["attention_queue"]["items"][0]["project_asset"]["execution_profile"] = {"planning_horizon_goal": enabled} + packet = build_quota_should_run(status, goal_id=GOAL_ID, agent_id=AGENT_ID, + turn_instance_id="horizon-default-off") + assert "core_goal" not in packet["planning_horizon"] + assert packet["planning_horizon"]["selection_contract"]["horizon_changes_selection"] is False diff --git a/tests/test_chat_goal_configuration_api.py b/tests/test_chat_goal_configuration_api.py index 6172238005..5d3504a11a 100644 --- a/tests/test_chat_goal_configuration_api.py +++ b/tests/test_chat_goal_configuration_api.py @@ -324,6 +324,7 @@ def test_machine_inheritable_goal_capabilities_can_clear_their_overrides() -> No "clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True, + "clear_execution_planning_horizon_goal": True, } assert _goal_capability_options("change_quality_qualification", None) == { "clear_change_quality_configuration": True