diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index a33931a0a7..c87ceb214e 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -56,9 +56,9 @@ export function parseEditableCapabilityJson( export function replanCadenceEditorValue(value: unknown): unknown { const record = configurationObject(value); if (Object.hasOwn(record, "completed_todos") && !Object.hasOwn(record, "count_unit")) { - return { count_unit: "completed_todos", count: record.completed_todos }; + return { count_unit: "completed_todos", count: record.completed_todos, stride_strategy: "off" }; } - return value; + return { ...record, stride_strategy: record.stride_strategy ?? "off" }; } /** A v0 storage selector did not enable creation; opening Settings must not. */ 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 df82a8e38e..235c44f1d6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -22,7 +22,7 @@ const capabilityCopy: Record> = { displayName: "Model and executor", description: "Guides the steward executor, model, and selection boundary for this machine. A pinned route blocks substitution; a flexible pool permits only authorized fallback.", }, - todo_replan_cadence: { displayName: "Goal review cadence", description: "Configures the Goal review cadence." }, + todo_replan_cadence: { displayName: "Goal review and stride", description: "Set review frequency and optionally choose a versioned stride strategy for work scope and route changes." }, change_quality_qualification: { displayName: "Change quality qualification", description: "Prepares a provider-neutral review packet, allows at most one policy-authorized safe-fix pass, and can require an exact-diff receipt.", @@ -89,7 +89,7 @@ const capabilityCopy: Record> = { displayName: "模型与执行器", description: "配置本机管家的执行器、模型与选择边界;锁定路径禁止替代,灵活池只允许在已授权范围内回退。", }, - todo_replan_cadence: { displayName: "Goal 复核周期", description: "配置 Goal 的复核周期。" }, + todo_replan_cadence: { displayName: "Goal 复盘与步幅", description: "设置复盘周期,可选用带版本的步幅策略,调节工作大小与路线变化。" }, change_quality_qualification: { displayName: "变更质量验证", description: "生成与 Provider 无关的审阅包,最多允许一次策略授权的安全修复,并可要求精确 diff 回执。", @@ -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" } }, + 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." }, coordinator_agent_id: { label: "Coordinator Agent", description: "Use an already registered Agent id; leave blank to disable coordination." }, @@ -191,6 +192,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" } }, + 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: "每行填写一个有边界、可公开的职责域。" }, coordinator_agent_id: { label: "协调 Agent", description: "填写一个已经注册的 Agent ID;留空表示关闭协调。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 7b940507c9..a240b624cd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1064,7 +1064,7 @@ const en = { "machine.periodicReportActivationDescription": "This is not a weekly timer. When LoopX validates a Goal stage completion, an Agent prepares and freezes the report, then automatically delivers it through the configured Goal Channel. The enabled subscription is standing delivery authority; failures and route drift stop closed for repair.", "machine.changeQualityActivation": "Qualification is a quality gate, not new authority", "machine.changeQualityActivationDescription": "Goals that inherit this policy qualify their exact final diff. safe_fix allows at most one bounded fix pass inside existing write authority; this setting never grants file, permission, or merge authority.", - "machine.replanCadenceActivation": "Review cadence changes timing, not execution authority", + "machine.replanCadenceActivation": "Review timing and stride policy do not grant execution authority", "machine.replanCadenceActivationDescription": "Goals without an explicit override read this threshold before the next review decision. It does not create Turns, spend quota, or grant authority.", "machine.preview": "Review exact machine change", "machine.previewChanges": "Preview changes", @@ -2467,7 +2467,7 @@ const zhCN: Record = { "machine.periodicReportActivationDescription": "这不是每周定时器。当 LoopX 验证 Goal 已完成一个阶段时,Agent 会生成并冻结报告,随后通过配置的 Goal Channel 自动发送。启用此订阅即授予持续投递权;发送失败或路由漂移会 fail closed 并进入修复。", "machine.changeQualityActivation": "质量验证是质量门禁,不是新增授权", "machine.changeQualityActivationDescription": "继承该策略的 Goal 会验证最终精确 diff。safe_fix 只允许在既有写入权限内执行至多一次有界修复;此设置不会授予文件、权限或合并权。", - "machine.replanCadenceActivation": "复核周期只改变时机,不改变执行权限", + "machine.replanCadenceActivation": "复盘周期与步幅策略,不扩大执行权限", "machine.replanCadenceActivationDescription": "没有显式覆盖的 Goal 会在下一次复核决策前读取此阈值;它不会创建 Turn、消耗配额或授予权限。", "machine.preview": "审阅机器配置变更", "machine.previewChanges": "预览变更", diff --git a/benchmark/edgebench/README.md b/benchmark/edgebench/README.md index 8088511c63..36eee7b417 100644 --- a/benchmark/edgebench/README.md +++ b/benchmark/edgebench/README.md @@ -439,6 +439,25 @@ other replan reasons may trigger sooner. Both runtime and worker-profile receipt record the resolved cadence. Only newly launched attempts use the new default; keep existing runs and archived settings pinned. +### Optional structured stride strategy + +Add `--stride-strategy evidence_conditioned_v1` to a new `heartbeat-resume` +or `heartbeat-explore` trial to opt into the existing Goal review strategy. +The runner writes the option together with the resolved cadence and checks +the real Goal configuration before planning or execution. The install, +worker-profile and runtime receipts record an explicitly selected version. +Omitting the option preserves ordinary off commands and receipt shapes; +`--stride-strategy off` explicitly selects the disabled treatment. Plain and +native-goal profiles reject this option before creating a trial. Unknown +versions also fail before registration or execution. + +The [Goal review contract](../../docs/reference/protocols/goal-vision-replan-contract-v0.md) +defines structured proposals and the three independently controlled axes. +Strategy configuration grants no new execution authority, evaluator feedback, +budget or submissions. Freeze it with exact sources under a new run ID; do not +change live workers. Receipts qualify requested configuration, while actual +review, proposal adoption and task gains require trajectory evidence. + ## Optional short-context treatment Add `--turn-envelope` to an otherwise identical `heartbeat-resume` or diff --git a/benchmark/edgebench/export_report.py b/benchmark/edgebench/export_report.py index a06bff4b16..d8be072b56 100644 --- a/benchmark/edgebench/export_report.py +++ b/benchmark/edgebench/export_report.py @@ -215,6 +215,11 @@ def export(runs_root: Path, selection: list, output: Path, *, observed_at: str): if row.get("runner_revision") != receipt["runner_commit"]: raise ValueError("Board and runner revision disagree") _validate_feedback_payload(receipt, profile) + if receipt.get("stride_strategy") != profile.get("stride_strategy"): + raise ValueError("Profile disagrees with runtime: stride_strategy") + if "stride_strategy" in receipt: + from loopx.control_plane.work_items.stride_policy_codec import normalize_stride_strategy + normalize_stride_strategy(receipt["stride_strategy"]) if number(final["best_score"]) != number(receipt["best_score"]): raise ValueError("Final and runtime scores disagree") score_entries = [ @@ -273,6 +278,9 @@ def export(runs_root: Path, selection: list, output: Path, *, observed_at: str): if "feedback_payload" in receipt: config["runner"]["feedback_payload"] = receipt["feedback_payload"] config["worker_profile"]["feedback_payload"] = profile["feedback_payload"] + if "stride_strategy" in receipt: + config["runner"]["stride_strategy"] = receipt["stride_strategy"] + config["worker_profile"]["stride_strategy"] = profile["stride_strategy"] row["status"], row["observed_at"] = "completed", observed_at row["metrics"]["best_score"] = { "value": final["best_score"], diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index 46fb130e6e..05b2bf6c3b 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -20,6 +20,7 @@ from sforge.harness.task_spec import make_task_spec from benchmark.runtime.codex import TASK_ENTRIES +from loopx.control_plane.work_items.stride_policy_codec import stride_strategy_options from benchmark.runtime.sforge import ( DEFAULT_TIMEOUT_SECONDS, PROFILES, SForgeWorker, ) @@ -116,6 +117,8 @@ def main(argv=None): help="Heartbeat default: 6 settled effective work Turns") cadence.add_argument("--replan-after-todos", type=int, choices=range(1, 6), help="Explicit completed-Todo cadence ablation for heartbeat profiles") + parser.add_argument("--stride-strategy", choices=stride_strategy_options(), + help="Explicit heartbeat Goal-review strategy; omitted keeps the ordinary off treatment") parser.add_argument("--eval-interval", type=int, help="Native default: 0 (no periodic evaluation); other modes use task defaults; explicit 0 disables") parser.add_argument("--submission-cooldown", type=int, @@ -137,6 +140,8 @@ def main(argv=None): if (args.replan_after_turns is not None or args.replan_after_todos is not None ) and not args.worker.startswith("heartbeat-"): parser.error("Replan cadence requires a heartbeat worker") + if args.stride_strategy is not None and not args.worker.startswith("heartbeat-"): + parser.error("--stride-strategy requires a heartbeat worker") # One directory is one attempt: never reuse native registration or overwrite # source/profile evidence after an ambiguous launch. trial = args.log_dir / "runs" / args.run_id / args.task @@ -189,7 +194,9 @@ def main(argv=None): task_entry=args.task_entry, turn_envelope=args.turn_envelope, replan_after_turns=args.replan_after_turns, - replan_after_todos=args.replan_after_todos) + replan_after_todos=args.replan_after_todos, + **({"stride_strategy": args.stride_strategy} + if args.stride_strategy is not None else {})) if args.api_proxy_url: agent.default_api_base_url = args.api_proxy_url logger = logging.getLogger("edgebench-runtime") @@ -233,6 +240,8 @@ def main(argv=None): if agent.replan_after_turns is not None else {}), **({"replan_after_completed_todos": agent.replan_after_todos} if agent.replan_after_todos is not None else {}), + **({"stride_strategy": args.stride_strategy} + if args.stride_strategy is not None else {}), } receipt_path = trial / "runtime-receipt.json" receipt_path.write_text(json.dumps(receipt, indent=2)) diff --git a/benchmark/runtime/harbor.py b/benchmark/runtime/harbor.py index 56d8c29a1d..ee5e8fe435 100644 --- a/benchmark/runtime/harbor.py +++ b/benchmark/runtime/harbor.py @@ -16,6 +16,8 @@ from harbor.models.trajectories import FinalMetrics, Trajectory from harbor.utils.trajectory_utils import format_trajectory_json +from loopx.control_plane.work_items.stride_policy_codec import normalize_stride_strategy + from .codex_offline import CodexOffline, stage_portable_python from .codex import DEFAULT_REPLAN_AFTER_TURNS, Execution @@ -56,6 +58,7 @@ def __init__( scheduler_timeout_sec=5080, replan_after_todos=None, replan_after_turns=None, + stride_strategy=None, task_entry=None, planning_timeout_sec=300, turn_envelope=False, @@ -95,6 +98,10 @@ def __init__( self.replan_after_todos = int(3 if replan_after_todos is None else replan_after_todos) if not 1 <= self.replan_after_todos <= 5: raise ValueError("replan_after_todos must be between 1 and 5") + if stride_strategy is not None and not self.execution.uses_loopx: + raise ValueError("stride_strategy requires a LoopX execution mode") + self.stride_strategy = (normalize_stride_strategy(stride_strategy) + if stride_strategy is not None else None) self._task_path = "/usr/local/bin:/usr/bin:/bin" self._phase_number = 0 self._seeded_todo_id: str | None = None @@ -113,9 +120,10 @@ def _replan_configuration(self) -> tuple[str, str, int]: return ("replan_after_completed_todos", "--execution-replan-after-todos", self.replan_after_todos) - def _replan_receipt(self) -> dict[str, int]: + def _replan_receipt(self) -> dict[str, int | str]: key, _, value = self._replan_configuration() - return {key: value} + return {key: value, **({"stride_strategy": self.stride_strategy} + if self.stride_strategy is not None else {})} @staticmethod def name() -> str: @@ -362,6 +370,8 @@ async def _prepare_phase( self, environment: BaseEnvironment, instruction: str, *, cwd: str ) -> None: key, option, expected = self._replan_configuration() + stride_args = (["--execution-stride-strategy", self.stride_strategy] + if self.stride_strategy is not None else []) pending = await environment.exec( command=f"test -e {_LOOPX_RUNTIME}/benchmark-pending-turn.json" ) @@ -407,6 +417,7 @@ async def _prepare_phase( "trial-workspace", option, str(expected), + *stride_args, "--agent-work-mode", f"{_AGENT_ID}=active", "--execute", @@ -423,6 +434,7 @@ async def _prepare_phase( _GOAL_ID, option, str(expected), + *stride_args, "--execute", ], cwd=cwd, @@ -440,6 +452,11 @@ async def _prepare_phase( raise RuntimeError( f"replan cadence readback mismatch for {key}: expected {expected}, got {configured!r}" ) + strategy = configured_state.get("execution_profile", {}).get("stride_strategy", "off") + if strategy != (self.stride_strategy or "off"): + raise RuntimeError( + f"stride strategy readback mismatch: expected {self.stride_strategy or 'off'}, got {strategy!r}" + ) async def _seed_phase(self, environment: BaseEnvironment, *, cwd: str) -> None: text = ( diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index 9fbd211258..b2bfff5e0f 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -90,7 +90,8 @@ def __init__(self, config, *, profile: str, cwd: str, task_entry: str | None = None, turn_envelope: bool = False, replan_after_turns: int | None = None, - replan_after_todos: int | None = None): + replan_after_todos: int | None = None, + stride_strategy: str | None = None): super().__init__(config) if profile not in PROFILES: raise ValueError("Unknown benchmark worker profile") @@ -129,6 +130,12 @@ def __init__(self, config, *, profile: str, cwd: str, replan_after_turns = DEFAULT_REPLAN_AFTER_TURNS self.replan_after_turns = replan_after_turns self.replan_after_todos = replan_after_todos + if stride_strategy is not None and not profile.startswith("heartbeat-"): + raise ValueError("stride_strategy requires a heartbeat profile") + 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) + self.stride_strategy = stride_strategy self.profile, self.cwd = profile, cwd if feedback not in FEEDBACK_MODES: raise ValueError("Unknown feedback mode") @@ -214,6 +221,7 @@ def _install_worker(self, backend, handle, log_dir, logger): turn_envelope=self.turn_envelope, replan_after_turns=self.replan_after_turns, replan_after_todos=self.replan_after_todos, + stride_strategy=self.stride_strategy, task_objective=self.task_objective, ) asyncio.run(self.runtime.install(self.environment)) diff --git a/benchmark/tests/test_edgebench_report.py b/benchmark/tests/test_edgebench_report.py index 79dc9521b5..d0b7993210 100644 --- a/benchmark/tests/test_edgebench_report.py +++ b/benchmark/tests/test_edgebench_report.py @@ -11,6 +11,42 @@ from benchmark.edgebench.export_report import digest, export, verify, write +@pytest.mark.parametrize("strategy", [None, "off", "evidence_conditioned_v1"]) +def test_report_preserves_observed_stride_version_and_absence(trial, strategy): + runs, selection, output, task = trial + task = runs / "run" / "case" + for name in ("runtime-receipt", "worker-profile"): + path = task / (name + ".json") + value = json.loads(path.read_text()) + if strategy is not None: + value["stride_strategy"] = strategy + value["worker" if name == "runtime-receipt" else "profile"] = "heartbeat-resume" + 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("stride_strategy") == strategy + assert ("stride_strategy" in config[field]) is (strategy is not None) + + +@pytest.mark.parametrize("runner,profile", [ + ("evidence_conditioned_v1", "off"), + ("evidence_conditioned_v1", None), + ("future_policy", "future_policy"), +]) +def test_report_rejects_unbound_or_unknown_stride_version(trial, runner, profile): + runs, selection, output, task = trial + task = runs / "run" / "case" + for name, strategy in (("runtime-receipt", runner), ("worker-profile", profile)): + path = task / (name + ".json") + value = json.loads(path.read_text()) + if strategy is not None: + value["stride_strategy"] = strategy + write(path, value) + with pytest.raises(ValueError, match="stride_strategy"): + export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") + + @pytest.fixture def trial(tmp_path): row = dict( diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index 1fb2453cfb..4651474de4 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -454,6 +454,9 @@ def test_envelope_rejects_incompatible_sforge_worker(profile): ("native-goal", ["--replan-after-todos", "3"]), ("heartbeat-resume", ["--replan-after-turns", "3", "--replan-after-todos", "3"]), ("heartbeat-explore", ["--replan-after-todos", "0"]), + ("official", ["--stride-strategy", "off"]), + ("native-goal", ["--stride-strategy", "evidence_conditioned_v1"]), + ("heartbeat-resume", ["--stride-strategy", "future_policy"]), ]) def test_edgebench_rejects_invalid_profile_settings_before_creating_trial(tmp_path, profile, options): pytest.importorskip('sforge') diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index edc3dddbd0..e9e3e2787d 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -408,6 +408,44 @@ async def no_pending(**kwargs): assert agent._replan_receipt() == {field: value} +def test_requested_stride_requires_configuration_readback_before_execution(tmp_path, monkeypatch): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + + agent = BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", + stride_strategy="evidence_conditioned_v1") + + async def prepared(*args, **kwargs): + return True + + async def no_pending(**kwargs): + return SimpleNamespace(return_code=1) + + async def wrong_strategy(*args, **kwargs): + return {"after": {"execution_profile": { + "replan_after_effective_turns": 6, "stride_strategy": "off", + }}} + + monkeypatch.setattr(agent, "_registry_exists", prepared) + monkeypatch.setattr(agent, "_write_task_document", prepared) + monkeypatch.setattr(agent, "_loopx", wrong_strategy) + with pytest.raises(RuntimeError, match="stride strategy readback mismatch"): + asyncio.run(agent._prepare_phase(SimpleNamespace(exec=no_pending), "Synthetic task", cwd=str(tmp_path))) + + +@pytest.mark.parametrize("settings", [ + {"stride_strategy": "future_policy"}, + {"stride_strategy": True}, + {"execution_mode": "plain", "stride_strategy": "off"}, +]) +def test_stride_rejects_unknown_version_and_incompatible_mode(tmp_path, settings): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + + with pytest.raises(ValueError, match="stride_strategy"): + BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", **settings) + + def test_staged_snapshot_keeps_observed_commit_when_branch_moves(tmp_path, monkeypatch): pytest.importorskip("harbor") import tarfile diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index 44d7a99eac..5dbc329cd2 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -569,6 +569,9 @@ def test_sforge_planning_failed_host_has_no_execution_handoff(planning_env): ({}, "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), + ({"stride_strategy": "evidence_conditioned_v1"}, "replan_after_effective_turns", 6), + ({"stride_strategy": "evidence_conditioned_v1", "replan_after_todos": 2}, "replan_after_completed_todos", 2), ]) def test_runner_cadence_persists_through_real_configure_goal( planning_env, tmp_path, monkeypatch, settings, field, value @@ -609,4 +612,7 @@ async def no_pending(**kwargs): other = ("replan_after_completed_todos" if field == "replan_after_effective_turns" else "replan_after_effective_turns") assert other not in profile - assert agent._replan_receipt() == {field: value} + strategy = settings.get("stride_strategy") + assert profile.get("stride_strategy", "off") == (strategy or "off") + assert agent._replan_receipt() == {field: value, **({"stride_strategy": strategy} + if strategy is not None else {})} diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index e290d5629d..5b12d0d130 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -6,9 +6,9 @@ | Supersedes / closes | none | | Date | 2026-08-15 | | Authors | LoopX maintainers | -| Scope | Effect feedback, bounded delivery, authority escalation, model qualification, and long-horizon efficiency | +| Scope | Effect feedback, bounded delivery, research scope, authority escalation, and long-horizon outcome/cost | | Tracking issue | [#3203](https://github.com/huangruiteng/loopx/issues/3203) | -| Source baseline | LoopX `8b8e7b248` | +| Source baseline | LoopX `d03863fd0`; source-level decision followthrough refinement below | > Language note: the > [Chinese version](./hierarchical-agent-stride-control-v0.zh-CN.md) and this @@ -39,11 +39,22 @@ gate, and human authority. An inner result may force an early delivery settlement; a delivery result may force an early authority escalation. A scheduler tick alone must not manufacture either semantic transition. -This RFC proposes a measurement-first program. The smallest useful slice is a -read-only, provider-neutral stride observation derived from existing receipts. -It should characterize current standard and fine-grained execution before any -runtime automatically widens or narrows a stride. Adaptive control begins in -shadow mode and is promoted only after repeated, stable qualification. +Research makes delivery stride a **vector**: work-slice size, distance from the +current mechanism, and validation breadth. These coordinates can move +independently. A long +slice can perform many correct local checks without obtaining new information +about the remaining goal gap. Fewer control events cannot fix that failure. +Conversely, a small late gain near a task's attainable limit may be useful; +elapsed time or a flat score alone cannot select a larger experiment. + +The delivery controller therefore closes **decision -> actual work -> evidence +-> next decision**, while the existing owners retain execution and settlement. +The current bounded implementation returns the previous accepted Vision path +and subsequent recorded progress to replan, with goal gain explicitly unknown. +This is decision input and Agent guidance, not adaptive scheduling, a result +adoption gate or proof of research utility. Automatic policies remain opt-in +proposals requiring repeated qualification. Research-state branches provide the +counterfactual test without discarding the accumulated work. The core judgment is: @@ -162,6 +173,7 @@ the research direction. | Delivery | Execution profiles distinguish standard and fine-grained Todo contracts; fine-grained mode uses a coherent-slice turn budget and evidence-sensitive successor creation. Every admitted heartbeat still performs full durable settlement. | | Continuation | In addition to same-Turn controller dispositions, a typed delivery-continuity reducer can preserve the latest accountable `outcome_progress` Todo across heartbeat wakes while that same Todo remains open, actionable, capability-ready, and owned by the same agent. | | Progress | Typed progress observations, repeat detection, semantic replan closure, and evidence projections can distinguish material delta from maintenance. | +| Review feedback | The replan context source implementation can return the last accepted evidence-linked Vision path and subsequent recorded progress references. History append order and Goal/Agent/Turn lineage bind this input; missing execution and goal gains remain unknown. It does not certify that a proposed intervention ran. | | Research | The [Research Exploration Control Plane RFC](./research-exploration-control-plane-v0.md) defines an optional typed knowledge frontier and composition experiments. | | Authority | Goal vision, user gates, permission policy, and peer/supervisor boundaries remain separate from execution and scheduling. | @@ -169,12 +181,13 @@ the research direction. | Gap | Consequence | |---|---| -| No shared stride vocabulary | Host, Todo, scheduler, and human-attention changes are discussed as one generic "turn length" problem. | -| No cross-layer observation | LoopX cannot tell whether low throughput came from model/tool roundtrips, over-frequent settlement, or authority churn. | +| Incomplete outcome-linked observations | Read-only M1 counts and review followthrough exist, but they cannot explain task gains, model/tool cost, settlement cost or authority churn without provider evidence. | +| No qualified research-scope policy | Continued local tuning can be mistaken for broad exploration; generic progress identifiers do not establish mechanism novelty or useful information. | | No model capability profile for stride | A fast tool-using model and a slower protocol-reliable model receive mostly static execution contracts. | | No semantic split/merge qualification | Todo granularity is configured or prompted, but not evaluated against observed decision changes and settlement cost. | | No interruption-value model | Periodic reports and genuine authority changes are not measured separately. | -| No adaptive promotion path | There is no shadow recommendation -> bounded experiment -> promoted policy lifecycle for stride tuning. | +| No qualified adaptive promotion path | Shadow input does not yet establish model adoption, matched continuation benefit or a promoted model/work-class policy. | +| No qualified research-state branch | Code snapshots alone cannot preserve conversation, accumulated evidence and evaluation opportunities for a fair continuation comparison. | ## 5. The Three-Layer Model @@ -482,6 +495,188 @@ through existing task/capability surfaces. CLI readback alone is a partial slice. Scientific usefulness follows the independent qualification in [research decisions](research-exploration-control-plane-v0.md#115-evidence-conditioned-experiment-decisions-proposed-refinement). +### 7.6 Research scope and evidence-conditioned control + +Research stride has three independently chosen coordinates. The first two are +the main controls; the third bounds the evidence and cost needed to act: + +- **Work-slice size:** the coherent outcome, scope and cost admitted before the + next decision. This includes Todo granularity, splitting a broad experiment + and continuing a supported route across a larger slice. Todo text length or + number of files is not size; a larger slice must retain an observable + postcondition and normal per-Turn settlement. + +- **Mechanism distance:** local parameter/implementation tuning versus a + materially different representation, model, search neighborhood or causal + explanation. A renamed hypothesis, a new file or more tests is not distance. +- **Evidence breadth:** a cheap discriminating probe, a comparable pilot, then + the validation needed for actual adoption. More coverage is useful only when + it resolves an uncertainty that matters to the next decision. + +Replan reviews this vector without becoming another authority owner. +Changing an agent-created method or provisional candidate rule inside the +authorized goal is an ordinary research decision. Changing the task, acceptance, +feedback protocol, budget, permission or stop conditions requires its existing +owner. A request to explore more broadly never grants access to hidden data or +permits rewriting the metric after failure. + +| Observed basis | Next decision to compare | +|---|---| +| A new result is missing, invalid or incomparable | Repair the observation or choose a probe with known coverage; keep utility unknown. | +| One valid candidate loses or results conflict within uncertainty | Protect the incumbent; scope the negative evidence and compare replication, another uncovered condition and a different mechanism. Do not retire a family from one loss. | +| Local checks succeed but do not explain the authoritative goal gap | Compare a bounded mechanism-changing probe with continued tuning. Name the distinguishing prediction and how it reaches the executed candidate. | +| Known headroom is small or variance dominates the expected effect | Prefer a justified uncertainty reduction or a low-cost improvement; require evidence before spending a large new search budget. A theoretical ceiling is not a feasible optimum. | +| No reasonable in-scope probe remains under the current evidence and stops | Record scoped exhaustion or a concrete hold through the existing owner; do not manufacture activity or claim the goal achieved. | + +The model chooses; generic code must not derive these scientific rows from prose +substrings, a score slope, elapsed hours or a fixed failure count. The provider +owns metric meaning, comparison validity, noise and task-specific headroom. +Explore retains explicitly declared mechanism relations and scoped findings. +The existing typed replan owner delivers the goal, evidence and prior path; +ordinary task/successor and Vision writeback own the decision and its adoption. + +At each decision boundary: + +1. Reconcile the prior path with subsequent work, current artifact and + authoritative outcomes. Identify what actually ran, what changed knowledge, + what was adopted and what remains unknown. A progress receipt is insufficient. +2. Compare continuation, a discriminating alternative and justified replication + when relevant. Rank expected goal/information gain against complete marginal + cost: model input/output, tool work, validation, control and opportunity cost. +3. Choose one bounded next probe with its distinguishing prediction, comparison, + incumbent protection, adoption criterion and cost/stop bounds. A larger + mechanism change can start with a small pilot; it need not start with a large + validation sweep. +4. Execute through the normal admitted task. Bind evidence to the tested input + and exact candidate; retain failed, negative and uncertain results distinctly. +5. At the next review, use the new observation to retain, revise or stop the + scoped route. State why a repeat can resolve a new uncertainty. Do not reset + other trigger counters merely because the prompt was read. + +The source-level `replan_context.decision_followthrough` is the first reusable +feedback slice. It references the last accepted evidence-linked Vision path and +at most six later distinct progress fingerprints, retaining complete recorded +counts and omissions. It uses immutable append order rather than timestamps, +excludes other agents and explicitly retired Goal instances, and resolves refs +through the existing history read. An absent or corrupt basis is not replaced +with invented evidence. The field never qualifies execution, scientific novelty, +utility, effective-Turn counts or acceptance; these remain independent checks. +No new scheduler, selection rule, quota debit or per-result settlement is added. + +#### An iterative strategy and engineering boundary + +The first strategy is **model-guided, evidence-conditioned vector adjustment**. +It proposes increasing, retaining or reducing each coordinate, with a reason +and a falsifiable next probe. It does not optimize a universal novelty score: +mechanism distance is relative to a declared reference method and goal, while +slice size is relative to the next useful postcondition and decision risk. + +| Basis | Work slice | Mechanism distance | Evidence breadth | +|---|---|---|---| +| A supported method yields repeatable useful progress | Increase coherent work when decision risk permits | Retain unless a remaining gap justifies alternatives | Keep required checks; avoid redundant sweeps | +| Current method leaves a substantive gap under applicable evidence | Start with a small discriminating pilot | Compare a larger departure with continuation | Narrow to the observation that distinguishes the routes | +| Results conflict or expected gains are below uncertainty | Reduce work until the decision is clearer | Retain or compare alternatives without declaring refutation | Increase only a justified replication/uncertainty probe | +| A larger departure produces attributable useful results | Expand the adopted method's next slice | Retain the useful departure, not novelty for its own sake | Expand validation needed for adoption | +| Decision-relevant failure or an authority boundary appears | End/split the current slice through its owner | Reassess within the existing goal | Validate the specific failure; never widen authority | + +This table guides a model decision; it is not a prose classifier or a fixed +numeric policy. The engineering design has four replaceable boundaries: + +1. **Observation adapters** produce exact candidate/result, validity/uncertainty, + coverage and cost facts. Domain providers own their meanings; they cannot + write generic policy or confer execution permission. +2. **Strategy** consumes one source-bound review basis and proposes the stride + vector, next probe and expected benefit/cost. Version and record the strategy + with the experiment. Start with the table above; deterministic heuristics or + a learned strategy can later replace this function under the same inputs. + Do not build a plugin registry or train a controller before a second real + qualified strategy needs that seam. +3. **Admission/adoption** validates the proposal through existing Todo, Vision, + replan, budget and authority owners. Work-slice changes use normal split/ + continuation/successor operations. A route decision cannot execute a + successor during an original Turn's outstanding settlement. +4. **Feedback** returns actual execution, results, retained artifact and complete + cost to the next review. Proposal, adoption and utility remain separately + replayable; missing feedback never becomes success. + +The present source slice implements the fourth boundary's recorded-decision +input and the optional TypeScript strategy component `evidence_conditioned_v1`. +The first version accepts a model-assessed evidence basis and deterministically +derives three relative `grow | hold | shrink` coordinates. A versioned +`agent_vision.stride_decision` carries that vector, the admitted obligation and +observed context ids, and the next probe/adoption/cost commitments. Vision +authoring validates the declared shape and vector before mutation. Enabled fresh +Vision path reviews require the structured proposal; other source-specific exits +remain unchanged. Existing +Todo/Next Action admits work, and the next review returns the decision with +actual observations. This is structured proposal control, not an automatic +executor or a machine classifier of research merit. Its shared configuration +vocabulary is `off | evidence_conditioned_v1`; unknown +versions fail before mutation. The existing Goal review capability editor and +CLI expose the version for a whole Goal override or inherited device default. +`off` preserves ordinary evidence identity/selection and the five existing review +instructions. Enablement adds the source-bound followthrough input and separate +work-slice/mechanism-distance/validation-breadth advice. It has no automatic +vector actuator. Use the existing bounded `path_delta` and task Next Action to +record the first proposals; do not create another policy/state database. The explicit versioned attachment prevents parsing prose as a control vector. +Future strategy versions preserve default-off parity, bounded fields and +separate proposal/adoption authority; do not silently reinterpret old decisions. Promote each strategy after same-state ablations establish +adoption and full-cost benefit; change one coordinate before studying their +interaction. Six effective work Turns remains the existing review default, +not the size of a Todo, experiment or mechanism change. + +Exploratory iteration may run only strategy-enabled test arms and reuse historical +baselines as descriptive references. A fresh concurrent baseline is not required +for every iteration; such runs establish route adoption and useful iteration +signals, not an isolated causal effect or promotion qualification. Reserve +matched continuation and replication for decisions that need those claims. + +### 7.7 Research-state branching and matched continuation + +A restorable research point combines **conversation position, research evidence, +code/candidate artifacts and observation opportunities**. A code archive or +trajectory prefix alone is not that point. Capture and verification reuse the +existing [backup/recovery owners](complete-state-recovery-v0.md), Host session +owner and artifact provider. This RFC supplies the research experiment's +consistency and comparison requirements, not a second restore authority. + +Before capture, declare the point by elapsed research budget or a semantic event; +do not choose it retrospectively for a favorable score. A later point such as +ten hours is an experiment setting, not a product default or automatic pivot. +Record the actual boundary and any skew: + +- native conversation cursor and a Host-qualified export/fork path; copied log + text does not establish a resumable session; +- settled LoopX research evidence, original goal/priorities, prior decisions, + scoped negative results and unresolved questions; +- exact working code, necessary authorized untracked files, incumbent artifact + and its qualifying result, kept separately from unmeasured candidates; +- source/model/tool/evaluator pins and solver-visible feedback already consumed; +- remaining total budget, accepted submission history and next feedback + opportunity under the original protocol. + +An inert verification unit grants no execution authority. The destination uses +fresh experiment/Host identities and the existing Goal/session/state admission +paths. Historical leases, quota, timers, pending effects and submission tokens +cannot become destination authority. Reconcile unknown effects by original +identity before any replay. A Host without a qualified fork reports that gap; +an evidence-summary restart is a different treatment, not exact continuation. + +Validate digest/cursor/artifact consistency, partial capture, missing files, +changed inputs, duplicate restore, conflicting writer, stopped source and lost +result delivery before running a matched study. Resume must demonstrate actual +use of the captured evidence and code, then useful new work. Successful tar +verification or session opening alone is not acceptance. + +For the research ablation, branch the **same** qualified point into unchanged +continuation and evidence-conditioned scope review, with equal continuation +budgets and resources. Change one control factor; preserve feedback opportunity +counts and cooldown timing through the evaluator owner. Label session restart, +summary replacement, source upgrades or policy bundles as additional factors. +Keep all branches and invalid/negative results; compare adopted native outcomes, +information gained and full continuation cost. The result qualifies the scoped +intervention, not all LoopX behavior or an optimal universal stride. + ## 8. Measurement Model The first implementation should measure before it controls. @@ -673,6 +868,18 @@ The first study should avoid a full three-dimensional combinatorial grid. - event-driven authority escalation; - identical effect and delivery contracts. +**Stage E: compare research scope from the same captured state** + +- unchanged continuation versus the §7.6 evidence-conditioned scope review; +- the same qualified §7.7 conversation/evidence/code point and feedback history; +- equal continuation resources, budget and evaluation opportunities; +- distinct native outcome, information/adoption and complete-cost measures; +- a small recovery/adoption pilot first, repeated cells before promotion. + +Choose tasks with room for a genuinely different mechanism and retain a +near-ceiling or noisy case as a limit check. Do not select checkpoints or omit +branches after observing their scores. + Only after these stages identify interactions should a factorial experiment combine promoted profiles. @@ -687,21 +894,18 @@ keyword. ## 11. Smallest Useful Implementation Slice -The first implementation is deliberately narrow and reversible: - -1. Add one public architecture vocabulary for effect, delivery, and authority - stride. -2. Derive a read-only stride observation from existing Turn, settlement, Todo, - quota, and gate receipts where available. -3. Add an offline evaluator that reports layer metrics and mismatch signals. -4. Characterize standard and fine-grained LoopX profiles with public-safe - fixtures and at least one real model behavior qualification path. -5. Emit shadow recommendations only. Do not alter Todo selection, scheduler - frequency, effect execution, or user notification. +M1 read-only observation remains available. The next complete, reversible slice +returns prior accepted decisions and subsequent evidence to the existing replan +entry, with the guidance in §7.6. It does not add a classifier, scheduler or +acceptance gate. Qualify the real CLI/Host projection, immutable reference +readback, missing/corrupt/stale inputs and unchanged semantic discharge first. -The first slice should extend the nearest existing Turn/status read model. It -should not create a new built-in capability, executor, scheduler, or generic -policy framework. +Then qualify one research-state capture/continuation profile under §7.7. Keep +verification, executable continuation, model adoption and scientific benefit as +separate outcomes. Matched continuation pilots judge whether scope review +produces discriminating experiments and adopted improvements; repeated studies +and full-cost evidence precede adaptive-policy promotion. A partial Host export +or a read-only decision projection does not close this implementation program. ## 12. Milestones @@ -730,6 +934,9 @@ policy framework. transitions; - validate recommendations against independent acceptance rules; - retain Todo and replan as the only execution authorities. +- qualify §7.6 decision followthrough at the real replan entry and observe + whether the next probe changes relevant information, rather than identifiers; +- qualify one §7.7 research-state branch and matched continuation profile; - qualify the proposed §7.5 provider checkpoint/monitor binding in shadow; distinguish model decisions from experiment completion and Turn settlement. @@ -768,6 +975,14 @@ The research program is qualified only when it can prove: 9. promoted profiles improve end-to-end quality or cost across repeated runs, not only one layer-local count; 10. failure cases remain honestly terminal and recoverable. +11. research scope changes preserve the original task and acceptance; evidence + breadth is justified by decision value rather than repeated-check count; +12. a restored research point retains conversation/evidence/code and equivalent + feedback opportunities without reviving source execution authority; +13. prior decision, actual experiment, result interpretation and adopted action + are distinguishable; missing execution or utility remains unknown; +14. matched continuation comparisons retain incumbent, uncertainty, failures and + full model/tool/control costs; a local proxy gain is not native goal gain. ## 14. Non-Goals diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index 15997e658b..700d47dae6 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -6,9 +6,9 @@ | 替代 / 关闭 | 无 | | 日期 | 2026-08-15 | | 作者 | LoopX maintainers | -| 范围 | Effect 反馈、有界交付、权限升级、模型资格验证与长程效率 | +| 范围 | Effect 反馈、有界交付、研究范围、权限升级与长程效果/成本 | | Tracking issue | [#3203](https://github.com/huangruiteng/loopx/issues/3203) | -| 源码基线 | LoopX `8b8e7b248` | +| 源码基线 | LoopX `d03863fd0`;下文包含源码层面的决策接续修订 | > 语言说明: > [英文版](./hierarchical-agent-stride-control-v0.md)与本文档是语义镜像, @@ -33,10 +33,16 @@ interpreter 拥有,中层由 LoopX Turn/Todo/settlement 生命周期拥有, goal、vision、gate 与人类权限拥有。内层结果可以迫使中层提前结算;中层结果 可以迫使外层提前升级权限。一次 scheduler tick 本身不能制造任何一种语义迁移。 -本 RFC 提出一项 measurement-first 研究计划。最小有用切片是从现有 receipt -派生只读、provider-neutral 的 stride observation。在任何 runtime 自动扩大或 -缩小步幅前,先刻画现有 standard 与 fine-grained execution。Adaptive control -先进入 shadow mode,只有反复、稳定的资格验证成立后才 promotion。 +研究使 delivery stride 成为**向量**:工作切片大小、相对当前机制的距离与验证 +广度。各坐标可独立调节。很长的工作切片可以完成大量正确的局部检查,却未增加关于 +目标缺口的信息;减少控制事件不能修好这个问题。反过来,接近题目可达极限时, +中后期的小收益也可能有价值;单凭时长或分数持平不能决定扩大实验。 + +因此交付控制闭合**决定 -> 实际工作 -> 证据 -> 下一决定**,执行和结算仍归原 +owner。当前有界源码实现把上次被接受的 Vision 路线和后续记录进度返回给 replan, +目标收益明确保持 unknown。这是决策输入和 Agent 指引,不是自适应调度、结果 +采用门禁或研究收益证明。自动策略仍是需要重复资格验证的 opt-in 提案;研究 +状态分支为反事实验证提供起点,而不丢弃已积累的工作。 核心判断是: @@ -146,6 +152,7 @@ bounded delivery slice。它必须诚实终止于以下之一: | Delivery | Execution profile 已区分 standard 与 fine-grained Todo contract;fine-grained mode 使用 coherent-slice turn budget,并基于 fresh evidence 创建 successor。每个获准 heartbeat 仍执行完整 durable settlement。 | | Continuation | 除同一 Turn 的 controller disposition 外,typed delivery-continuity reducer 可在 heartbeat wake 之间保留最近一次 accountable `outcome_progress` Todo,前提是同一 Todo 仍 open、actionable、capability-ready,且仍由同一 agent 拥有。 | | Progress | Typed progress observation、repeat detection、semantic replan closure 与 evidence projection 能区分 material delta 和 maintenance。 | +| 复盘反馈 | replan context 源码实现可返回上次被接受、绑定证据的 Vision 路线与后续进度引用;按历史追加顺序和 Goal/Agent/Turn lineage 绑定输入。缺失的执行与目标收益仍为 unknown,不证明拟议干预实际运行。 | | Research | [研究型探索控制面 RFC](./research-exploration-control-plane-v0.zh-CN.md) 定义了可选的 typed knowledge frontier 与 composition experiment。 | | Authority | Goal vision、user gate、permission policy 与 peer/supervisor boundary 和 execution、scheduler 保持分离。 | @@ -153,12 +160,13 @@ bounded delivery slice。它必须诚实终止于以下之一: | 缺口 | 后果 | |---|---| -| 没有共享的 stride vocabulary | Host、Todo、scheduler 与 human-attention 的变化都被当成一个泛化的“turn length”问题。 | -| 没有跨层 observation | LoopX 无法判断低吞吐究竟来自 model/tool 往返、过度频繁的 settlement,还是 authority churn。 | +| 缺少与 outcome 绑定的完整观测 | 已有 M1 只读计数和决策接续输入,但仍需 provider 证据解释目标收益、model/tool、结算与权限变化成本。 | +| 研究范围策略尚未资格化 | 持续局部调参可能被误认为广泛探索;通用进度标识不证明机制创新或有效信息。 | | 没有面向步幅的 model capability profile | 快速 tool-using model 与较慢但协议可靠的模型大体收到静态 execution contract。 | | 没有语义 split/merge qualification | Todo 粒度由配置或 prompt 决定,却不按真实 decision change 与 settlement cost 评估。 | | 没有 intervention value model | Periodic report 与真正 authority change 没有分开测量。 | -| 没有 adaptive promotion path | 还没有 shadow recommendation -> bounded experiment -> promoted policy 生命周期。 | +| adaptive promotion 尚未资格化 | shadow 输入尚不能证明模型采用、匹配接续收益或已 promotion 的 model/work-class 策略。 | +| 研究状态分支尚未资格化 | 单独源码快照不能保留会话、积累证据及评测机会,无法建立公平接续对照。 | ## 5. 三层模型 @@ -433,6 +441,139 @@ readback 属于 partial slice。科学效用遵循 [研究决策](research-exploration-control-plane-v0.zh-CN.md#115-由证据驱动的实验决策修订提案) 的独立资格验证。 +### 7.6 研究范围与证据驱动控制 + +研究步幅包含三个独立选择的坐标。前两个是主要控制量,第三个限定采取行动所需 +证据与成本: + +- **工作切片大小**:下次决定前获准执行的连贯 outcome、范围和成本,包括 Todo + 粒度、拆分过大的实验、在已支持路线上扩大连贯切片。Todo 文本长度和文件数 + 不是大小;扩大仍要有可观察 postcondition 和正常逐 Turn 结算。 + +- **机制距离**:局部参数/实现微调,或明显不同的表示、模型、搜索邻域、因果 + 解释。假设改名、新文件和更多测试不构成距离。 +- **证据广度**:低成本的判别性探针、可比 pilot,再到真正采用所需的验证。 + 只有解决与下一决定有关的不确定性,扩大覆盖才有价值。 + +replan 复查这个向量,不成为新的权限 owner。在授权目标内改变 Agent 自建方法 +或暂定候选规则是正常研究决定;改变题目、验收、反馈协议、预算、权限或停止 +条件仍要经过原 owner。扩大探索不授权读取隐藏数据,也不允许失败后重写指标。 + +| 已观察依据 | 应比较的下一决定 | +|---|---| +| 新结果缺失、无效或不可比 | 修复观测,或选覆盖已知的探针;收益保持 unknown。 | +| 单个有效候选退步,或结果在不确定性内冲突 | 保留 incumbent,限定负证据范围,比较重复测量、未覆盖条件与不同机制;不能因一次失败退休整个 family。 | +| 局部检查通过,却不能解释权威目标缺口 | 比较有界的机制变化与继续微调,说明判别性预测以及它如何进入真实执行候选。 | +| 已知空间很小,或方差大于预期效应 | 优先有理由的不确定性消减或低成本改进;大量新增搜索预算要有依据。理论上界不是可行最优解。 | +| 现有证据与停止条件下已无合理范围内探针 | 通过原 owner 记录有范围的耗尽或具体 hold;不制造活动,也不因此宣布目标达成。 | + +由模型选择;通用代码不得根据文案子串、分数斜率、已过小时数或固定失败次数 +推导这些科学判断。指标含义、可比性、噪声和题目空间归 provider;显式机制关系 +与有范围 finding 归 Explore。既有 typed replan owner 投递目标、证据及上次路线; +正常 task/successor 与 Vision 写回拥有决定和采用。 + +每个决策边界执行: + +1. 将上次路线与后续工作、当前产物和权威结果对照;区分实际运行、知识变化、 + 真实采用与 unknown。进度回执不足以证明这些关系。 +2. 按需要比较原路线接续、判别性替代与有理由的重复测量,权衡预期目标/信息 + 收益及完整边际成本:模型输入输出、工具、验证、控制与机会成本。 +3. 选择一个有界探针,说明判别性预测、比较对象、incumbent 保护、采用条件和 + 成本/停止界限。较大机制变化可从小 pilot 开始,不必先做大范围验证。 +4. 通过正常获准 task 执行,证据绑定到受测输入与精确候选;失败、负结果及 + 不确定结果分别保留。 +5. 下次复盘用新观测保留、修订或停止有范围路线;重复必须说明能解决什么新 + 不确定性。不因 prompt 已读就重置其他触发计数。 + +源码层面的 `replan_context.decision_followthrough` 是首个可复用反馈切片:引用 +上次被接受、绑定证据的 Vision 路线,以及最多六个后续不同进度 fingerprint, +保留完整记录计数与遗漏信息。按不可变追加顺序而非时间戳关联,排除其他 Agent +及显式退休的 Goal 实例;引用由现有 history 入口解析。依据缺失或损坏时不虚构 +替代证据。此字段不资格化执行、科学创新、收益、有效 Turn 计数或验收,后者仍 +独立验证;不增加 scheduler、选择规则、quota 消耗或逐结果结算。 + +#### 可迭代策略与工程边界 + +第一版策略是**模型依据证据调节向量**:为各坐标提出扩大、保持或缩小,附上理由 +和可证伪的下一探针。不优化一个通用 novelty 分数:机制距离相对于明确的参照 +方法和目标,切片大小相对于下一有用 postcondition 与决策风险。 + +| 依据 | 工作切片 | 机制距离 | 证据广度 | +|---|---|---|---| +| 已支持方法持续产生可复现的有用进展 | 决策风险允许时扩大连贯工作 | 保持,除非剩余缺口支持替代方案 | 保留必需检查,避免冗余扫测 | +| 适用证据下当前方法仍有明显目标缺口 | 从小型判别性 pilot 开始 | 比较更大偏离与原路线接续 | 缩到能区分路线的观测 | +| 结果冲突或预期收益小于不确定性 | 决策明确前缩小工作 | 保持或比较替代,不宣布已反驳 | 仅扩大有理由的重复/不确定性探针 | +| 较大偏离产生可归因的有用结果 | 扩大已采用方法的下一切片 | 保留有效偏离,不为新颖而新颖 | 扩大采用所需验证 | +| 决策相关失败或权限边界出现 | 经原 owner 结束/拆分当前切片 | 在原目标内重新审视 | 验证具体失败,不扩大权限 | + +此表指导模型决定,不是文案分类器或固定数字策略。工程设计保留四个可替换边界: + +1. **观测适配器**提供精确候选/结果、有效性/不确定性、覆盖与成本事实;含义归 + 垂域 provider,不能写通用策略或授予执行权。 +2. **策略**消费一份绑定来源的复盘依据,提出步幅向量、下一探针与预期收益/ + 成本;实验记录策略版本。先采用上表,后续确定性启发式或学习策略可在相同 + 输入下替换此函数。第二个真实、已资格化策略需要接缝前,不建 plugin registry + 或训练 controller。 +3. **准入/采用**通过既有 Todo、Vision、replan、预算和权限 owner 验证提案。 + 工作大小变化使用正常 split/continuation/successor;原 Turn 结算未完时, + 路线决定不能执行 successor。 +4. **反馈**将实际执行、结果、保留产物与完整成本返回下次复盘。提案、采用和 + 收益分别可回放;缺失反馈不能变成成功。 + +当前源码切片实现第四个边界的已记录决定输入和可选 TypeScript 策略组件 +`evidence_conditioned_v1`。第一版消费 Agent 判断的证据 basis,确定性计算三个 +坐标的 grow/hold/shrink;versioned `agent_vision.stride_decision` 保存向量、当轮 +义务与观测 context 引用、下一探针及采用/成本条件。Vision authoring 在写入前 +校验声明的结构和向量;启用后的新 Vision 路线复盘必须提交结构化提案,其他 +来源专属出口保留。正常 Todo/Next Action 接续执行,下轮对照真实观测。 +这是结构化提案调控,不是自动执行器或研究效果分类器。共享配置词表为 `off | evidence_conditioned_v1`,未知 +版本在写入前拒绝。通过既有 Goal 复盘能力的编辑器与 CLI 设置完整 Goal 覆盖 +或沿用设备默认;off 保持普通证据身份、选择和原有五条指引,启用时才增加 +决定回读及工作大小、机制距离、验证范围的独立建议。没有 +自动向量执行器。先用既有有界 `path_delta` 与 task Next Action 记录提案,不新建 +策略/状态数据库。显式 versioned attachment 避免从文案解析控制向量。未来策略版本继续验证 +default-off parity 与字段界限,不静默重释旧决定;提案与采用权限保持分离。每版策略经同起点 +消融证明采用和完整成本收益再 promotion;先改一个坐标,再研究交互。六个有效 +工作 Turn 仍是现有复盘默认周期,不是 Todo、实验或机制变化的大小。 + +敏捷探索可只跑启用策略的测试组,以历史基线作描述性参照;不要求每次迭代都 +重启并发基线。这类运行用于观察路线采用与迭代收益,不单独证明因果净收益或 +promotion 资格。需要相应结论时,再执行匹配接续与重复验证。 + +### 7.7 研究状态分支与匹配接续 + +可恢复研究点包含**会话位置、研究证据、代码/候选产物与观测机会**。单独源码 +归档或轨迹前缀不是完整研究点。捕获与验证复用既有[备份/恢复 owner](complete-state-recovery-v0.zh-CN.md)、 +Host 会话 owner 和 artifact provider;本 RFC 定义研究实验的一致性及可比性要求, +不新建 restore 权威。 + +捕获前按已分配研究预算或语义事件声明位置,不事后挑分数有利的点。十小时等 +中后期位置属于实验设置,不是产品默认值或自动转向条件。记录实际边界与 skew: + +- 原生会话 cursor 和已资格化的 Host export/fork 路径;复制日志不证明能恢复 + session; +- 已结算的 LoopX 研究证据、原目标/优先级、既有决定、有范围负结果及未解问题; +- 精确工作源码、必要且获授权的未跟踪文件、incumbent 与其 qualifying result; + 尚未测量的候选与 incumbent 分开保留; +- source/model/tool/evaluator pins 与已消费的 solver-visible feedback; +- 剩余总预算、已受理提交历史及原协议下的下一次反馈机会。 + +inert verification unit 不授予执行权。目标侧使用新的实验/Host 身份,通过既有 +Goal/session/state admission 进入。历史租约、quota、timer、pending effect 和 +submission token 不能变成目标侧权限;未知效果先按原身份 reconcile,再决定 +是否 replay。Host 尚无已资格化 fork 就报告缺口;证据摘要重启是另一种 treatment, +不能称为精确接续。 + +匹配实验前先验证 digest/cursor/artifact 一致性、partial capture、缺失文件、 +输入变化、重复恢复、竞争 writer、来源停止和结果丢失投递。恢复要证明实际采用 +所保存的证据和代码,并完成新工作;tar 验证或打开 session 本身不是验收。 + +研究消融从**同一份**已资格化研究点分成原策略接续与证据驱动范围复盘,使用 +等额接续预算与资源,只改变一个控制因素;通过 evaluator owner 保留反馈机会数 +与冷却时机。Session 重启、摘要替换、版本升级或策略组合属于额外变量。保留 +所有分支及无效/负结果,比较实际采用的原生成绩、新信息与完整接续成本。结果 +只资格化这项有范围干预,不证明整个 LoopX 或一个通用最优 stride。 + ## 8. 测量模型 第一版实现应先测量,再控制。 @@ -613,6 +754,17 @@ Stride qualification 至少应区分: - event-driven authority escalation; - effect 与 delivery contract 保持一致。 +**Stage E:从同一捕获状态比较研究范围** + +- 原策略接续与 §7.6 的证据驱动范围复盘; +- 同一份经 §7.7 资格化的会话/证据/代码研究点与反馈历史; +- 等额接续资源、预算与评测机会; +- 原生目标结果、新信息/采用及完整成本分别测量; +- 先做小规模恢复/采用 pilot,重复 cell 之后才 promotion。 + +选取确有不同机制空间的任务,同时保留接近上界或噪声较大的 case 检查适用界限。 +不能在看到分数后挑 checkpoint 或省略分支。 + 只有这些阶段识别出 interaction 后,factorial experiment 才组合已 promotion 的 profile。 @@ -625,19 +777,14 @@ authority。测试不能仅因输出包含某个关键词就通过。 ## 11. 最小有用实现切片 -第一版实现有意保持窄小、可逆: - -1. 为 effect、delivery、authority stride 增加一套 public architecture vocabulary。 -2. 在可用处从现有 Turn、settlement、Todo、quota 与 gate receipt 派生只读 stride - observation。 -3. 增加 offline evaluator,报告各层 metric 与 mismatch signal。 -4. 用 public-safe fixture 刻画 standard/fine-grained profile,并至少保留一条真实 - model behavior qualification 路径。 -5. 只输出 shadow recommendation,不改变 Todo selection、scheduler frequency、 - effect execution 或 user notification。 +M1 只读观测仍可用。下一完整、可逆切片把既有被接受的决定与后续证据投递到现有 +replan 入口,配合 §7.6 指引;不新增分类器、调度器或验收门。先验证真实 CLI/Host +投影、不可变引用回读、缺失/损坏/陈旧输入及不变的语义 discharge。 -第一版应扩展最近的现有 Turn/status read model,不能新建 built-in capability、 -executor、scheduler 或 generic policy framework。 +随后按 §7.7 资格化一个研究状态捕获/接续 profile,区分归档验证、可执行接续、 +模型采用和科学收益。匹配接续 pilot 判断范围复盘能否产生判别性实验与真实采用 +的改进;重复研究及完整成本证据先于自适应策略 promotion。部分 Host export 或 +只读决定投影都不闭合整项实现计划。 ## 12. Milestone @@ -665,6 +812,9 @@ executor、scheduler 或 generic policy framework。 - 从 typed postcondition/evidence transition 建议 split、keep 或 merge; - 用独立 acceptance rule 验证建议; - Todo 与 replan 仍是唯一 execution authority。 +- 在真实 replan 入口资格化 §7.6 决策接续,观察下一探针是否改变有用信息, + 而非仅改变标识; +- 资格化一个 §7.7 研究状态分支与匹配接续 profile; - 在 shadow 中验证 §7.5 的 provider checkpoint/monitor binding,区分模型 决策、实验完成与 Turn settlement。 @@ -702,6 +852,11 @@ executor、scheduler 或 generic policy framework。 8. shadow mode 不改变 scheduling、quota spend、notification、gate 或 execution; 9. promoted profile 在 repeated run 中改善 end-to-end quality/cost,而不只是局部计数; 10. failure case 保持 honest terminal 且可恢复。 +11. 研究范围变化保留原题目与验收,证据广度由决策价值而非重复检查数解释; +12. 恢复研究点保留会话/证据/代码及等价反馈机会,不复活来源执行权; +13. 既有决定、真实实验、结果解释与采用动作分别可观察;缺失执行/收益保持 unknown; +14. 匹配接续比较保留 incumbent、不确定性、失败及完整 model/tool/control 成本, + 局部代理指标收益不当作原生目标收益。 ## 14. 非目标 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 365248c256..c47aadc0fe 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -345,7 +345,7 @@ subsystem was not performed. Section 8 records the focused audit. | [Human-confirmed domain operations (v0)](human-confirmed-domain-operations-v0.md) | S8/S9 + R2/R3 | Accepted; canonical operation seam exists, managed native transport under owner review | Qualify source-context vs admitted-executor separation, exact human approval→one-shot consumption→domain evidence→original-route return; owned Turn/delegation need not await Desktop authentication. Live approval/effect/wakeup remain unqualified; finance provider stays separate | | [Provider-side authorization at effect acceptance (v0)](provider-effect-acceptance-v0.md) | S8/S9, supporting S2/S4 | Accepted design only; no runtime integration or qualified provider | M1: controlled provider and deterministic revoke/crash/replay conformance; strict production wiring remains gated by exact Goal lifetime, receipt retention and independent provider qualification | | [Research Exploration Control Plane v0](research-exploration-control-plane-v0.md) | S11/S3 | Accepted; partial M2 composition/successor; §11.5 result/adoption refinement proposed | P1: one real provider result -> scoped interpretation -> adopted step/successor, exact lineage/shared write gate and packaged readback; qualify shadow before opt-in, retain composition gaps; defer inference and M4 portfolio ranking | -| [Hierarchical Agent Stride Control v0](hierarchical-agent-stride-control-v0.md) | S11/S7 | Accepted; M1 read-only observation; §7.5 checkpoint/monitor binding proposed | P2: matched shadow decision timing with observer/model costs and lost-result recovery; preserve per-Turn settlement, no default cadence change | +| [Hierarchical Agent Stride Control v0](hierarchical-agent-stride-control-v0.md) | S11/S7 | Accepted; M1 observation and source-level replan decision followthrough; adaptive policy/continuation unqualified | P2: §7.6 mechanism/evidence scope review -> actual probe/adoption/full cost; §7.7 same-state continuation and recovery before adaptive promotion. Preserve per-Turn settlement and authority; no forced-hour pivot or default cadence change | | [Goal-scoped Capability Portfolio v0](goal-scoped-capability-portfolio-v0.md) | S1/S3/S6/S8/S11 | Accepted; read-only settings/context inspection slice | P1: correction→fresh-session decision across existing owners, then demand-driven composition and measured method evolution; no universal memory database or second opt-in | | [Post-Outcome Memory Utility Attribution v0](post-outcome-memory-utility-attribution-v0.md) | S6/S11 | Accepted; Stage 1 verified-outcome binding | P1 read-only reducer→P2 pilot: distinguish recalled/applied/utility, no automatic ranking change | | [Obelisk Session Evidence Provider v0](obelisk-session-evidence-provider-v0.md) | S6/S8 | Accepted; optional read-only evaluation | P2: explicit gap recall with resolvable provenance/access, off-path parity; no work authority | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 7a7ee368fb..b37ba09788 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -279,7 +279,7 @@ Muse 设计页在浏览器超时,其文章通过网页检索读取。本次调 | [Human-confirmed domain operations (v0)](human-confirmed-domain-operations-v0.zh-CN.md) | S8/S9 + R2/R3 | 已接受;规范操作接缝存在,受管原生传输待 owner review | 验收来源上下文与已准入执行者分离、精确用户批准→单次消费→垂域证据→原路返回;自有 Turn/delegation 不等待 Desktop 认证。真实批准/效果/唤醒仍未资格化;金融 provider 保持独立 | | [Provider 在效果接受点执行授权(v0)](provider-effect-acceptance-v0.zh-CN.md) | S8/S9,S2/S4 支撑 | 已接受设计;尚未接入 runtime,也未准入 provider | M1:controlled provider 与 deterministic revoke/crash/replay conformance;strict production 接线仍需精确 Goal 生命周期、receipt retention 与独立 provider 资格 | | [Research Exploration Control Plane v0](research-exploration-control-plane-v0.zh-CN.md) | S11/S3 | 已接受;M2 composition/successor 局部实现;§11.5 result/adoption 修订为提案 | P1:一条真实 provider result -> 有范围 interpretation -> adopted step/successor,精确 lineage/shared write gate 与 packaged readback;shadow 资格先于 opt-in,保留 composition gap;推断与 M4 portfolio 排序继续 defer | -| [Hierarchical Agent Stride Control v0](hierarchical-agent-stride-control-v0.zh-CN.md) | S11/S7 | 已接受;M1 只读观测;§7.5 checkpoint/monitor binding 为提案 | P2:matched shadow 决策时机、observer/model 成本与 lost-result recovery;保留每 Turn settlement,不改变默认 cadence | +| [Hierarchical Agent Stride Control v0](hierarchical-agent-stride-control-v0.zh-CN.md) | S11/S7 | 已接受;M1 观测及源码层面的 replan 决策接续;自适应策略/恢复接续未资格化 | P2:§7.6 机制/证据范围复盘 -> 真实探针/采用/完整成本;§7.7 同起点接续及恢复验证先于自适应 promotion。保留每 Turn 结算与权限,不按固定小时强制转向、不改变默认 cadence | | [Goal-scoped Capability Portfolio v0](goal-scoped-capability-portfolio-v0.zh-CN.md) | S1/S3/S6/S8/S11 | 已接受;只读配置/上下文检查切片 | P1:复用既有 owner 验收纠正→新会话决策,再做按需组合与可测方法演化;不建万能记忆库或第二个启用开关 | | [Post-Outcome Memory Utility Attribution v0](post-outcome-memory-utility-attribution-v0.zh-CN.md) | S6/S11 | 已接受;Stage 1 verified-outcome 绑定 | P1 只读 reducer→P2 pilot:区分 recalled/applied/utility,归因不自动改 ranking | | [Obelisk Session Evidence Provider v0](obelisk-session-evidence-provider-v0.zh-CN.md) | S6/S8 | 已接受;可选只读评估 | P2:显式 gap recall、来源与权限可解析、关闭不影响主流程;不当 work authority | diff --git a/docs/architecture/rfcs/research-exploration-control-plane-v0.md b/docs/architecture/rfcs/research-exploration-control-plane-v0.md index 843a615439..a2ff789e96 100644 --- a/docs/architecture/rfcs/research-exploration-control-plane-v0.md +++ b/docs/architecture/rfcs/research-exploration-control-plane-v0.md @@ -715,6 +715,41 @@ valid same-family probes and default-off parity. Then independently qualify model adoption and scientific utility under matched total budgets. Lower control cost must not come from hiding useful negative evidence. +### 11.6 Research scope review at the shared replan boundary + +The [stride control loop](hierarchical-agent-stride-control-v0.md#76-research-scope-and-evidence-conditioned-control) +complements result adoption. It asks whether the next experiment should change +the mechanism, the validation coverage, or the uncertainty being resolved. +More correct tests and more named hypotheses cannot establish broader research. +Native/provider outcomes, noise and headroom remain domain evidence, not kernel +classifications. In-scope method changes do not require a new user gate; task, +acceptance, permission, feedback and budget changes retain their existing owners. + +The bounded source prerequisite reuses accepted `goal_path_delta_v0` decisions +and subsequent typed progress in `replan_context.decision_followthrough`, delivered +at the existing review entry. It adds no mandatory Explore attachment or capture, +works independently of Explore activation, and cannot satisfy or discharge any +extra obligation. Progress is recorded evidence, never an execution/adoption or +scientific novelty verdict. §11.5's opt-in result-adoption gate and composition +lineage are still separate, unqualified work. + +The Agent should preserve the qualified incumbent, identify the unexplained +goal gap and compare a discriminating alternative with continuation or justified +replication. Bind the chosen probe's prediction, adoption criterion and cost to +normal task/Vision continuation, then examine actual candidate execution and +result at the next review. A changed input can reopen a scoped negative finding; +unchanged conditions need a reason to repeat. No elapsed-hour or losing-score +rule forces a pivot or retires a family. + +Qualify this intervention through the [same-state continuation study](hierarchical-agent-stride-control-v0.md#77-research-state-branching-and-matched-continuation), +retaining conversation, negative evidence, code/incumbent and feedback +opportunities. Measure whether the next probe resolves an uncertainty and its +result enters an adopted artifact, then compare native outcomes and full cost. +Session opening, path writeback and context delivery are prerequisite facts, +not research effectiveness. Keep automatic ranking/enforcement default-off +until the scoped profile qualifies; neither this input slice nor a blog claim +closes M3/M4. + ## 12. Write-Time Enforcement The write gate must reuse the same current goal-frontier and Explore gap diff --git a/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md b/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md index d64273c1b7..787911621d 100644 --- a/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md +++ b/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md @@ -650,6 +650,33 @@ input 改变后重开 scope、合法同 family probe 与 default-off parity。 matched 总预算下独立验证模型 adoption 和科学效用;降低控制成本不能靠隐藏 有效负证据。 +### 11.6 共享 replan 边界的研究范围复盘 + +[步幅控制闭环](hierarchical-agent-stride-control-v0.zh-CN.md#76-研究范围与证据驱动控制) +补充结果采用:下一实验应改变机制、验证覆盖,还是需要解决的不确定性?更多 +正确测试和更多命名假设不证明研究更广。原生/provider outcome、噪声与收益空间 +仍是领域证据,不成为 kernel 分类。在原目标内改变方法不要求新的 user gate; +题目、验收、权限、反馈与预算变更仍由既有 owner 决定。 + +有界源码前置切片复用已接受的 `goal_path_delta_v0` 与后续 typed progress,在既有 +复盘入口投递 `replan_context.decision_followthrough`。不增加强制 Explore 附件或 +capture,独立于 Explore 是否启用,也不能满足或解除额外义务。Progress 是记录 +证据,不是执行/采用或科学创新的裁决。§11.5 的 opt-in 结果采用门禁与 composition +lineage 仍是独立、未资格化的工作。 + +Agent 应保留已资格化 incumbent、指出未解释的目标缺口,并比较判别性替代、 +原路线接续与有理由的重复测量。所选探针的预测、采用条件及成本绑定正常 task/ +Vision continuation,下次复盘检查实际候选执行与结果。输入变化可重新打开有范围 +负 finding;不变条件下重复要说明理由。没有按已过小时数或退步分数强制转向、 +退休 family 的规则。 + +通过[同起点接续实验](hierarchical-agent-stride-control-v0.zh-CN.md#77-研究状态分支与匹配接续) +资格化干预,保留会话、负证据、代码/incumbent 与反馈机会。观察下一探针是否 +解决不确定性、结果是否进入实际采用的产物,再比较原生目标结果和完整成本。 +打开 session、path 写回与 context 送达都是前置事实,不证明研究效果。自动排序/ +enforcement 在特定 profile 验证前保持 default-off;此输入切片或 blog 声明都不 +闭合 M3/M4。 + ## 12. 写时强制 Write gate 必须复用 quota 所用的同一 current goal-frontier 与 Explore gap diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index b4ecbec5a0..8c0cafe1a2 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -122,10 +122,14 @@ loopx configure-goal --goal-id example loopx configure-goal --goal-id example --clear-execution-replan-after-turns --execute ``` -The Dashboard exposes **Goal review cadence** in the Capability Center for +The Dashboard exposes **Goal review and stride** in the Capability Center for both the device and selected Goal. The existing machine namespace -`todo_replan_cadence_machine_defaults_v1` stores `count_unit` (`effective_turns` -or `completed_todos`) and `count`. It is built into the control plane and needs +`todo_replan_cadence_machine_defaults_v2` stores `count_unit` (`effective_turns` +or `completed_todos`), `count` and optional strategy selection. Existing v0/v1 +configurations remain readable without migrating them on inspection. The +strategy defaults off; `evidence_conditioned_v1` adds evidence-conditioned scope +and route advice, with unchanged admission and semantic exits. See the +[versioned strategy configuration and rollback](reference/protocols/goal-vision-replan-contract-v0.md#configure-and-roll-back-the-stride-strategy). It is built into the control plane and needs no provider installation. The CLI, settings readback, quota decision and writeback validation use the same effective configuration. diff --git a/docs/reference/protocols/assets/stride-strategy-editor.png b/docs/reference/protocols/assets/stride-strategy-editor.png new file mode 100644 index 0000000000..4b9c7c3d9b Binary files /dev/null and b/docs/reference/protocols/assets/stride-strategy-editor.png differ diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index ecbc596a1c..8cca555ae0 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -264,10 +264,45 @@ the same `agent_id` as the refresh run. This keeps `research-executor`, `evaluator-promoter`, and other roles from overwriting or satisfying each other's active vision. +### Decision followthrough in replan context + +With `evidence_conditioned_v1` enabled, when the captured history contains a prior +accepted evidence-linked Vision path, +`replan_context.decision_followthrough` returns that path with the later recorded +progress references. It uses the existing Goal/Agent/Turn binding and immutable +history source order; timestamps do not decide causality. The full local index +supplies append positions. Compact newest-first carriers and the named +`newest_first_runs` builder reverse those positions without reordering ordinary +evidence. Arbitrary iterables with unknown order supply no causal positions and +therefore no decision-followthrough claim. An explicit current +Goal instance excludes retired-instance sources. The normal history read resolves +its evidence references; private transport facts are not returned. The derived +`replan-decision-*` reference binds the normalized path and its original +`replan-evidence-*` history reference. Changed paths cannot resolve an old +decision reference; existing history reference identities are unchanged. + +Details are bounded to six distinct progress fingerprints with the full recorded +count, omitted count, source completeness and missing-order count. These are +recorded observations, not settled-effective-Turn counts or independent trials. +`execution_and_goal_gain=unknown` stays explicit: a changed progress identifier, +accepted replan or serialized observation proves neither a new mechanism nor its +execution, candidate adoption, scientific improvement or Goal completion. A +missing, rejected or malformed basis adds no followthrough projection. No +selection, trigger, quota, acceptance, lease or scheduler rule changes. + +启用 `evidence_conditioned_v1` 且历史中已有被接受、绑定证据的 Vision 路线时,replan context 返回上次决定与之后的 +进度引用,按合格来源的顺序关联,而非根据时间戳或文案猜测因果。完整本地索引提供 +追加位置;已知最新在前的紧凑载体与 `newest_first_runs` 入口转换位置,保留普通 +证据原顺序。顺序未知的 iterable 不提供因果位置,也不生成决定接续投影。显式当前 Goal +实例排除已退休实例。详情最多六项,保留总数、遗漏数、源完整性与顺序缺失数; +正常 history 入口可读取引用,私有 transport facts 不公开。这些是记录中的观测, +不等于有效结算 Turn、独立试验或执行/收益证明;后者明确保持 unknown。缺失、 +拒绝或损坏的依据不产生投影;选择、触发、结算及权限规则不变。 + ### Replan planning guidance -The shared `replan_action_packet.planning_guidance` carries five Agent -instructions through full/compact quota and the host Turn envelope: +The shared `replan_action_packet.planning_guidance` carries five existing Agent +instructions and an optional sixth stride instruction through full/compact quota and the host Turn envelope: - Preserve the requested end state under current user direction. A bounded slice retains outstanding requirements; easier tests cannot redefine @@ -294,15 +329,24 @@ instructions through full/compact quota and the host Turn envelope: Prefer a discriminating probe, keep negative evidence scoped to tested conditions and proxies scoped to their coverage, and reuse still-applicable evidence when no new uncertainty warrants repetition. - -This guidance is included by default whenever a replan action packet is -projected. It is Agent judgment guidance, not a new machine-enforced acceptance -gate or a permission grant. Typed semantic outcomes, lifecycle transitions and -non-replan turns are unchanged. The existing TypeScript replan owner supplies -the text; CLI and host projections preserve it without a new setting or editor. -Transport tests prove delivery and unchanged gates, not improved model behavior. - -共享 replan action packet 默认携带五条指引,并在完整/精简 quota 与 +- **Only with `evidence_conditioned_v1`:** Review the previous path decision against subsequent work and authoritative + outcomes. When local tuning or repeated validation leaves the goal gap + unexplained, compare a bounded alternative mechanism with continuation or + replication. Elapsed time and one noisy result do not force a pivot. Protect + the best qualified artifact; name the next probe's distinguishing prediction, + adoption criterion and bounded cost, then check actual execution and outcome + at the next review. Research scope changes use existing paths or successors. + +The first five instructions remain the ordinary replan contract. The optional +TypeScript strategy component adds the sixth and the followthrough read model; +`off` (the default, including legacy configurations) preserves ordinary context +identity, evidence selection and guidance. Unknown versions fail before mutation. +This is Agent judgment advice, not a new machine-enforced acceptance gate, +permission, adaptive scheduler or automatic route actuator. Typed semantic +outcomes and non-replan Turns are unchanged. Tests prove delivery and isolation; +model adoption and utility require matched experiments. + +共享 replan action packet 保留五条默认指引,可选策略增加第六条,并在完整/精简 quota 与 Turn envelope 中保留: - 不能为了容易通过测试而缩小目标;阶段成果保留剩余要求,遵守用户范围、 @@ -319,10 +363,118 @@ Turn envelope 中保留: 确认改动进入实际执行路径和当前产物。优先选择能区分剩余解释的探针;负证据 只覆盖已测条件,局部代理指标只覆盖其代表的范围。没有新不确定性时复用 仍然适用的证据。 +- **仅启用 `evidence_conditioned_v1` 时:** 将上次路线决定与后续工作、权威结果对照。若局部调参或重复验证仍无法解释目标 + 缺口,比较有界的新机制、原路线接续与重复测量;不因固定时长或一次噪声结果 + 强制转向。保留最佳已验证产物,为下一探针说明判别性预测、采用条件与有界成本, + 下次复查真实执行和结果;实验范围通过现有 path/successor 改变,不改变权限或验收。 + +默认 `off` 及旧配置保持普通 context 身份、证据选择和前五条指引;启用策略时 +增加第六条与上次决定回读。未知版本在写入前拒绝。它提供 Agent 判断建议, +不新增机器验收门禁、执行权限、自动调度或路线执行器;送达和隔离测试不能 +证明模型采用及收益。 + +### Configure and roll back the stride strategy + +Use the existing **Goal review and stride** capability editor, selecting device +defaults or an explicit Goal target. Choose the review unit/count and strategy, +preview once, confirm and read back the effective configuration/source. The +whole Goal review override wins over the device review configuration; clearing +it restores current device defaults. A legacy override without a strategy means +`off`, even when the device strategy is enabled. No separate capability switch +or per-field inheritance is added. + +![Versioned stride strategy in the existing capability editor](assets/stride-strategy-editor.png) + +Isolated packaged editor with the real revision-checked configuration handler; +workspace discovery is synthetic. This demonstrates configuration readback, not +installed-App adoption or research utility. + +CLI preview/apply and readback (replace `GOAL`): + +```sh +loopx configure-goal --goal-id GOAL --execution-replan-after-turns 6 --execution-stride-strategy evidence_conditioned_v1 +loopx configure-goal --goal-id GOAL --execution-replan-after-turns 6 --execution-stride-strategy evidence_conditioned_v1 --execute +loopx --format json configure-goal --goal-id GOAL +loopx configure-goal --goal-id GOAL --execution-stride-strategy off --execute +``` -这是 Agent 判断指引,不新增机器验收门禁、配置或权限,不改变已有语义写回 -规则及非重规划 Turn。文本复用既有 TypeScript owner;投影测试证明送达和 -门禁保持,不能证明模型采用或效果提升。 +Turning the strategy off retains the review interval and ordinary review. To +restore device defaults for the entire review configuration, clear the cadence +and strategy through the editor or `--clear-execution-replan-after-turns +--clear-execution-replan-after-todos --clear-execution-stride-strategy --execute`. +Historical evidence remains readable after disabling. Activation cannot authorize +experiments, spend quota, clone a checkpoint, relax acceptance or rewrite a +frozen experiment. Configuration is versioned; preregister the selected version +and captured input scope for comparisons. + +在既有「Goal 复盘与步幅」能力中心中选择设备默认值或明确的 Goal,设置计数依据、 +周期和策略,预览、确认并回读生效来源。Goal 整体覆盖设备配置;旧 Goal 覆盖中 +没有策略则为 off。关闭策略保留复盘周期;清除整个覆盖恢复设备默认值。 +历史引用仍可读取。启用不会授权实验、消耗配额、复制 checkpoint、放宽验收或 +改写冻结实验;比较时预先记录策略版本与输入覆盖范围。 + +### Structured stride control + +The opt-in component exposes `replan_action_packet.stride_control` through full, +compact CLI and host envelopes. `evidence_conditioned_v1` consumes an explicitly +**Agent-assessed** evidence basis and derives three coordinates in TypeScript: + +| Evidence basis | Work slice | Route distance | Validation scope | +|---|---|---|---| +| `supported_progress` | grow | hold | hold | +| `unexplained_gap` | shrink | grow | shrink | +| `uncertain_evidence` | shrink | hold | grow | +| `useful_new_route` | grow | hold | grow | +| `decision_failure` | shrink | hold | shrink | + +These are relative adjustments to the declared current method and next useful +postcondition, not a universal novelty score, runtime budget or certified +research verdict. Reducing validation scope cannot remove required acceptance +checks. The model assesses the basis from applicable evidence; the strategy +owns the vector. For example, an unexplained method gap calls for a small probe +of a larger route departure, while conflicting evidence calls for more targeted +validation without automatically changing route. + +Declare a top-level `agent_vision.stride_decision` attachment with +`schema_version=stride_decision_v1`, the selected `strategy_version`, +`reference_context_id`, `reference_obligation_id`, `basis` and `next_probe` +(`scope`, `prediction`, `adoption_criterion`, `cost_bound`). Copy the actual +reference ids from the live control packet. `axes` may be omitted; the strategy +fills them. A supplied vector must match the version/basis. Unknown fields, +versions, mismatched vectors, missing probe commitments, private text and +oversized text are rejected during Vision authoring, before writeback. Attachment +text counts toward the existing 1,800-character Vision budget. + +Declared proposals are structurally validated. Under enabled review the fresh Vision path exit requires this structured +proposal; other source-specific successor, progress and terminal exits remain +available. Missing proposals or a proposal referencing a different obligation +cannot satisfy the fresh Vision path exit. The binding uses the admitted obligation, not the latest +context hash: legitimate shared-pool changes must not invalidate the original +Turn's settlement. The context id records the observed basis; it does not prove +that the Agent's assessment or predicted gain is correct. + +A valid schema/vector alone cannot discharge a review, execute a Todo, spend +quota or certify usefulness. The evidence-linked Path Delta and ordinary Todo/ +Next Action carry the proposal into authorized work. History and compact Vision +readback retain the normalized decision; the next review returns it with later +observations. Actual adoption, execution, gains and costs still need their +own evidence. Off/legacy inputs with no declared attachment preserve ordinary +projection and settlement. Explicit attachments are validated data even when +ordinary review is selected; they do not enable automatic strategy projection. + +可选策略通过完整/精简 CLI 和 Host packet 提供结构化控制契约。Agent 根据证据 +判断 basis,TypeScript 策略计算工作大小、路线距离、验证范围的 grow/hold/shrink。 +例如目标缺口仍无法解释时,采用“小实验+较大路线变化”;证据冲突时,“小实验+ +路线保持+扩大针对性验证”。它不依据运行时长或分数文案自动分类。 + +结构化提案绑定当轮义务及观察到的 context,说明下一探针的范围、预测、采用条件 +与成本边界。启用后的新 Vision 路线复盘必须带结构化提案;其他 typed progress、successor +和 terminal 出口保留。机器校验版本、向量、字段界限和来源绑定,沿用既有 Vision +文本预算; +只提交一个向量不能完成重规划。正常 Todo/Next Action 负责接续,下轮回读决定与 +真实观测。缩小验证范围不撤销硬验收;格式有效不证明实际采用或收益。关闭模式 +不自动投影策略;显式声明的附件仍须通过数据校验。策略建议与机器校验分别命名, +执行权限及结算归属不扩大。 ### Path Delta diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 8962c8e6c0..07ab62cca4 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -149,7 +149,7 @@ export function goalCapabilityCatalog(multiSubagentConfiguration) { return [ goalCapability({ capabilityId: "todo_replan_cadence", - displayName: "Goal review cadence", + displayName: "Goal review and stride", editorScopes: ["machine", "goal"], defaultConfiguration: { count_unit: "effective_turns", count: 5 }, fields: [ @@ -1043,7 +1043,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true }, { namespace: "todo_replan_cadence", - title: "Goal review cadence", + title: "Goal review and stride", description: "Live review threshold without added turns, quota, or authority.", schema_versions: ["todo_replan_cadence_machine_defaults_v0", "todo_replan_cadence_machine_defaults_v1"], configuration_template: cadenceConfiguration, diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index e7a16198be..717fc60320 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -34,15 +34,25 @@ export const replanCadenceScenario = { }, }); const { page } = context; + async function applyGoalPreview(name = "应用此预览") { + const response = page.waitForResponse((value) => + value.url().includes("/api/chat/goal-configuration/apply") && value.request().method() === "POST"); + await page.getByRole("button", { name, exact: true }).click(); + const settled = await response; + if (!settled.ok()) throw new Error(await settled.text()); + } + async function openMachineCadence() { await page.getByRole("button", { name: "设置", exact: true }).click(); await page.locator(".personal-settings-tabs").getByRole("button", { name: "能力中心", exact: true }).click(); - await page.getByRole("navigation", { name: "机器能力目录" }).getByRole("button", { name: /Goal 复核周期/ }).click(); + await page.getByRole("navigation", { name: "机器能力目录" }).getByRole("button", { name: /Goal 复盘与步幅/ }).click(); } await openMachineCadence(); const unit = page.getByLabel(/^复核计数依据/); const count = page.getByLabel("两次复核间的数量", { exact: true }); + const strategy = page.getByLabel(/^步幅策略/); await waitForInputValue(count, "6"); + if (await strategy.inputValue() !== "off") throw new Error("Stride must default off"); if (await unit.inputValue() !== "effective_turns") throw new Error("Product default is not settled work Turns"); await page.screenshot({ path: resolve(outputDir, "replan-cadence-product-default.png"), animations: "disabled" }); const preview = await request(base + "/preview", legacy); @@ -60,34 +70,48 @@ export const replanCadenceScenario = { stored = (await request(base)).machine_configuration.namespaces.todo_replan_cadence; if (stored.completed_todos !== 2) throw new Error("Invalid preview mutated state"); await count.fill("3"); + await strategy.selectOption("evidence_conditioned_v1"); await page.getByRole("button", { name: "预览变更", exact: true }).click(); await page.getByRole("button", { name: "应用已审阅预览", exact: true }).click(); await page.getByRole("button", { name: "预览回滚", exact: true }).waitFor(); stored = (await request(base)).machine_configuration.namespaces.todo_replan_cadence; if (stored.count_unit !== "effective_turns" || stored.count !== 3 || "completed_todos" in stored) throw new Error("Migration did not persist the selected unit"); + if (stored.stride_strategy !== "evidence_conditioned_v1" || stored.schema_version !== "todo_replan_cadence_machine_defaults_v2") throw new Error("Machine stride version not recorded"); await page.screenshot({ path: resolve(outputDir, "replan-cadence-machine.png"), animations: "disabled" }); await page.getByRole("radio", { name: "单个 Goal", exact: true }).check(); await page.getByRole("combobox", { name: "目标 Goal", exact: true }).selectOption("multi-agent-projection"); - await page.getByRole("navigation", { name: "Goal 能力目录" }).getByRole("button", { name: /Goal 复核周期/ }).click(); + await page.getByRole("navigation", { name: "Goal 能力目录" }).getByRole("button", { name: /Goal 复盘与步幅/ }).click(); await waitForInputValue(count, "3"); if (await unit.inputValue() !== "effective_turns" || await count.inputValue() !== "3") throw new Error("Goal did not inherit effective-Turn device policy"); async function readGoalCadence() { const packet = await request("/api/chat/goal-configuration?goal_id=multi-agent-projection"); return packet.capability_catalog.capabilities.find((row) => row.capability_id === "todo_replan_cadence"); } + if (await strategy.inputValue() !== "evidence_conditioned_v1") throw new Error("Goal did not inherit strategy version"); await count.fill("2"); + await strategy.selectOption("off"); await page.getByRole("button", { name: "预览变更", exact: true }).click(); - await page.getByRole("button", { name: "应用此预览", exact: true }).click(); + await applyGoalPreview(); await page.getByRole("button", { name: "恢复沿用设备默认", exact: true }).waitFor(); let goalCadence = await readGoalCadence(); if (goalCadence.effective_configuration.source !== "goal_override" || goalCadence.current.count !== 2) throw new Error("Goal override readback failed"); + if (goalCadence.current.stride_strategy !== "off") throw new Error("Goal stride opt-out failed"); + if ((await request(base)).machine_configuration.namespaces.todo_replan_cadence.stride_strategy !== "evidence_conditioned_v1") throw new Error("Goal override changed machine strategy"); + await strategy.selectOption("evidence_conditioned_v1"); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + await page.getByRole("button", { name: "恢复沿用设备默认", exact: true }).waitFor(); + if ((await readGoalCadence()).current.stride_strategy !== "evidence_conditioned_v1") throw new Error("Goal strategy activation failed"); await page.getByRole("button", { name: "恢复沿用设备默认", exact: true }).click(); - await page.getByRole("button", { name: "应用此预览", exact: true }).click(); + await applyGoalPreview(); await page.getByRole("button", { name: "恢复沿用设备默认", exact: true }).waitFor({ state: "detached" }); goalCadence = await readGoalCadence(); if (goalCadence.effective_configuration.source !== "machine_default" || goalCadence.effective_configuration.configuration.count !== 3) throw new Error("Goal clear did not restore device policy"); await page.setViewportSize({ width: 390, height: 844 }); await unit.focus(); + await page.keyboard.press("Tab"); + 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"); await page.setViewportSize({ width: 1512, height: 982 }); @@ -97,19 +121,20 @@ export const replanCadenceScenario = { await page.locator(".personal-settings-tabs").getByRole("button", { name: "Capability Center", exact: true }).click(); await page.getByRole("radio", { name: "One Goal", exact: true }).check(); await page.getByRole("combobox", { name: "Target Goal", exact: true }).selectOption("multi-agent-projection"); - await page.getByRole("navigation", { name: "Goal capability catalog" }).getByRole("button", { name: /Goal review cadence/ }).click(); + await page.getByRole("navigation", { name: "Goal capability catalog" }).getByRole("button", { name: /Goal review and stride/ }).click(); const englishUnit = page.getByLabel(/^Review after/); const englishCount = page.getByLabel("Number between reviews", { exact: true }); + const englishStrategy = page.getByLabel(/^Stride strategy/); await waitForInputValue(englishCount, "3"); - if (await englishUnit.inputValue() !== "effective_turns") throw new Error("English editor lost the persisted unit"); + if (await englishUnit.inputValue() !== "effective_turns" || await englishStrategy.inputValue() !== "evidence_conditioned_v1") throw new Error("English editor lost the unit or strategy"); await englishCount.fill("1"); await page.getByRole("button", { name: "Preview changes", exact: true }).click(); - await page.getByRole("button", { name: "Apply preview", exact: true }).click(); + await applyGoalPreview("Apply preview"); await page.getByRole("button", { name: "Use device defaults", exact: true }).waitFor(); if ((await readGoalCadence()).current.count !== 1) throw new Error("English override was not applied"); await page.screenshot({ path: resolve(outputDir, "replan-cadence-goal-english.png"), animations: "disabled" }); await page.getByRole("button", { name: "Use device defaults", exact: true }).click(); - await page.getByRole("button", { name: "Apply preview", exact: true }).click(); + await applyGoalPreview("Apply preview"); await page.getByRole("button", { name: "Use device defaults", exact: true }).waitFor({ state: "detached" }); if ((await readGoalCadence()).effective_configuration.configuration.count !== 3) throw new Error("English clear did not restore device policy"); const remove = { namespace: "todo_replan_cadence", operation: "remove" }; @@ -122,10 +147,10 @@ export const replanCadenceScenario = { await page.locator(".personal-settings-tabs").getByRole("button", { name: "Capability Center", exact: true }).click(); await page.getByRole("radio", { name: "One Goal", exact: true }).check(); await page.getByRole("combobox", { name: "Target Goal", exact: true }).selectOption("multi-agent-projection"); - await page.getByRole("navigation", { name: "Goal capability catalog" }).getByRole("button", { name: /Goal review cadence/ }).click(); + await page.getByRole("navigation", { name: "Goal capability catalog" }).getByRole("button", { name: /Goal review and stride/ }).click(); await waitForInputValue(englishCount, "6"); - if (await englishUnit.inputValue() !== "effective_turns") throw new Error("Goal editor did not restore product unit"); - return { coverageEntries: context.coverageEntries, note: "Packaged default six effective Turns, machine v0→v1 migration, invalid preview, Goal override/clear and device removal through real handlers." }; + if (await englishUnit.inputValue() !== "effective_turns" || await englishStrategy.inputValue() !== "off") throw new Error("Goal editor did not restore product defaults"); + return { coverageEntries: context.coverageEntries, note: "Packaged default six effective Turns, machine v0→v2 migration and default-off/versioned stride activation/Goal opt-out, invalid preview, Goal override/clear and device removal through real handlers." }; } catch (error) { if (context) { await context.page.screenshot({ path: resolve(outputDir, "replan-cadence-failure.png"), animations: "disabled" }); diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index f03c69d411..ce711e4795 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1074,7 +1074,7 @@ export const typedActionsScenario = { } if (await page.locator(".personal-capability-editor-status").count()) throw new Error("Editable Goal settings must not show internal editor-contract notices"); const expectedGoalCapabilities = [ - "变更质量验证", "Goal 复核周期", "探索图谱", "探索 Harness", "飞书事件收件箱", + "变更质量验证", "Goal 复盘与步幅", "探索图谱", "探索 Harness", "飞书事件收件箱", "飞书看板心跳同步", "已退役的 Authority 观测", "自适应子 Agent 容量", "已注册 Peer 任务协调", "周期报告", "Reward Memory 实验", ]; @@ -1294,7 +1294,7 @@ export const typedActionsScenario = { || api.machineConfigurationRequests.length !== requestsBeforeReadOnly) { throw new Error("Other machine settings mixed Goal-only or steward controls into the catalog"); } - await machineCatalog.getByRole("button", { name: /^Goal 复核周期/ }).click(); + await machineCatalog.getByRole("button", { name: /^Goal 复盘与步幅/ }).click(); const reviewUnit = page.getByLabel(/^复核计数依据/u); const reviewCount = page.getByLabel("两次复核间的数量", { exact: true }); await reviewUnit.waitFor({ state: "visible" }); diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index b88a7ad7ef..c0487b9f35 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -6,6 +6,7 @@ from ..configuration_transaction import configuration_payload_revision from ..control_plane.goals.goal_vision_policy import MAX_EFFECTIVE_TURN_REPLAN_THRESHOLD +from ..control_plane.work_items.stride_policy_codec import stride_strategy_options from .progress_review.policy import ( PROGRESS_REVIEW_MAX_DRIFT_THRESHOLD, @@ -114,6 +115,9 @@ 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("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."), ], }, "periodic_report": { diff --git a/loopx/capabilities/todo_replan_cadence/goal_configuration.py b/loopx/capabilities/todo_replan_cadence/goal_configuration.py index 01a64245ce..92550ae244 100644 --- a/loopx/capabilities/todo_replan_cadence/goal_configuration.py +++ b/loopx/capabilities/todo_replan_cadence/goal_configuration.py @@ -12,15 +12,18 @@ 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 {} if "replan_after_effective_turns" in profile: return { "count_unit": "effective_turns", "count": profile["replan_after_effective_turns"], + **strategy, } if "replan_after_completed_todos" in profile: return { "count_unit": "completed_todos", "count": completed_todo_replan_threshold(dict(profile)), + **strategy, } return None diff --git a/loopx/capabilities/todo_replan_cadence/machine_defaults.py b/loopx/capabilities/todo_replan_cadence/machine_defaults.py index 5ca81990a6..e281494cfd 100644 --- a/loopx/capabilities/todo_replan_cadence/machine_defaults.py +++ b/loopx/capabilities/todo_replan_cadence/machine_defaults.py @@ -27,6 +27,7 @@ class ReplanCadenceUnit(str, Enum): TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V1 = "todo_replan_cadence_machine_defaults_v1" +TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2 = "todo_replan_cadence_machine_defaults_v2" def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, Any]: @@ -35,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) != {"count_unit", "count"}: + if set(raw) not in ({"count_unit", "count"}, {"count_unit", "count", "stride_strategy"}): raise ValueError("review cadence requires count_unit and count") try: unit = ReplanCadenceUnit(raw["count_unit"]) @@ -48,7 +49,11 @@ def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, count = normalize_completed_todo_replan_threshold(count) else: count = normalize_effective_turn_replan_threshold(count) - return {"count_unit": unit.value, "count": count} + result = {"count_unit": unit.value, "count": count} + 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"]) + return result def normalize_todo_replan_cadence_machine_defaults( @@ -64,10 +69,13 @@ def normalize_todo_replan_cadence_machine_defaults( raw.get("completed_todos") ), } - if version != TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V1: - raise ValueError("todo_replan_cadence must use a supported v0 or v1 schema") - if set(raw) != {"schema_version", "count_unit", "count"}: - raise ValueError("v1 review cadence requires count_unit and count") + if version not in (TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V1, TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2): + raise ValueError("todo_replan_cadence must use a supported v0, v1 or v2 schema") + expected = {"schema_version", "count_unit", "count"} + if version == TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2: + expected.add("stride_strategy") + if set(raw) != expected: + raise ValueError("review configuration fields do not match its schema version") return { "schema_version": version, **normalize_replan_cadence_configuration( @@ -100,21 +108,24 @@ def todo_replan_cadence_machine_configuration_namespace() -> ( { TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA, TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V1, + TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2, } ), normalize=normalize_todo_replan_cadence_machine_defaults, project_public=lambda value: dict(value), apply_public_update=lambda _current, update: dict(update), - title="Goal review cadence", + title="Goal review and stride", description=( "Live review threshold for Goals without an explicit cadence override. " "Defaults to six settled work Turns; completed Todos remain an explicit option. " + "The optional versioned stride strategy is disabled by default; enabled fresh Vision reviews require a bounded structured proposal. " "It does not create " "turns, spend quota, or grant authority." ), default_configuration={ - "schema_version": TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V1, + "schema_version": TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2, **default_replan_cadence_configuration(), + "stride_strategy": "off", }, ) @@ -164,6 +175,8 @@ def apply_todo_replan_cadence_machine_default( else "replan_after_completed_todos" ) profile[field] = cadence["count"] + if cadence.get("stride_strategy", "off") != "off": + profile["stride_strategy"] = cadence["stride_strategy"] projected["execution_profile"] = profile return projected diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 0367e6c164..5295d7e997 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -189,7 +189,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} + return {"clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True} if capability_id == "pull_request_review": return {"clear_pull_request_review_configuration": True} if capability_id == "change_quality_qualification": @@ -202,7 +202,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"}, + "todo_replan_cadence": {"completed_todos", "count_unit", "count", "stride_strategy"}, "multi_subagent": { "enabled", "max_children", @@ -240,7 +240,8 @@ def _goal_capability_options( 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"]} + return {option: cadence["count"], **({"execution_stride_strategy": cadence["stride_strategy"]} + if "stride_strategy" in cadence else {})} if capability_id == "periodic_report": return {"periodic_report_configuration": config} if capability_id == "reward_memory": diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index 5932658ff6..3d44c9b284 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_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, clear_execution_replan_after_todos=bool( args.clear_execution_replan_after_todos diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 8690b8215f..ece249b284 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 ..control_plane.work_items.stride_policy_codec import stride_strategy_options + configure_goal_parser.add_argument( + "--execution-stride-strategy", choices=stride_strategy_options(), + help="Choose a versioned Agent stride strategy. Off preserves ordinary review; no automatic execution or authority change.", + ) + configure_goal_parser.add_argument( + "--clear-execution-stride-strategy", action="store_true", + help="Remove the strategy from the Goal review override (off). Clear the review cadence too to restore device defaults.", + ) configure_goal_parser.add_argument( "--quota-compute", type=float, diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index df5801d930..353fbc08f2 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -162,13 +162,14 @@ def build_goal_configuration_catalog( }, { "feature_id": "todo_replan_cadence", - "display_name": "Goal review cadence", + "display_name": "Goal review and stride", "availability": "supported_explicit_override", "default": default_replan_cadence_configuration(), "consider_when": "Direction needs regular review even while the same Todo remains open.", "effect": ( "Choose 1–6 settled work Turns or 1–5 completed Todos per Agent. " - "Legacy settings retain their completed-Todo units until explicitly changed." + "Legacy settings retain their completed-Todo units until explicitly changed. " + "The optional versioned stride strategy proposes work-slice and route adjustments; off retains ordinary review." ), "does_not": [ "create host continuation turns or interrupt running work", @@ -185,10 +186,10 @@ def build_goal_configuration_catalog( str(default_replan_cadence_configuration()["count"]), execute=True ), "preview_disable": _configure_command( - goal_id, "--clear-execution-replan-after-todos", "--clear-execution-replan-after-turns" + goal_id, "--clear-execution-replan-after-todos", "--clear-execution-replan-after-turns", "--clear-execution-stride-strategy" ), "apply_disable": _configure_command( - goal_id, "--clear-execution-replan-after-todos", "--clear-execution-replan-after-turns", execute=True + goal_id, "--clear-execution-replan-after-todos", "--clear-execution-replan-after-turns", "--clear-execution-stride-strategy", execute=True ), "verify": [inspect_command], }, diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 8cf8732ae0..4ad3ea02b8 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -438,6 +438,8 @@ def configure_goal( execution_turn_granularity: str | None = None, execution_replan_after_todos: int | None = None, execution_replan_after_turns: int | None = None, + execution_stride_strategy: str | None = None, + clear_execution_stride_strategy: bool = False, clear_execution_replan_after_turns: bool = False, clear_execution_replan_after_todos: bool = False, self_repair_enabled: bool | None = None, @@ -826,6 +828,7 @@ def configure_goal( turn_granularity=execution_turn_granularity, 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, 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/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index f59e3b97be..80efacdf42 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1781,6 +1781,7 @@ def build_goal_frontier_projection_context_from_status( goal_id=goal_id, agent_id=agent_id, newest_first_runs=(), source_status=status_payload, + stride_strategy=((project_asset or {}).get("execution_profile") or {}).get("stride_strategy", "off"), goal_acceptance_contract=(agent_todo_summary or {}).get("goal_acceptance_contract"), ) diff --git a/loopx/control_plane/goals/goal_vision.py b/loopx/control_plane/goals/goal_vision.py index 1a2f720789..7f92bda520 100644 --- a/loopx/control_plane/goals/goal_vision.py +++ b/loopx/control_plane/goals/goal_vision.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +from copy import deepcopy from .vision_checkpoint import ( GOAL_VISION_BUDGET_ERROR as GOAL_VISION_BUDGET_ERROR, @@ -150,6 +151,10 @@ def compact_goal_vision_packet(value: Any) -> dict[str, Any] | None: if todo_delta: compact["todo_delta"] = todo_delta + # Preserve the TS-normalized, bounded control proposal; no Python policy. + if isinstance(value.get("stride_decision"), dict): + compact["stride_decision"] = deepcopy(value["stride_decision"]) + declarations = _compact_fallback_declarations(value.get("fallback_declarations")) if declarations: compact["fallback_declarations"] = declarations diff --git a/loopx/control_plane/goals/goal_vision_read_model.py b/loopx/control_plane/goals/goal_vision_read_model.py index 4d6e6a58fb..87e7db4dba 100644 --- a/loopx/control_plane/goals/goal_vision_read_model.py +++ b/loopx/control_plane/goals/goal_vision_read_model.py @@ -83,6 +83,8 @@ def latest_agent_vision_from_runs( else None, "generated_at": run.get("generated_at"), } + if isinstance(vision.get("stride_decision"), dict): + result["stride_decision"] = vision["stride_decision"] if isinstance(vision.get("path_delta"), dict): result["path_delta"] = vision["path_delta"] if isinstance(vision.get("fallback_declarations"), list): diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index f350101128..4a2a93671b 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -11,6 +11,8 @@ import { type DeliveryOutcome, } from "../work_items/delivery_outcome.ts"; +import {normalizeStrideDecision, STRIDE_DECISION_SCHEMA, STRIDE_PROBE_LIMITS} from "../work_items/stride_policy.ts"; + export const VISION_REFRESH_REQUEST_SCHEMA = "loopx_vision_refresh_request_v0"; export const VISION_REFRESH_PREPARED_SCHEMA_VERSION = "vision_refresh_prepared_v0"; @@ -565,6 +567,13 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject if (typeof container !== "object" || container === null || Array.isArray(container)) continue; for (const [field, value] of Object.entries(container)) { if (prefix === "agent_vision" && field === "path_delta") continue; + if (prefix === "agent_vision" && field === "stride_decision") continue; + if (field === STRIDE_DECISION_SCHEMA || + (prefix === "agent_vision.vision_patch" && field === "stride_decision") || + (typeof value === "object" && value !== null && !Array.isArray(value) && + (value as JsonObject).schema_version === STRIDE_DECISION_SCHEMA)) { + throw new EffectRuntimeRequestError(`${prefix}.${field} must be supplied as agent_vision.stride_decision`); + } if (field === GOAL_PATH_DELTA_SCHEMA_VERSION || (prefix === "agent_vision.vision_patch" && field === "path_delta") || (typeof value === "object" && value !== null && !Array.isArray(value) && @@ -631,6 +640,12 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta); Object.assign(fieldUsage, pathDeltaUsage); + const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, boundedPublicText); + if (strideDecision && (!pathDelta || !["continue", "no_change", "replan"].includes(String(pathDelta.outcome)) || + !Array.isArray(pathDelta.evidence_refs) || !pathDelta.evidence_refs.length)) { + throw new EffectRuntimeRequestError("stride_decision requires a fresh evidence-linked path decision"); + } + Object.assign(fieldUsage, strideUsage); const [fallbackDeclarations, fallbackUsage] = normalizeFallbackDeclarations( updatePacket.fallback_declarations, ) ?? [null, {}]; @@ -672,6 +687,11 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject for (const [field, [, itemLimit]] of Object.entries(GOAL_PATH_DELTA_LIST_LIMITS)) { fieldLimits[`path_delta.${field}[]`] = itemLimit; } + if (strideDecision) { + for (const [field, limit] of Object.entries(STRIDE_PROBE_LIMITS)) { + fieldLimits[`stride_decision.next_probe.${field}`] = limit; + } + } fieldLimits["fallback_declarations"] = VISION_FALLBACK_DECLARATION_ENTRY_LIMIT; fieldLimits["fallback_declarations[]"] = VISION_FALLBACK_DECLARATION_ID_LIMIT; @@ -693,6 +713,7 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject validation, }; if (pathDelta !== null) agentVision.path_delta = pathDelta; + if (strideDecision !== null) agentVision.stride_decision = strideDecision; if (fallbackDeclarations !== null) { agentVision.fallback_declarations = fallbackDeclarations; } diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index d8a10230f1..9c993ff041 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -237,7 +237,7 @@ function replanActionPacket(payload: JsonObject): JsonObject | null { if (Object.keys(source).length === 0) return null; const compact = compactFields(source, [ "schema_version", "decision", "obligation_id", "uncovered_frontier", - "required_outcome", "allowed_terminal", "bounded_frontier", "settlement_only", "successor_todo_id", + "required_outcome", "allowed_terminal", "bounded_frontier", "settlement_only", "successor_todo_id", "stride_control", ]); // The typed replan owner supplies bounded instructions; do not truncate their // authority/stop qualifiers through generic diagnostic compaction. diff --git a/loopx/control_plane/stride_decision_schema.json b/loopx/control_plane/stride_decision_schema.json new file mode 100644 index 0000000000..71ae73733a --- /dev/null +++ b/loopx/control_plane/stride_decision_schema.json @@ -0,0 +1,7 @@ +{ + "schema_version": "stride_decision_v1", + "basis_options": ["supported_progress", "unexplained_gap", "uncertain_evidence", "useful_new_route", "decision_failure"], + "axis_names": ["work_slice", "route_distance", "validation_scope"], + "adjustments": ["grow", "hold", "shrink"], + "probe_field_limits": {"scope": 120, "prediction": 120, "adoption_criterion": 120, "cost_bound": 100} +} diff --git a/loopx/control_plane/stride_strategy_schema.json b/loopx/control_plane/stride_strategy_schema.json new file mode 100644 index 0000000000..af086af5dc --- /dev/null +++ b/loopx/control_plane/stride_strategy_schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Stride strategy version", + "type": "string", + "enum": ["off", "evidence_conditioned_v1"], + "default": "off" +} diff --git a/loopx/control_plane/work_items/progress_observation.py b/loopx/control_plane/work_items/progress_observation.py index 31c5169c14..91cc75ee43 100644 --- a/loopx/control_plane/work_items/progress_observation.py +++ b/loopx/control_plane/work_items/progress_observation.py @@ -483,6 +483,7 @@ def build_replan_context( obligation: Mapping[str, Any], *, goal_id: str, agent_id: str | None, newest_first_runs: Iterable[Mapping[str, Any]], source_status: Mapping[str, Any] | None = None, + stride_strategy: str = "off", goal_acceptance_contract: Mapping[str, Any] | None = None, ) -> dict[str, Any]: from .replan_context_codec import project_replan_context @@ -490,7 +491,8 @@ def build_replan_context( return project_replan_context( goal_id=goal_id, agent_id=agent_id or obligation.get("agent_id"), runs=newest_first_runs, obligation=obligation, source_status=source_status, - goal_acceptance_contract=goal_acceptance_contract, + goal_acceptance_contract=goal_acceptance_contract, stride_strategy=stride_strategy, + history_newest_first=True, ) @@ -602,6 +604,8 @@ def build_replan_action_packet( ProgressResultClass.NO_FOLLOWUP.value, ], } + if "stride_control" in requirements: + packet["stride_control"] = requirements["stride_control"] if isinstance(selected_gap, Mapping): packet["bounded_frontier"] = { key: selected_gap[key] diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index 29685d0585..3e0c1c01e9 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -4,8 +4,10 @@ import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; -import {requiredSemanticOutcomes} from "./replan_semantics.ts"; +import {FRESH_PATH_DISPOSITIONS, requiredSemanticOutcomes} from "./replan_semantics.ts"; import {readReplanSnapshot} from "./replan_history_snapshot.ts"; +import {normalizeGoalPathDelta} from "../goals/vision_checkpoint.ts"; +import {normalizeStrideDecision, normalizeStrideStrategy} from "./stride_policy.ts"; const CONTEXT_SCHEMA = "replan_context_v0"; // Replan is an infrequent decision, not the ordinary guard's display window. @@ -102,6 +104,97 @@ function coreGoal(value: unknown): JsonObject { objective_missing: objective === null, acceptance_contract: acceptance}; } +function acceptedDecisionSource(row: JsonObject): boolean { + const source = objectOrNull(row._source_facts); + const identity = objectOrNull(source?.settlement_identity); + const vision = objectOrNull(source?.agent_vision); + const ack = objectOrNull(source?.autonomous_replan_ack); + const delta = objectOrNull(ack?.semantic_delta); + return !!source && identity?.schema_version === "quota_settlement_identity_v0" && + ["goal_id", "agent_id", "todo_id"].every(key => identity[key] === row[key]) && + identity.turn_instance_id === source.turn_instance_id && + typeof identity.turn_instance_id === "string" && ID.test(identity.turn_instance_id) && + !objectOrNull(source.quota_spend_commit) && + vision?.schema_version === "goal_vision_replan_contract_v0" && vision.agent_id === row.agent_id && + (vision.goal_id == null || vision.goal_id === row.goal_id) && + ack?.schema_version === "autonomous_replan_ack_v0" && ack.recorded === true && + delta?.schema_version === "replan_semantic_delta_v0" && delta.accepted === true && + Array.isArray(delta.satisfying_outcomes) && delta.satisfying_outcomes.includes("fresh_vision_path_outcome"); +} + +function recordedDecision(row: JsonObject): JsonObject | null { + if (!acceptedDecisionSource(row)) return null; + const source = objectOrNull(row._source_facts)!; + let path: JsonObject | null; + try { + [path] = normalizeGoalPathDelta(objectOrNull(source.agent_vision)?.path_delta); + } catch { return null; } + if (!path || !FRESH_PATH_DISPOSITIONS.has(String(path.outcome)) || + !Array.isArray(path.evidence_refs) || !path.evidence_refs.length) return null; + const ref = objectOrNull(source.goal_ref); + if (source.goal_ref != null && (!ref || ref.goal_id !== row.goal_id || + typeof ref.goal_instance_id !== "string" || !ID.test(ref.goal_instance_id))) return null; + let stride: JsonObject | null; + try { [stride] = normalizeStrideDecision(objectOrNull(source.agent_vision)?.stride_decision); } + catch { return null; } + const decision = {history_evidence_ref: "replan-evidence-" + digest(publicEvidenceRow(row)), + generated_at: row.generated_at, todo_id: row.todo_id, turn_instance_id: source.turn_instance_id, + goal_ref: ref ? {goal_id: ref.goal_id, goal_instance_id: ref.goal_instance_id} : null, + path_delta: path, ...(stride ? {stride_decision: stride} : {})}; + // Existing history refs keep their identity. This derived ref additionally + // binds the path itself, so a changed decision cannot resolve old evidence. + return {evidence_ref: "replan-decision-" + digest(decision), ...decision}; +} + +/** Return a recorded decision to the next review, without judging its utility. + * Append order belongs to the captured history, not timestamps or prose. A + * progress observation proves neither execution nor scientific improvement. + */ +function decisionFollowthrough(rows: JsonObject[], goalFacts: unknown, fromFullIndex: boolean): JsonObject | null { + const currentRef = objectOrNull(objectOrNull(goalFacts)?.goal_ref); + const scoped = rows.filter(row => { + const source = objectOrNull(row._source_facts); + const ref = objectOrNull(source?.goal_ref); + return source && Number.isSafeInteger(source.history_position) && + Number(source.history_position) >= 0 && + (!currentRef || (ref?.goal_id === currentRef.goal_id && + ref?.goal_instance_id === currentRef.goal_instance_id)); + }).sort((a, b) => Number(objectOrNull(b._source_facts)!.history_position) - + Number(objectOrNull(a._source_facts)!.history_position)); + const previous = scoped.find(acceptedDecisionSource); + if (!previous) return null; + // Do not fall back to an older decision when the latest basis is corrupt. + const decision = recordedDecision(previous); + if (!decision) return null; + const source = objectOrNull(previous._source_facts)!; + const subsequent = new Map(); + for (const row of scoped) { + const facts = objectOrNull(row._source_facts)!; + if (Number(facts.history_position) <= Number(source.history_position) || !row.progress_observation) continue; + const progress = row.progress_observation as JsonObject; + if (!subsequent.has(String(progress.fingerprint))) subsequent.set(String(progress.fingerprint), row); + } + // The complete denominator is retained even when the review details are capped. + const observations = [...subsequent.values()]; + return { + previous_decision: decision, + source_history_complete: fromFullIndex, + unsequenced_progress_count: rows.filter(row => row.progress_observation && + !Number.isSafeInteger(objectOrNull(row._source_facts)?.history_position)).length, + recorded_progress_count: observations.length, + observations: observations.slice(0, 6).map(row => ({ + evidence_ref: "replan-evidence-" + digest(publicEvidenceRow(row)), + generated_at: row.generated_at, todo_id: row.todo_id, + coverage_ref: (row.progress_observation as JsonObject).fingerprint, + })), + omitted_observation_count: Math.max(0, observations.length - 6), + execution_and_goal_gain: "unknown", + guidance: "Compare the previous path decision with subsequent work and authoritative outcomes. " + + "Recorded progress and changed identifiers do not prove execution, a new mechanism, or goal gain. " + + "Resolve evidence before deciding what to retain, change, or test next.", + }; +} + export function validateReplanContext(value: unknown): JsonObject { const context = requireJsonObject(value, "replan_context"); if (context.schema_version !== CONTEXT_SCHEMA) { @@ -125,6 +218,26 @@ export function validateReplanContext(value: unknown): JsonObject { if (!Array.isArray(frontier.required_any_of)) { throw new EffectRuntimeRequestError("replan_context uncovered_frontier requires required_any_of"); } + if (context.stride_strategy !== undefined) normalizeStrideStrategy(context.stride_strategy); + if (context.decision_followthrough !== undefined) { + const follow = requireJsonObject(context.decision_followthrough, "decision_followthrough"); + if (context.stride_strategy !== "evidence_conditioned_v1" || follow.execution_and_goal_gain !== "unknown") { + throw new EffectRuntimeRequestError("decision followthrough requires the enabled strategy and unknown utility"); + } + const observations = objects(follow.observations, "decision_followthrough.observations"); + if (observations.length > 6 || typeof follow.source_history_complete !== "boolean" || + ["recorded_progress_count", "omitted_observation_count", "unsequenced_progress_count"].some( + key => !Number.isSafeInteger(follow[key]) || Number(follow[key]) < 0) || + Number(follow.recorded_progress_count) !== observations.length + Number(follow.omitted_observation_count)) { + throw new EffectRuntimeRequestError("decision followthrough requires bounded detail and explicit coverage counts"); + } + const previous = requireJsonObject(follow.previous_decision, "decision_followthrough.previous_decision"); + identifier(previous.evidence_ref, "previous_decision.evidence_ref"); + identifier(previous.history_evidence_ref, "previous_decision.history_evidence_ref"); + identifier(previous.turn_instance_id, "previous_decision.turn_instance_id"); + normalizeGoalPathDelta(previous.path_delta); + normalizeStrideDecision(previous.stride_decision); + } return context; } @@ -173,7 +286,7 @@ export function projectReplanContext(value: unknown): JsonObject { if (request.operation === "resolve") { if (!agent) throw new EffectRuntimeRequestError("evidence resolution requires agent_id"); const ref = identifier(request.evidence_ref, "evidence_ref"); - const match = unique.get(ref); + const match = unique.get(ref) ?? rows.map(recordedDecision).find(decision => decision?.evidence_ref === ref); if (!match) throw new EffectRuntimeRequestError( "evidence reference is unavailable in this Goal and Agent history window; refresh replan_context", "replan_evidence_unavailable"); @@ -252,7 +365,12 @@ export function projectReplanContext(value: unknown): JsonObject { ? [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 contextId = "replan-context-" + digest({goal, agent, obligationId, core, ledger, shown, frontier}); + const strideStrategy = normalizeStrideStrategy(request.stride_strategy); + const followthrough = agent && strideStrategy !== "off" + ? decisionFollowthrough(rows, request.goal_facts, request.from_full_index === true) : null; + const contextId = "replan-context-" + digest({goal, agent, obligationId, core, ledger, shown, frontier, + ...(strideStrategy !== "off" ? {stride_strategy: strideStrategy} : {}), + ...(followthrough ? {decision_followthrough: followthrough} : {})}); const context: JsonObject = { schema_version: CONTEXT_SCHEMA, context_id: contextId, goal_id: goal, agent_id: agent, obligation_id: obligationId, evidence_source: "compact_run_history", delivery: "host_projected", @@ -263,6 +381,8 @@ export function projectReplanContext(value: unknown): JsonObject { coverage_ledger: ledger, coverage_count: coverage.size, coverage_truncated: coverage.size > ledger.length, uncovered_frontier: frontier, }; + if (strideStrategy !== "off") context.stride_strategy = strideStrategy; + if (followthrough) context.decision_followthrough = followthrough; if (omitted.length) context.omitted_evidence = { distinct_observation_count: omitted.length, newest_at: omitted[0].row.generated_at, oldest_at: omitted.at(-1)!.first, diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py index 019f5b255e..f017e3fa79 100644 --- a/loopx/control_plane/work_items/replan_context_codec.py +++ b/loopx/control_plane/work_items/replan_context_codec.py @@ -29,11 +29,24 @@ def replan_history_from_status(status: Mapping[str, Any], goal_id: str) -> list[ return snapshot -def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, agent_id: str | None) -> list[dict[str, Any]]: +def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, agent_id: str | None, + include_decisions: bool = False, history_newest_first: bool | None = None, +) -> list[dict[str, Any]]: from .progress_observation import normalize_progress_observation + if history_newest_first is not None and not isinstance(history_newest_first, bool): + raise ValueError("history_newest_first must be a boolean or unknown") + # Carrier order is an IO fact, not a timestamp-based policy. Keep the wire + # rows in their original order so ordinary evidence/reference parity holds. + # Arbitrary iterables supply no causal positions unless the caller knows + # their order; real indexes are append ordered, compact history is newest first. + if include_decisions and history_newest_first is True: + runs = list(runs) + newest_position = len(runs) - 1 + else: + newest_position = None rows = [] - for run in runs: + for position, run in enumerate(runs): if not isinstance(run, Mapping): raise ValueError("replan history row must be an object") # Legacy compact records may omit the enclosing Goal id. Agent attribution @@ -63,12 +76,21 @@ def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, age text = public_safe_compact_text(run.get(key), limit=240) if text: row[key] = text - if "settlement_identity" in run or "quota_spend_commit" in run: + if "settlement_identity" in run or "quota_spend_commit" in run or (include_decisions and "agent_vision" in run): # Transport facts, not a Python eligibility decision. Complete-text # digests prevent a shared display prefix from erasing distinct tails. # This private wire metadata is excluded from public reference identity. source: dict[str, Any] = {key: run.get(key) for key in ( "goal_id", "agent_id", "todo_id", "turn_instance_id")} + # Facts only: the typed replan owner qualifies the prior decision + # and orders subsequent observations. Do not classify research + # progress or infer adoption from prose in this transport codec. + if include_decisions: + if history_newest_first is not None: + source["history_position"] = newest_position - position if newest_position is not None else position + source["goal_ref"] = run.get("goal_ref") + source["agent_vision"] = run.get("agent_vision") + source["autonomous_replan_ack"] = run.get("autonomous_replan_ack") for key, fields in (("settlement_identity", ( "schema_version", "goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id")), ("quota_spend_commit", ("schema_version", "effect_id"))): @@ -98,6 +120,8 @@ def goal_text(value: Any) -> str | None: goal = load_goal_from_registry(Path(str(status["registry"])), goal_id) if goal: + if goal.get("goal_instance_id"): + 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 and path.exists(): @@ -119,12 +143,18 @@ def project_replan_context( obligation: Mapping[str, Any] | None = None, evidence_ref: str | None = None, source_status: Mapping[str, Any] | None = None, goal_acceptance_contract: Mapping[str, Any] | None = None, + stride_strategy: str = "off", + history_newest_first: bool | None = None, ) -> dict[str, Any]: from .replan_history_codec import project_replan_request + from .stride_policy_codec import normalize_stride_strategy + + strategy = normalize_stride_strategy(stride_strategy) prefix = ["loopx"] if source_status is not None: runs = replan_history_from_status(source_status, goal_id) + history_newest_first = not bool(source_status.get("registry") and source_status.get("runtime_root")) for field, option in (("registry", "--registry"), ("runtime_root", "--runtime-root")): if source_status.get(field): prefix.extend([option, str(source_status[field])]) @@ -133,7 +163,10 @@ def project_replan_context( "operation": "resolve" if evidence_ref is not None else "project", "goal_id": goal_id, "agent_id": agent_id, "obligation": dict(obligation) if obligation is not None else None, - "rows": replan_evidence_rows(runs, goal_id=goal_id, agent_id=agent_id), + "rows": replan_evidence_rows(runs, goal_id=goal_id, agent_id=agent_id, + include_decisions=strategy != "off" or bool(evidence_ref and evidence_ref.startswith("replan-decision-")), + history_newest_first=history_newest_first), + "stride_strategy": strategy, "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 {}, diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 4f2cfb11f6..45d6eab60f 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -3,6 +3,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; import { visionAuthoringContract } from "../goals/vision_checkpoint.ts"; import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; +import {normalizeStrideDecision, normalizeStrideStrategy, strideControlContract, stridePlanningGuidance} from "./stride_policy.ts"; const PROGRESS_OUTCOMES = [ "new_surface", "new_hypothesis", "new_probe_family", "new_runnable_successor", @@ -24,7 +25,7 @@ const VISION_TRIGGERS = new Set([ "vision_successor_required", "required_agent_vision_missing", ]); const EXTERNAL_REVIEW_TRIGGERS = new Set(["external_progress_review_drift"]); -const FRESH_PATH_DISPOSITIONS = new Set(["continue", "no_change", "replan"]); +export const FRESH_PATH_DISPOSITIONS = new Set(["continue", "no_change", "replan"]); const PROGRESS_CLI_ARGS = "--progress-result-class --progress-surface-id --progress-hypothesis-id --progress-probe-kind --progress-evidence-id "; const VISION_CLI_ARGS = "--agent-vision-json ''"; @@ -325,7 +326,10 @@ export function projectReplanSemantics(value: unknown): JsonObject { const required = requiredSemanticOutcomes(obligation); const externalReview = isExternalReview(obligation); if (request.operation === "requirements") { - return {required_any_of: required, planning_guidance: [...REPLAN_PLANNING_GUIDANCE], + const strideControl = strideControlContract(object(obligation.replan_context), obligation.obligation_id); + return {required_any_of: required, planning_guidance: [...REPLAN_PLANNING_GUIDANCE, + ...stridePlanningGuidance(object(obligation.replan_context).stride_strategy)], + ...(strideControl ? {stride_control: strideControl} : {}), ...writebackProjection(required, externalReview)}; } if (request.operation !== "qualify") { @@ -337,6 +341,14 @@ export function projectReplanSemantics(value: unknown): JsonObject { const vision = object(request.agent_vision); const patch = object(vision.vision_patch); const path = object(vision.path_delta); + const context = object(obligation.replan_context); + const [strideDecision] = normalizeStrideDecision(vision.stride_decision); + const structuredReview = normalizeStrideStrategy(context.stride_strategy) !== "off"; + const freshPath = Boolean(String(patch.acceptance_summary ?? "").trim() && + FRESH_PATH_DISPOSITIONS.has(String(path.outcome ?? "").trim()) && strings(path.evidence_refs).length); + const missingStride = structuredReview && freshPath && !strideDecision; + const staleStride = !!strideDecision && structuredReview && + strideDecision.reference_obligation_id !== obligation.obligation_id; let outcomes = strings(observation.delta_kinds); if (outcomes.some(outcome => !KNOWN_OUTCOMES.has(outcome))) { throw new EffectRuntimeRequestError("observation_delta contains an unknown typed outcome"); @@ -359,11 +371,10 @@ export function projectReplanSemantics(value: unknown): JsonObject { const inconsistentTerminal = outcomes.includes("coverage_backed_no_followup") && (vision.state !== "no_followup" || path.outcome !== "stop"); if (inconsistentTerminal) outcomes = outcomes.filter(outcome => outcome !== "coverage_backed_no_followup"); - if (String(patch.acceptance_summary ?? "").trim() && - FRESH_PATH_DISPOSITIONS.has(String(path.outcome ?? "").trim()) && - strings(path.evidence_refs).length && !outcomes.includes("fresh_vision_path_outcome")) { + if (freshPath && !missingStride && !staleStride && !outcomes.includes("fresh_vision_path_outcome")) { outcomes.push("fresh_vision_path_outcome"); } + if (staleStride || missingStride) outcomes = outcomes.filter(outcome => outcome !== "fresh_vision_path_outcome"); const satisfying = inconsistentTerminal ? [] : outcomes.filter(outcome => required.includes(outcome as SemanticOutcome)); const replayRefused = (replayed || knownBlocker) && !satisfying.length && !inconsistentTerminal; const identityRefused = identityWithoutEvidence && !satisfying.length && !inconsistentTerminal; @@ -372,13 +383,17 @@ export function projectReplanSemantics(value: unknown): JsonObject { outcomes, satisfying_outcomes: satisfying, required_any_of: required, observation_fingerprint: observation.observation_fingerprint ?? null, reason: satisfying.length ? "writeback changes an outcome accepted by this obligation source" + : missingStride ? "enabled stride review requires agent_vision.stride_decision from the delivered control contract" + : staleStride ? "stride decision must reference the admitted replan obligation; reassess against its evidence" : inconsistentTerminal ? "coverage-backed no-follow-up requires agent_vision.state=no_followup and path_delta.outcome=stop" : replayRefused ? (knownBlocker ? "replan does not accept a blocker already recorded in its baseline, window or coverage ledger" : "external progress review does not accept a typed observation already claimed in the obligation window") : identityRefused ? "external progress review accepts a new surface, hypothesis or probe family only with evidence ids absent from the evaluated baseline and every claim in the obligation window" : "writeback does not satisfy this obligation's typed outcomes", - ...(inconsistentTerminal ? {reason_code: "no_followup_vision_path_inconsistent"} + ...(missingStride && !satisfying.length ? {reason_code: "stride_decision_required"} + : staleStride && !satisfying.length ? {reason_code: "stride_reference_obligation_mismatch"} + : inconsistentTerminal ? {reason_code: "no_followup_vision_path_inconsistent"} : replayRefused ? {reason_code: "progress_observation_replayed"} : identityRefused ? {reason_code: "progress_identity_without_new_evidence"} : {}), }; diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts new file mode 100644 index 0000000000..154db87671 --- /dev/null +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -0,0 +1,130 @@ +/** Optional stride strategy: proposes model decisions, never execution authority. + * Configuration tokens have one shared schema. Add a strategy implementation + * and qualify its behavior before making another version selectable. + */ +import schema from "../stride_strategy_schema.json" with {type: "json"}; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; + +export function normalizeStrideStrategy(value: unknown): string { + if (value === undefined) return schema.default; + if (typeof value !== "string" || !schema.enum.includes(value)) { + throw new EffectRuntimeRequestError("stride_strategy must be off or evidence_conditioned_v1"); + } + return value; +} + +export function stridePlanningGuidance(value: unknown): string[] { + const version = normalizeStrideStrategy(value); + if (version === "off") return []; + if (version === "evidence_conditioned_v1") return [ + "Review the previous path decision against subsequent work and authoritative outcomes. " + + "Adjust work-slice size, mechanism distance and validation breadth independently: a bold new " + + "route can start with a small probe; a supported route can justify a larger coherent slice. " + + "If local tuning leaves the goal gap unexplained, compare an alternative with continuation or replication; " + + "do not force a pivot from elapsed time or one noisy result. Preserve the best qualified artifact. " + + "State the next probe's distinguishing prediction, adoption criterion and bounded cost; review its " + + "actual execution and outcome next. Use the live stride_control contract to declare a bounded " + + "agent_vision.stride_decision; its strategy derives the three axes from the evidence basis. " + + "Use existing Todo/path/successor boundaries without changing authority or acceptance.", + ]; + throw new EffectRuntimeRequestError("stride strategy version has no qualified implementation"); +} + +// This is a model-assessed evidence basis, not a classifier of prose or scores. +// The versioned TS policy owns the three independently adjustable coordinates. +import decisionSchema from "../stride_decision_schema.json" with {type: "json"}; +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; + +const V1_VECTORS: Record = { + supported_progress: {work_slice: "grow", route_distance: "hold", validation_scope: "hold"}, + unexplained_gap: {work_slice: "shrink", route_distance: "grow", validation_scope: "shrink"}, + uncertain_evidence: {work_slice: "shrink", route_distance: "hold", validation_scope: "grow"}, + useful_new_route: {work_slice: "grow", route_distance: "hold", validation_scope: "grow"}, + decision_failure: {work_slice: "shrink", route_distance: "hold", validation_scope: "shrink"}, +}; + +export const STRIDE_DECISION_SCHEMA = decisionSchema.schema_version; +export const STRIDE_PROBE_LIMITS = decisionSchema.probe_field_limits; + +type BoundedText = (field: string, value: unknown, limit: number) => string; +function text(field: string, value: unknown, limit: number): string { + const result = requireNonEmptyString(value, field).trim(); + if (result.length > limit) throw new EffectRuntimeRequestError(`${field} exceeds ${limit} characters`); + return result; +} +function exactFields(value: JsonObject, fields: string[], label: string): void { + if (Object.keys(value).some(key => !fields.includes(key))) { + throw new EffectRuntimeRequestError(`${label} contains unsupported fields`); + } +} + +/** Normalize a declared control decision; derive the vector in the strategy + * owner rather than trusting an arbitrary model-supplied vector. The basis and + * predicted utility remain Agent claims, not certified evidence or authority. + */ +export function normalizeStrideDecision(value: unknown, bounded: BoundedText = text): + [JsonObject | null, Record] { + if (value === undefined) return [null, {}]; + const input = requireJsonObject(value, "stride_decision"); + exactFields(input, ["schema_version", "strategy_version", "reference_context_id", "reference_obligation_id", "basis", "axes", "next_probe"], "stride_decision"); + if (input.schema_version !== decisionSchema.schema_version || + normalizeStrideStrategy(input.strategy_version) !== "evidence_conditioned_v1") { + throw new EffectRuntimeRequestError("stride_decision requires stride_decision_v1 and evidence_conditioned_v1"); + } + const context = requireNonEmptyString(input.reference_context_id, "stride_decision.reference_context_id"); + if (!/^replan-context-[a-f0-9]{24}$/.test(context)) { + throw new EffectRuntimeRequestError("stride_decision requires the delivered replan context id"); + } + const obligation = requireNonEmptyString(input.reference_obligation_id, "stride_decision.reference_obligation_id"); + if (!/^replan-[a-f0-9]{16}$/.test(obligation)) { + throw new EffectRuntimeRequestError("stride_decision requires the admitted replan obligation id"); + } + if (typeof input.basis !== "string" || !decisionSchema.basis_options.includes(input.basis)) { + throw new EffectRuntimeRequestError("stride_decision.basis must be a registered model-assessed evidence basis"); + } + const axes = V1_VECTORS[input.basis]; + if (!axes) throw new EffectRuntimeRequestError("stride basis has no qualified strategy implementation"); + if (input.axes !== undefined) { + const proposed = requireJsonObject(input.axes, "stride_decision.axes"); + exactFields(proposed, decisionSchema.axis_names, "stride_decision.axes"); + if (decisionSchema.axis_names.some(axis => proposed[axis] !== axes[axis])) { + throw new EffectRuntimeRequestError("stride_decision axes do not match the selected version and basis"); + } + } + const probe = requireJsonObject(input.next_probe, "stride_decision.next_probe"); + exactFields(probe, Object.keys(decisionSchema.probe_field_limits), "stride_decision.next_probe"); + const normalized: JsonObject = {}; + const usage: Record = {}; + for (const [field, limit] of Object.entries(decisionSchema.probe_field_limits)) { + const label = `stride_decision.next_probe.${field}`; + normalized[field] = bounded(label, requireNonEmptyString(probe[field], label), limit); + usage[label] = String(normalized[field]).length; + } + for (const [field, entry] of Object.entries({strategy_version: input.strategy_version, + reference_context_id: context, reference_obligation_id: obligation, basis: input.basis, ...axes})) { + usage[`stride_decision.${decisionSchema.axis_names.includes(field) ? "axes." : ""}${field}`] = String(entry).length; + } + return [{schema_version: decisionSchema.schema_version, strategy_version: input.strategy_version, + reference_context_id: context, reference_obligation_id: obligation, basis: input.basis, axes: {...axes}, next_probe: normalized}, usage]; +} + +export function strideControlContract(context: JsonObject, obligationId: unknown = null): JsonObject | null { + if (normalizeStrideStrategy(context.stride_strategy) === "off") return null; + return {schema_version: decisionSchema.schema_version, strategy_version: context.stride_strategy, + reference_context_id: context.context_id ?? null, reference_obligation_id: obligationId, + basis_vectors: V1_VECTORS, + minimal_example: {schema_version: decisionSchema.schema_version, strategy_version: context.stride_strategy, + reference_context_id: context.context_id ?? null, reference_obligation_id: obligationId, + basis: "uncertain_evidence", next_probe: {scope: "One discriminating probe.", + prediction: "Distinguish the remaining explanations.", adoption_criterion: "Applicable evidence supports the next slice.", + cost_bound: "One bounded comparison before another review."}}, + probe_field_limits: decisionSchema.probe_field_limits, + attachment: "agent_vision.stride_decision", requires_structured_fresh_path: true, + rule: "Enabled fresh Vision path reviews require this structured proposal. Other source-specific exits remain unchanged. " + + "Assess the evidence basis using the delivered context. The strategy derives the axes; " + + "declare the next probe's scope, prediction, adoption criterion and cost bound. " + + "The declaration is a structured control proposal, not execution permission or proof of utility. " + + "Todo/Next Action governs admitted work; review actual execution and results before expanding.", + }; +} diff --git a/loopx/control_plane/work_items/stride_policy_codec.py b/loopx/control_plane/work_items/stride_policy_codec.py new file mode 100644 index 0000000000..f911b4041e --- /dev/null +++ b/loopx/control_plane/work_items/stride_policy_codec.py @@ -0,0 +1,23 @@ +"""Configuration transport codec; stride decisions belong to the TS component. + +The root package asset is shared with TypeScript and already included in wheels. +Reading these lexical options does not start Node or introduce a Python policy. +""" +from __future__ import annotations + +import json +from functools import lru_cache +from importlib.resources import files +from typing import Any + + +@lru_cache(maxsize=1) +def stride_strategy_options() -> tuple[str, ...]: + contract = json.loads(files("loopx.control_plane").joinpath("stride_strategy_schema.json").read_text()) + return tuple(contract["enum"]) + + +def normalize_stride_strategy(value: Any) -> str: + if not isinstance(value, str) or value not in stride_strategy_options(): + raise ValueError("stride_strategy must be off or evidence_conditioned_v1") + return value diff --git a/loopx/execution_profile.py b/loopx/execution_profile.py index 987d9c92ea..7ead8148aa 100644 --- a/loopx/execution_profile.py +++ b/loopx/execution_profile.py @@ -154,6 +154,12 @@ def compact_execution_profile(value: Any) -> dict[str, Any]: if not isinstance(value, dict): return profile + 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"]) + if not any(key in value for key in ("replan_after_effective_turns", "replan_after_completed_todos")): + raise ValueError("a Goal stride strategy requires an explicit review cadence in the same profile") + if "replan_after_effective_turns" in value and "replan_after_completed_todos" in value: raise ValueError("choose one review cadence unit") if "replan_after_effective_turns" in value: @@ -243,6 +249,8 @@ def apply_goal_execution_profile_change( clear_replan_after_completed_todos: bool, replan_after_effective_turns: int | None = None, clear_replan_after_effective_turns: bool = False, + stride_strategy: str | None = None, + clear_stride_strategy: bool = False, ) -> None: if ( clear_replan_after_completed_todos @@ -256,23 +264,38 @@ def apply_goal_execution_profile_change( replan_after_completed_todos is not None or clear_replan_after_effective_turns ): 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") raw = goal.get("execution_profile") profile = dict(raw) if isinstance(raw, dict) else {} if ( - turn_granularity is None + stride_strategy is None + and not clear_stride_strategy + and turn_granularity is None and replan_after_completed_todos is None and replan_after_effective_turns is None and not clear_replan_after_effective_turns and not (clear_replan_after_completed_todos and "replan_after_completed_todos" in profile) ): return + if clear_stride_strategy: + profile.pop("stride_strategy", None) + if stride_strategy is not None: + profile["stride_strategy"] = stride_strategy if clear_replan_after_effective_turns or replan_after_completed_todos is not None: profile.pop("replan_after_effective_turns", None) + if replan_after_completed_todos is not None: + profile["replan_after_completed_todos"] = replan_after_completed_todos if replan_after_effective_turns is not None: profile["replan_after_effective_turns"] = replan_after_effective_turns profile.pop("replan_after_completed_todos", None) if clear_replan_after_completed_todos: profile.pop("replan_after_completed_todos", None) + if stride_strategy is None and (clear_replan_after_completed_todos or clear_replan_after_effective_turns) and not any( + key in profile for key in ("replan_after_effective_turns", "replan_after_completed_todos") + ): + # This capability is one review configuration, not per-field inheritance. + profile.pop("stride_strategy", None) goal["execution_profile"] = configure_execution_profile( profile, turn_granularity=turn_granularity, diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 6a5aa75de2..5933fd344f 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1031,7 +1031,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_transaction:project_registry_transaction#1", - "line": 521, + "line": 523, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -1039,7 +1039,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_read:load_project_registry#1", - "line": 731, + "line": 733, "column": 14, "kind": "codec_read", "api": "load_project_registry", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index ba416d7b3a..fa4be004b1 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -444,7 +444,7 @@ def _build_state_refresh_output_projections( field: agent_vision.get(field) for field in ( "schema_version", "agent_id", "state", "vision_patch", - "todo_delta", "fallback_declarations", "vision_budget", + "todo_delta", "fallback_declarations", "stride_decision", "vision_budget", ) } if isinstance(agent_vision.get("path_delta"), dict): diff --git a/tests/capabilities/test_machine_configuration_goal_defaults.py b/tests/capabilities/test_machine_configuration_goal_defaults.py index 95a8307eae..6dfe7b3fdd 100644 --- a/tests/capabilities/test_machine_configuration_goal_defaults.py +++ b/tests/capabilities/test_machine_configuration_goal_defaults.py @@ -329,3 +329,63 @@ def test_product_default_and_namespace_removal_use_six_settled_turns(tmp_path, m configure_goal(registry_path=registry_path, goal_id=GOAL_ID, clear_execution_replan_after_todos=True, execute=True) assert _history_goal(registry_path, runtime_root)["execution_profile"]["replan_after_effective_turns"] == 6 + + +def test_stride_version_inherits_as_one_review_configuration_and_rolls_back(tmp_path: Path) -> None: + 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": 6, + "stride_strategy": "evidence_conditioned_v1", + } + before = registry_path.read_bytes() + preview = configure_machine_configuration(runtime_root=runtime_root, + registry=owner, configuration=configuration) + assert registry_path.read_bytes() == before + 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"]["stride_strategy"] == "evidence_conditioned_v1" + # A legacy Goal override pins the whole review configuration, including off. + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + execution_replan_after_turns=3, execute=True) + assert "stride_strategy" not in _history_goal(registry_path, runtime_root)["execution_profile"] + enabled = _goal_capability_options("todo_replan_cadence", { + "count_unit": "effective_turns", "count": 3, "stride_strategy": "evidence_conditioned_v1"}) + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, **enabled) + inspected = inspect_goal_capabilities(registry_path=registry_path, runtime_root=runtime_root, + goal_id=GOAL_ID)["configuration"] + review = next(item for item in inspected["capability_catalog"]["capabilities"] + if item["capability_id"] == "todo_replan_cadence") + assert review["current"] == {"count_unit": "effective_turns", "count": 3, "stride_strategy": "evidence_conditioned_v1"} + assert review["effective_configuration"]["source"] == "goal_override" + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + execution_stride_strategy="off", execute=True) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["stride_strategy"] == "off" + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + clear_execution_replan_after_turns=True, execute=True) + assert "stride_strategy" not in json.loads(registry_path.read_text())["goals"][0]["execution_profile"] + assert _history_goal(registry_path, runtime_root)["execution_profile"]["stride_strategy"] == "evidence_conditioned_v1" + # Unsupported versions fail before any registry mutation. + before = registry_path.read_bytes() + with pytest.raises(ValueError, match="stride_strategy"): + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + execution_replan_after_turns=3, execution_stride_strategy="future_v2", execute=True) + assert registry_path.read_bytes() == before + + +@pytest.mark.parametrize("schema_version, strategy", [ + ("todo_replan_cadence_machine_defaults_v1", "evidence_conditioned_v1"), + ("todo_replan_cadence_machine_defaults_v2", "future_v2"), + ("todo_replan_cadence_machine_defaults_v2", None), +]) +def test_stride_configuration_rejects_unsupported_or_ambiguous_versions(schema_version, strategy): + with pytest.raises(ValueError): + normalize_todo_replan_cadence_machine_defaults({ + "schema_version": schema_version, "count_unit": "effective_turns", + "count": 6, "stride_strategy": strategy, + }) diff --git a/tests/control_plane/test_long_chain_projected_closeout.py b/tests/control_plane/test_long_chain_projected_closeout.py index 51295bef74..525f56ca2c 100644 --- a/tests/control_plane/test_long_chain_projected_closeout.py +++ b/tests/control_plane/test_long_chain_projected_closeout.py @@ -91,6 +91,10 @@ def guard(turn): "--agent-id", AGENT_ID, "--turn-instance-id", turn, "--scan-path", str(project)) + call("configure-goal", "--goal-id", GOAL_ID, + "--execution-replan-after-turns", "6", + "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + shared = call("quota", "should-run", "--runtime-profile", "generic_cli", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) assert shared["goal_frontier_projection"]["replan_required"] is False @@ -114,6 +118,11 @@ def guard(turn): "kind": "autonomous_replan", "id": original["obligation_id"], "settlement_bound": False, "discharge": "todo_bound_writeback", } + control = before["replan_action_packet"]["stride_control"] + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.quota.cli_projection import compact_quota_should_run_cli_payload + for transport in (build_turn_envelope(before), compact_quota_should_run_cli_payload(before)): + assert transport["replan_action_packet"]["stride_control"] == control refresh, spend = actions assert "--agent-vision-json" in refresh assert "--replan-obligation-id" not in refresh @@ -133,6 +142,19 @@ def guard(turn): "acceptance_summary": "Each slice has independent validation before dependent work proceeds.", "advancement_policy": "as_needed", }, + "stride_decision": { + "schema_version": "stride_decision_v1", + "strategy_version": "evidence_conditioned_v1", + "reference_context_id": before["replan_action_packet"]["stride_control"]["reference_context_id"], + "reference_obligation_id": before["replan_action_packet"]["stride_control"]["reference_obligation_id"], + "basis": "unexplained_gap", + "next_probe": { + "scope": "Execute one alternative mechanism probe.", + "prediction": "The alternative distinguishes the remaining explanation.", + "adoption_criterion": "Retain only a validated improvement on accepted requirements.", + "cost_bound": "One isolated probe before another scope decision.", + }, + }, "path_delta": { "schema_version": "goal_path_delta_v0", "outcome": "replan", "prior_assumption": "The long chain needed a bounded review.", @@ -143,6 +165,16 @@ def guard(turn): }, })) refresh = refresh.replace("", str(decision)) + # Opt-in control is a real validation rule, not only extra prompt text. + valid_packet = json.loads(decision.read_text()) + legacy_packet = {key: value for key, value in valid_packet.items() if key != "stride_decision"} + decision.write_text(json.dumps(legacy_packet)) + rc, refused = _run_cli(registry, runtime, + *_projected_cli_args(refresh, turn_instance_id=TURN_ID), cwd=project) + assert rc == 1 + assert "requires agent_vision.stride_decision" in refused["error"] + assert _spend_run_count(runtime) == 0 + decision.write_text(json.dumps(valid_packet)) refreshed = call(*_projected_cli_args(refresh, turn_instance_id=TURN_ID)) assert refreshed["vision_checkpoint"]["satisfied"] is True ack = refreshed["autonomous_replan_ack"] @@ -154,6 +186,10 @@ def guard(turn): assert identity["turn_instance_id"] == TURN_ID persisted = json.loads(Path(refreshed["json_path"]).read_text()) assert persisted["autonomous_replan_ack"] == ack + assert persisted["agent_vision"]["stride_decision"]["axes"] == { + "work_slice": "shrink", "route_distance": "grow", "validation_scope": "shrink"} + from loopx.control_plane.goals.goal_vision import compact_goal_vision_packet + assert compact_goal_vision_packet(persisted["agent_vision"])["stride_decision"] == persisted["agent_vision"]["stride_decision"] assert _spend_run_count(runtime) == 0 settled = call(*_projected_cli_args(spend, turn_instance_id=TURN_ID)) assert settled["settlement_result"]["ok"] is True @@ -166,9 +202,39 @@ def guard(turn): assert after["decision"] == "run" assert after["goal_frontier_projection"]["replan_required"] is False assert _spend_run_count(runtime) == 1 + # A real second admitted Turn executes and records a bounded synthetic + # probe. The next review may observe that work, but cannot infer utility. + _bind_selected_replan_guard(registry, runtime, project, "turn-after-chain-review") + probe = project / "bounded-probe.txt" + probe.write_text("Alternative mechanism: isolated synthetic execution.\n") + assert probe.read_text().startswith("Alternative mechanism:") + call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", SELECTED_REPLAN_TODO_ID, "--turn-instance-id", "turn-after-chain-review", + "--classification", "bounded_probe", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--next-action", "Compare the executed alternative against the accepted requirement.", + "--progress-result-class", "advanced", "--progress-surface-id", "surface-alternative", + "--progress-probe-kind", "controlled-mechanism-comparison", + "--progress-evidence-id", "evidence:executed-alternative-probe") + call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", SELECTED_REPLAN_TODO_ID, "--turn-instance-id", "turn-after-chain-review", + "--slots", "1", "--source", "heartbeat", "--execute") + assert _spend_run_count(runtime) == 2 call("todo", "update", "--goal-id", GOAL_ID, "--todo-id", SELECTED_REPLAN_TODO_ID, "--agent-id", AGENT_ID, "--text", "Validate a changed acceptance boundary") changed = call("quota", "should-run", "--runtime-profile", "generic_cli", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) rearmed = changed["autonomous_replan_obligation"] assert rearmed["rearmed_after_obligation_id"] == ack["semantic_delta"]["obligation_id"] + follow = rearmed["replan_context"]["decision_followthrough"] + assert follow["previous_decision"]["turn_instance_id"] == TURN_ID + assert follow["previous_decision"]["path_delta"]["changed"] == ["Proceed with the first validation slice"] + assert follow["previous_decision"]["stride_decision"] == persisted["agent_vision"]["stride_decision"] + assert follow["recorded_progress_count"] == 1 + assert follow["execution_and_goal_gain"] == "unknown" + assert follow["source_history_complete"] is True + resolved = call("history", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--evidence-ref", follow["previous_decision"]["evidence_ref"]) + assert resolved["evidence"]["path_delta"] == follow["previous_decision"]["path_delta"] + assert "_source_facts" not in resolved["evidence"] + assert _spend_run_count(runtime) == 2 diff --git a/tests/control_plane/test_replan_context_order.py b/tests/control_plane/test_replan_context_order.py new file mode 100644 index 0000000000..513e7eb0d5 --- /dev/null +++ b/tests/control_plane/test_replan_context_order.py @@ -0,0 +1,91 @@ +"""Causal feedback must use the carrier's declared order, never its timestamps.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.work_items.progress_observation import build_replan_context +from loopx.control_plane.work_items.replan_context_codec import project_replan_context +from test_replan_context_evidence import _settled_pair + + +def _rows(): + work, _ = _settled_pair() + + def decision(turn, timestamp): + return {**work, "generated_at": timestamp, "turn_instance_id": turn, + "settlement_identity": {**work["settlement_identity"], "turn_instance_id": turn}, + "agent_vision": {"schema_version": "goal_vision_replan_contract_v0", "agent_id": "agent-a", + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "replan", + "prior_assumption": "The current route could explain the gap.", + "observed_reality": "The gap remains unexplained.", "changed": ["Compare another route."], + "evidence_refs": ["evidence:route-basis"]}}, + "autonomous_replan_ack": {"schema_version": "autonomous_replan_ack_v0", "recorded": True, + "semantic_delta": {"schema_version": "replan_semantic_delta_v0", "accepted": True, + "satisfying_outcomes": ["fresh_vision_path_outcome"]}}} + + older = decision("turn-old", "2026-08-18T03:00:00Z") + latest = decision("turn-latest", "2026-08-18T01:00:00Z") + later = {**work, "turn_instance_id": "turn-probe", "generated_at": "2026-08-18T00:00:00Z", + "settlement_identity": {**work["settlement_identity"], "turn_instance_id": "turn-probe"}, + "progress_observation": {**work["progress_observation"], "surface_id": "surface-probe"}} + return [older, latest, later] + + +def _assert_followthrough(context): + follow = context["decision_followthrough"] + assert follow["previous_decision"]["turn_instance_id"] == "turn-latest" + assert follow["recorded_progress_count"] == 1 + assert follow["observations"][0]["generated_at"] == "2026-08-18T00:00:00Z" + assert follow["execution_and_goal_gain"] == "unknown" + return follow + + +@pytest.mark.parametrize("shape", ["runs", "goals"]) +def test_compact_status_newest_first_returns_latest_decision_and_later_work(shape): + rows = list(reversed(_rows())) + history = {"runs": rows} if shape == "runs" else {"goals": [{"id": "evidence-goal", "latest_runs": rows}]} + context = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=(), + source_status={"run_history": history}, stride_strategy="evidence_conditioned_v1") + assert _assert_followthrough(context)["source_history_complete"] is False + + +def test_named_newest_first_builder_preserves_order_without_a_status_source(): + context = build_replan_context({"obligation_id": "replan-fixture"}, goal_id="evidence-goal", + agent_id="agent-a", newest_first_runs=reversed(_rows()), stride_strategy="evidence_conditioned_v1") + _assert_followthrough(context) + + +def test_real_append_index_ignores_compact_display_order_and_clock_skew(tmp_path): + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "evidence-goal", "repo": str(tmp_path)}]})) + index = tmp_path / "goals/evidence-goal/runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("".join(json.dumps(row) + "\n" for row in _rows())) + before = index.read_bytes() + context = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=(), + source_status={"registry": str(registry), "runtime_root": str(tmp_path), + "run_history": {"runs": [_rows()[0]]}}, stride_strategy="evidence_conditioned_v1") + assert _assert_followthrough(context)["source_history_complete"] is True + assert index.read_bytes() == before + + +@pytest.mark.parametrize("rows", [_rows(), list(reversed(_rows()))]) +def test_unknown_order_does_not_fabricate_a_previous_decision_or_later_work(rows): + context = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows, + stride_strategy="evidence_conditioned_v1") + assert "decision_followthrough" not in context + assert context["evidence_count"] == 3 + + +def test_carrier_metadata_preserves_feature_off_parity_and_rejects_non_boolean_order(): + rows = list(reversed(_rows())) + ordinary = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows) + declared = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows, + history_newest_first=True, stride_strategy="off") + assert declared == ordinary + for invalid in [0, 1, "newest_first"]: + with pytest.raises(ValueError, match="boolean or unknown"): + project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows, + history_newest_first=invalid) diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts index b288807233..7b344a0897 100644 --- a/tests/control_plane_ts/replan_context.test.ts +++ b/tests/control_plane_ts/replan_context.test.ts @@ -107,6 +107,134 @@ function pair(time = 1): [JsonObject, JsonObject] { return [work, accounting]; } +function reviewed(position = 0): JsonObject { + const [work] = pair(position + 1); + return {...work, _source_facts: {...work._source_facts, history_position: position, + agent_vision: {schema_version: "goal_vision_replan_contract_v0", agent_id: "agent-a", + path_delta: {schema_version: "goal_path_delta_v0", outcome: "replan", + prior_assumption: "The current method could explain the remaining gap.", + observed_reality: "Local validation improved but target quality remains uncertain.", + changed: ["Test an alternative mechanism on a bounded comparison."], + evidence_refs: ["evidence:review-basis"]}}, + autonomous_replan_ack: {schema_version: "autonomous_replan_ack_v0", recorded: true, + semantic_delta: {schema_version: "replan_semantic_delta_v0", accepted: true, + satisfying_outcomes: ["fresh_vision_path_outcome"]}}}}; +} + +function subsequent(position: number): JsonObject { + const [work] = pair(position + 1); + return {...work, progress_observation: {...progress, result_class: "advanced", + fingerprint: "probe-" + position, hypothesis_id: "hypothesis-local"}, + _source_facts: {...work._source_facts, history_position: position}}; +} + +const strideRequest = {...request, stride_strategy: "evidence_conditioned_v1"}; + +test("a prior accepted path returns with attributable later observations, never a utility verdict", () => { + const decision = reviewed(); + const later = subsequent(1); + const context = projectReplanContext({...strideRequest, from_full_index: true, rows: [decision, later]}); + const follow = context.decision_followthrough as JsonObject; + assert.equal(follow.recorded_progress_count, 1); + assert.equal(follow.execution_and_goal_gain, "unknown"); + assert.equal(follow.source_history_complete, true); + assert.equal(follow.unsequenced_progress_count, 0); + const previous = follow.previous_decision as JsonObject; + assert.equal(previous.todo_id, "todo-a"); + const resolve = projectReplanContext({...strideRequest, operation: "resolve", rows: [decision, later], + evidence_ref: previous.evidence_ref}); + assert.deepEqual((resolve.evidence as JsonObject).path_delta, previous.path_delta); + assert.ok(!("_source_facts" in (resolve.evidence as JsonObject))); + const changed = {...decision, _source_facts: {...decision._source_facts, + agent_vision: {schema_version: "goal_vision_replan_contract_v0", agent_id: "agent-a", + path_delta: {...previous.path_delta as JsonObject, changed: ["A replaced decision"]}}}}; + assert.throws(() => projectReplanContext({...strideRequest, operation: "resolve", rows: [changed, later], + evidence_ref: previous.evidence_ref}), /unavailable/); + + assert.deepEqual((follow.observations as JsonObject[])[0].coverage_ref, "probe-1"); + assert.ok(!("progress_observation" in (follow.observations as JsonObject[])[0])); + assert.deepEqual(context.uncovered_frontier, + projectReplanContext({...strideRequest, rows: [decision, later]}).uncovered_frontier); +}); + +test("append order survives clock skew; replay and accounting do not add research observations", () => { + const older = reviewed(); + const latest = {...reviewed(2), generated_at: row(0).generated_at, observed_at: 0}; + const before = subsequent(1); + const after = {...subsequent(3), generated_at: row(-1).generated_at, observed_at: -1}; + const [, accounting] = pair(5); + const context = projectReplanContext({...strideRequest, rows: [older, before, latest, after, after, accounting]}); + const follow = context.decision_followthrough as JsonObject; + assert.equal((follow.previous_decision as JsonObject).turn_instance_id, "turn-3"); + assert.equal(follow.recorded_progress_count, 1); + assert.equal((follow.observations as JsonObject[])[0].coverage_ref, "probe-3"); +}); + +test("bounded detail retains the full denominator and missing order remains visible", () => { + const context = projectReplanContext({...strideRequest, + rows: [reviewed(), ...Array.from({length: 9}, (_, i) => subsequent(i + 1)), row(12)]}); + const follow = context.decision_followthrough as JsonObject; + assert.equal(follow.recorded_progress_count, 9); + assert.equal((follow.observations as JsonObject[]).length, 6); + assert.equal(follow.omitted_observation_count, 3); + assert.equal(follow.unsequenced_progress_count, 1); + assert.equal(follow.source_history_complete, false); +}); + +test("missing, unaccepted or corrupt latest decision cannot impersonate an evidenced review", () => { + const valid = reviewed(); + const facts = valid._source_facts as JsonObject; + for (const patch of [{agent_vision: null}, {autonomous_replan_ack: null}, + {settlement_identity: null}, {turn_instance_id: "unrelated"}]) { + const context = projectReplanContext({...strideRequest, rows: [{...valid, _source_facts: {...facts, ...patch}}]}); + assert.equal(context.decision_followthrough, undefined); + } + const newer = reviewed(2); + const corrupt = {...newer, _source_facts: {...newer._source_facts, + agent_vision: {schema_version: "goal_vision_replan_contract_v0", agent_id: "agent-a", + path_delta: {outcome: "replan"}}}}; + assert.equal(projectReplanContext({...strideRequest, rows: [valid, corrupt]}).decision_followthrough, undefined); +}); + +test("another lane or retired Goal instance never supplies current decision evidence", () => { + const valid = reviewed(); + const other = {...valid, agent_id: "agent-b"}; + assert.equal(projectReplanContext({...strideRequest, rows: [other]}).decision_followthrough, undefined); + const oldRef = {goal_id: "goal-a", goal_instance_id: "instance-old"}; + const currentRef = {...oldRef, goal_instance_id: "instance-current"}; + const old = {...valid, _source_facts: {...valid._source_facts, goal_ref: oldRef}}; + assert.equal(projectReplanContext({...strideRequest, goal_facts: {goal_ref: currentRef}, rows: [old]}) + .decision_followthrough, undefined); + const current = {...valid, _source_facts: {...valid._source_facts, goal_ref: currentRef}}; + assert.deepEqual(((projectReplanContext({...strideRequest, goal_facts: {goal_ref: currentRef}, rows: [current]}) + .decision_followthrough as JsonObject).previous_decision as JsonObject).goal_ref, currentRef); +}); + +test("strategy off preserves context identity and selection despite decision transport facts", () => { + const rows = [reviewed(), subsequent(1)]; + const ordinary = projectReplanContext({...request, rows}); + const off = projectReplanContext({...request, rows, stride_strategy: "off"}); + assert.deepEqual(off, ordinary); + assert.equal(off.decision_followthrough, undefined); + const enabled = projectReplanContext({...strideRequest, rows}); + assert.equal(enabled.stride_strategy, "evidence_conditioned_v1"); + assert.deepEqual(validateReplanContext(enabled), enabled); + for (const patch of [{execution_and_goal_gain: "advanced"}, {recorded_progress_count: -1}, + {omitted_observation_count: 10}, {source_history_complete: null}]) { + assert.throws(() => validateReplanContext({...enabled, decision_followthrough: { + ...enabled.decision_followthrough as JsonObject, ...patch}}), /followthrough/); + } + assert.throws(() => validateReplanContext({...enabled, stride_strategy: "off"}), /enabled strategy/); + + assert.notEqual(enabled.context_id, off.context_id); + for (const field of ["evidence", "coverage_ledger", "uncovered_frontier", "evidence_count"]) { + assert.deepEqual(enabled[field], off[field]); + } + for (const version of ["future_v2", null, 1]) { + assert.throws(() => projectReplanContext({...request, rows, stride_strategy: version}), /stride_strategy/); + } +}); + test("reduction preserves raw counts, negative coverage, obligation and immutable resolution", () => { const [work, accounting] = pair(); const legacy = [work, accounting].map(({_source_facts: _source, ...row}) => row); diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 42ef0ccf5a..bbfd9be05a 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -310,6 +310,27 @@ test("planning advice cannot discharge a replan or widen source-specific exits", }); +test("versioned stride advice is opt-in and never a semantic discharge", () => { + const plain = {triggers: [{kind: "typed_progress_repeat"}]}; + const base = projectReplanSemantics({operation: "requirements", obligation: plain}); + const off = projectReplanSemantics({operation: "requirements", obligation: { + ...plain, replan_context: {stride_strategy: "off"}}}); + assert.deepEqual(off, base); + const enabled = {...plain, replan_context: {stride_strategy: "evidence_conditioned_v1"}}; + const withStrategy = projectReplanSemantics({operation: "requirements", obligation: enabled}); + assert.deepEqual((withStrategy.planning_guidance as string[]).slice(0, 5), base.planning_guidance); + assert.equal((withStrategy.planning_guidance as string[]).length, 6); + assert.deepEqual(withStrategy.required_any_of, base.required_any_of); + assert.match((withStrategy.planning_guidance as string[])[5], /work-slice size, mechanism distance and validation breadth independently/); + assert.match((withStrategy.planning_guidance as string[])[5], /do not force a pivot from elapsed time or one noisy result/); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: enabled, + planning_guidance: withStrategy.planning_guidance}).accepted, false); + for (const version of ["future_v2", null, 1]) { + assert.throws(() => projectReplanSemantics({operation: "requirements", obligation: { + ...plain, replan_context: {stride_strategy: version}}}), /stride_strategy/); + } +}); + test("vision ACK freshness covers its durable run, never later gaps or unknown timestamps", () => { const at = "2026-08-13T09:00:00+08:00"; const ack = {recorded: true, generated_at: at, diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts new file mode 100644 index 0000000000..785c425249 --- /dev/null +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {normalizeStrideDecision, strideControlContract} from "../../loopx/control_plane/work_items/stride_policy.ts"; +import {buildVisionCheckpoint, VISION_REFRESH_REQUEST_SCHEMA} from "../../loopx/control_plane/goals/vision_checkpoint.ts"; +import {projectReplanSemantics} from "../../loopx/control_plane/work_items/replan_semantics.ts"; + +const contextId = "replan-context-" + "a".repeat(24); +const proposal = {schema_version: "stride_decision_v1", strategy_version: "evidence_conditioned_v1", + reference_context_id: contextId, reference_obligation_id: "replan-" + "a".repeat(16), basis: "unexplained_gap", next_probe: { + scope: "Compare a bounded alternative route.", prediction: "The alternative explains the remaining gap.", + adoption_criterion: "Retain an attributable validated gain.", cost_bound: "One small probe before expanding."}}; +const packet = {schema_version: "goal_vision_replan_contract_v0", agent_id: "agent-a", + vision_patch: {acceptance_summary: "Applicable evidence leaves a goal gap."}, path_delta: { + schema_version: "goal_path_delta_v0", outcome: "replan", prior_assumption: "Local tuning could close the gap.", + observed_reality: "Evidence leaves that explanation unresolved.", changed: ["Test an alternative mechanism."], + evidence_refs: ["evidence:review"]}, stride_decision: proposal}; +const request = {schema_version: VISION_REFRESH_REQUEST_SCHEMA, phase: "prepare", goal_id: "goal-a", + agent_id: "agent-a", merge_patch: false, existing_agent_vision: null, + require_path_delta_for_durable_change: false, agent_vision_packet: packet}; + +test("v1 separates route departure from work size and uncertainty from route novelty", () => { + assert.deepEqual(normalizeStrideDecision(proposal)[0]?.axes, + {work_slice: "shrink", route_distance: "grow", validation_scope: "shrink"}); + assert.deepEqual(normalizeStrideDecision({...proposal, basis: "uncertain_evidence"})[0]?.axes, + {work_slice: "shrink", route_distance: "hold", validation_scope: "grow"}); + assert.deepEqual(normalizeStrideDecision({...proposal, basis: "supported_progress"})[0]?.axes, + {work_slice: "grow", route_distance: "hold", validation_scope: "hold"}); + assert.equal(strideControlContract({stride_strategy: "off"}), null); + const prepared = buildVisionCheckpoint(request); + const decision = (prepared.agent_vision as Record).stride_decision as Record; + assert.deepEqual(decision.axes, {work_slice: "shrink", route_distance: "grow", validation_scope: "shrink"}); + const budget = (prepared.agent_vision as Record).vision_budget as Record; + assert.ok(Number(budget.total_usage) < 1800); +}); + +test("invalid, ambiguous, oversized and misplaced control proposals fail authoring", () => { + for (const patch of [{basis: "plateau_from_elapsed_time"}, {strategy_version: "future_v2"}, + {axes: {work_slice: "grow", route_distance: "grow", validation_scope: "grow"}}, + {next_probe: {...proposal.next_probe, scope: "x".repeat(121)}}, {reference_context_id: "missing"}, + {permission: "execute_anything"}, {next_probe: {...proposal.next_probe, cost_bound: ""}}]) { + assert.throws(() => buildVisionCheckpoint({...request, agent_vision_packet: { + ...packet, stride_decision: {...proposal, ...patch}}})); + } + assert.throws(() => buildVisionCheckpoint({...request, agent_vision_packet: { + ...packet, vision_patch: {...packet.vision_patch, stride_decision: proposal}, stride_decision: undefined}}), /agent_vision.stride_decision/); + assert.throws(() => buildVisionCheckpoint({...request, agent_vision_packet: { + ...packet, stride_decision: {...proposal, next_probe: {...proposal.next_probe, scope: "/Users/private-owner/file"}}}}), /private/); +}); + +test("an accepted schema and vector do not authorize work or certify gain; stale references cannot discharge a path review", () => { + const vision = buildVisionCheckpoint(request).agent_vision; + const obligation = {obligation_id: "replan-" + "a".repeat(16), triggers: [{kind: "vision_acceptance_gap"}], + replan_context: {stride_strategy: "evidence_conditioned_v1", context_id: contextId}}; + assert.equal(projectReplanSemantics({operation: "qualify", obligation, agent_vision: vision}).accepted, true); + const stale = projectReplanSemantics({operation: "qualify", obligation: {...obligation, obligation_id: "replan-" + "b".repeat(16)}, agent_vision: vision}); + // A changed live context from shared-pool churn cannot steal the admitted obligation. + assert.equal(projectReplanSemantics({operation: "qualify", obligation: {...obligation, + replan_context: {...obligation.replan_context, context_id: "replan-context-" + "b".repeat(24)}}, agent_vision: vision}).accepted, true); + const legacyVision = {...packet, stride_decision: undefined}; + const missing = projectReplanSemantics({operation: "qualify", obligation, agent_vision: legacyVision}); + assert.equal(missing.accepted, false); + assert.equal(missing.reason_code, "stride_decision_required"); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: {...obligation, + replan_context: {stride_strategy: "off"}}, agent_vision: legacyVision}).accepted, true); + assert.equal(stale.accepted, false); + assert.equal(stale.reason_code, "stride_reference_obligation_mismatch"); + assert.equal(projectReplanSemantics({operation: "qualify", obligation, stride_decision: proposal}).accepted, false); +}); diff --git a/tests/test_chat_machine_configuration_api.py b/tests/test_chat_machine_configuration_api.py index adf21d44f2..c4f9eb253b 100644 --- a/tests/test_chat_machine_configuration_api.py +++ b/tests/test_chat_machine_configuration_api.py @@ -350,9 +350,10 @@ def test_inspection_lists_registered_namespaces_without_local_refs( "timezone": "UTC", } assert namespace_catalog["todo_replan_cadence"]["configuration_template"] == { - "schema_version": "todo_replan_cadence_machine_defaults_v1", + "schema_version": "todo_replan_cadence_machine_defaults_v2", "count_unit": "effective_turns", "count": 6, + "stride_strategy": "off", } assert namespace_catalog["change_quality_qualification"][ "configuration_template"