From f11d95005232864ee7ef05a343f7644d889790ee Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:16:19 +0800 Subject: [PATCH] fix(heartbeat): carry the closeout vision decision in the writeback guidance The vision checkpoint contract already requires a per-agent vision decision at a material closeout, but the guidance injected into every wake said only `material->actual outcome`. A lane that followed it recorded the segment with a `missing_required` checkpoint and paid for the decision on the next wake, which opened on a checkpoint-missing replan obligation and spent itself reconstructing facts that were in hand at closeout time. The guidance now states the contract the product actually implements: a material closeout - a material `delivery_outcome` on the agent lane, or a durable `## Next Action` update - carries its own vision decision in the same `refresh-state` call, an omitted decision is completed on the same turn through the repair command the CLI returns, the goal cannot reach its terminal no-follow-up state before the checkpoint is satisfied, and a segment does not record an `unchanged` reason it did not earn. The vision replan contract and course chapter 08 teach the same sequence. This declares the budget movement instead of dropping the semantics: - `vision_writeback_decision_prompt_v1` is a one-time prompt revision, detected from the exact rendered sentence, that unlocks a bounded allowance on the heartbeat prompt surfaces (128 characters) and nothing else. Row-level differential coverage proves the allowance is exact, prompt-only and gone once v1 is the baseline. - the hot-path `heartbeat_prompt_json` ceiling moves from 4,800 to 5,300. The representative scoped fixture measured 4,795, so the previous ceiling had 5 characters left; 5,300 restores the ~10% margin this contract asks for, and the contract document now states the measured fixture, the reason, and that the independent 2,500-character thin body cap, structural limits and emitted CLI ceilings are unchanged. The overflow test reads that ceiling from the budget table instead of a literal. The record-and-repair contract is deliberately untouched. An input-boundary refusal was proposed earlier in this PR and withdrawn: it removed the recorded-then-repaired path that the shipped host completion, same-turn supplement, idempotent replay and external-delivery resume confirmation all depend on, and 15 tests encode that path (they stay green). Verified: tests/test_host_vision_recovery.py, tests/control_plane/test_refresh_checkpoint_recovery.py, tests/control_plane/test_refresh_checkpoint_isolation.py and tests/test_state_refresh_agent_lane_action.py 64 passed; tests/control_plane/test_cli_output_budget.py, tests/control_plane/test_heartbeat_prompt_support.py and tests/control_plane/test_cli_output_differential.py 125 passed; cli-output-budget-regression (base/head differential), hot-path-interface-budget, heartbeat-prompt, goal-vision-refresh-state-budget, goal-vision-replan-contract, blocker-push-runtime and docs-governance smokes ok; loopx canary premerge --from-git-diff -> passed, manual_holds 0. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../08-evidence-refresh-and-self-repair.md | 9 +- .../contracts/interface-budget-contract.md | 13 ++- .../goal-vision-replan-contract-v0.md | 16 ++++ .../control_plane/cli-output-probe-runner.py | 3 + .../control_plane/heartbeat-prompt-smoke.py | 12 ++- .../hot-path-interface-budget-smoke.py | 5 +- loopx/control_plane/heartbeat/rules.py | 4 +- .../testing/cli_output_differential.py | 44 +++++++++ .../testing/cli_output_semantics.py | 24 +++++ .../test_cli_output_differential.py | 93 +++++++++++++++++++ .../test_heartbeat_prompt_support.py | 5 +- 11 files changed, 215 insertions(+), 13 deletions(-) diff --git a/docs/development/control-plane-course/08-evidence-refresh-and-self-repair.md b/docs/development/control-plane-course/08-evidence-refresh-and-self-repair.md index fba8bba9f2..e64ccca6fe 100644 --- a/docs/development/control-plane-course/08-evidence-refresh-and-self-repair.md +++ b/docs/development/control-plane-course/08-evidence-refresh-and-self-repair.md @@ -312,15 +312,18 @@ if total_usage > GOAL_VISION_TOTAL_LIMIT: 第二份 transcript。`examples/project/goal-vision-refresh-state-budget-smoke.py` 同时验证写入正确性、预算与 repair delta。 -当 material refresh 没有 vision patch 或 unchanged reason 时,CLI 会产生 `vision_checkpoint_missing` acceptance gap。Todo 即使完成,也不能直接 terminal。 +正常调用应当在同一次写回里带上这个决定。当 agent lane 的 material closeout 没有 vision +patch 或 unchanged reason 时,CLI 会记录 segment 并把 checkpoint 标成 +`vision_checkpoint_missing`,同时返回一条可直接执行的修复命令:在原 turn 用它补齐决定, +settlement identity 不变、不会再花一次 spend。补齐之前,Todo 即使完成也不能宣告 terminal。 -修复选择有三种: +要满足这个决定,路径仍是三种: 1. 写一个 bounded vision patch; 2. 明确记录 vision unchanged reason; 3. 用 evidence 关闭或 supersede 该 vision frontier。 -不要为了消除 warning 随便写“unchanged”。Reason 必须与本轮 acceptance 事实一致。 +不要为了消除这个提示自动填一个空泛的“unchanged”。Reason 必须与本轮 acceptance 事实一致。 ## Signal、Anchor 与 Feedback 不直接变成 Todo Truth diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index c4fb1c7729..334426267a 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -8,7 +8,7 @@ and size/count budgets. | Surface | Owner | Consumer Action | Cold Path | Size Budget | Nested Budget | Count Budget | | --- | --- | --- | --- | --- | --- | --- | -| `heartbeat_prompt_json` | heartbeat automation | wake and route one bounded turn | `quota should-run`, `status`, or `review-packet --handoff-only` | `json_chars <= 4800` plus `interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 30` | +| `heartbeat_prompt_json` | heartbeat automation | wake and route one bounded turn | `quota should-run`, `status`, or `review-packet --handoff-only` | `json_chars <= 5300` plus `interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 30` | | `review_packet_handoff_only_json` | project-agent handoff | forward the smallest sufficient task packet | full `review-packet` or run-history artifact | `json_chars <= 3000` plus `handoff_interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 18` | | `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 14000` | `nested_keys <= 350` | `top_level_keys <= 52` | | `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 19500` | `nested_keys <= 260` | `top_level_keys <= 25` | @@ -36,10 +36,13 @@ for the richer generator packet; neither is the recurring Agent hot path. The heartbeat envelope ceiling covers the unbound and representative agent/scope-bound Codex App thin fixtures. It includes generator metadata and repeated bound commands, not only the execution prompt. The shared host contract added static safety, repair -routing, and retry-stable Turn initialization; the scoped fixture now uses about -4,362 JSON characters. The 4,800-character ceiling leaves roughly 10% headroom for -that fixture, without relaxing the independent **2,500-character thin task body**, -4,000-character native Goal body, structural limits, or emitted CLI ceilings. +routing, and retry-stable Turn initialization, and the vision writeback guidance now +names the material-closeout vision decision and its same-turn repair. The scoped +fixture now uses about 4,795 JSON characters, so the ceiling moves to 5,300 to keep the +roughly 10% headroom this contract asks for, rather than squeezing writeback semantics +out of the envelope. Nothing else is relaxed: the independent **2,500-character thin +task body**, 4,000-character native Goal body, structural limits, and emitted CLI +ceilings are unchanged. It is not a token count, execution quota, or allowance to append more instructions. Arbitrary-length caller paths/scopes are not promised to fit this fixed fixture envelope; their emitted output is qualified separately by the CLI matrix. diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index d033131e0b..0977d2635b 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -232,6 +232,22 @@ Valid checkpoint decisions are: - `not_required`: no material closeout trigger was present, including a valid typed in-flight continuation. +The closeout is where this requirement is met, so the writeback guidance every +wake receives states it: a material closeout -- a material `delivery_outcome` on +the agent lane, or a durable `## Next Action` update -- carries its own vision +decision in the same `refresh-state` call, and does not record an `unchanged` +reason the segment did not earn. + +An omission is recorded, not discarded, and is repaired in the same turn: +`refresh-state` writes the segment with a `missing_required` checkpoint and +returns the repair command that repeats the original invocation with the missing +decision. The goal cannot reach its terminal no-follow-up state until that +decision satisfies the checkpoint, and the same-turn supplement applies it to the +original settlement identity rather than to a new one, so the repair neither +re-authors the segment nor spends a second time. An in-flight continuation owes +no decision, and neither does a closeout that carries no `agent_id`, because a +per-agent decision requires an agent to make it. + `missing_required` is not a chat reminder. Status keeps it in compact run history, quota filters it by current `agent_id`, and goal-frontier projection turns it into `acceptance_gaps[]`. If the current agent has no runnable diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 878187ccf1..fd4f32e7d6 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -108,6 +108,9 @@ def _receipt_row( "reward_memory_outcome_prompt_revision": ( semantics.reward_memory_outcome_prompt_revision(text) ), + "vision_writeback_decision_prompt_revision": ( + semantics.vision_writeback_decision_prompt_revision(text) + ), "managed_executor_binding_revision": ( semantics.managed_executor_binding_revision(text) ), diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 71167f43a2..708a70ca54 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -85,7 +85,11 @@ def user_output_policy(task_body: str, *, mode: str) -> dict[str, str]: def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: body = normalized(task_body) assert "no-change=`surface_only`/no spend; unchanged->" in body, mode - assert "`--vision-unchanged-reason`; material->actual outcome." in body, mode + assert ( + "`--vision-unchanged-reason`; material closeout->actual outcome" + "+自己的 vision 决定; 缺则按返回的修复命令同 turn 补齐, 补齐前不 terminal; " + "勿自动填 unchanged." + ) in body, mode if mode == "full": assert ( @@ -714,7 +718,11 @@ def main() -> int: ): assert "no-change=`surface_only`/no spend" in task, label assert "`--vision-unchanged-reason`" in task, label - assert "material->actual outcome" in task, label + assert "material closeout->actual outcome" in task, label + assert "自己的 vision 决定" in task, label + assert "缺则按返回的修复命令同 turn 补齐" in task, label + assert "补齐前不 terminal" in task, label + assert "勿自动填 unchanged" in task, label assert "if absent say" not in thin_task, thin_task assert "If false/0: quiet/no-user-todo" not in thin_task, thin_task diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index b06c1e22a3..22393f28d4 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -50,7 +50,10 @@ "cold_path": "quota should-run, status, or review-packet --handoff-only", # Includes generator metadata and scoped commands, not just task_body. # The independent 2,500-character thin body cap remains unchanged. - "max_json_chars": 4_800, + # The representative scoped fixture reached 4,795 characters, so this + # ceiling restores the contract's ~10% margin instead of squeezing the + # vision writeback guidance out of the envelope to fit 4,800. + "max_json_chars": 5_300, "max_nested_keys": 40, "max_top_level_keys": 30, "budget_field": "interface_budget", diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 264bd5f2b9..32b349b6de 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -23,7 +23,9 @@ ) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( "writeback: no-change=`surface_only`/no spend; " - "unchanged->`--vision-unchanged-reason`; material->actual outcome." + "unchanged->`--vision-unchanged-reason`; material closeout->actual outcome" + "+自己的 vision 决定; 缺则按返回的修复命令同 turn 补齐, 补齐前不 terminal; " + "勿自动填 unchanged." ) REWARD_MEMORY_OUTCOME_RULE = ( "`reward_memory_recall.experiment.automatic_ingest=true`: reusable Todo outcomes " diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 415cc2775e..e02acd4065 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -175,6 +175,20 @@ class GrowthAllowance: "compact_payload_chars": 640, } +# The vision writeback guidance now states the material-closeout decision: the +# closeout carries its own vision decision, an omission is repaired on the same +# turn through the command the CLI returns, the goal cannot reach its terminal +# state first, and a segment does not record an unchanged reason it did not +# earn. The allowance is bound to an exact none-to-v1 prompt revision, applies +# only to heartbeat rows, and keeps the absolute surface ceilings intact. Once +# v1 is the baseline, normal hot-path budgets apply again. +_VISION_WRITEBACK_DECISION_PROMPT_V1_MIGRATION_ALLOWANCE: dict[Metric, int] = { + "chars": 128, + "utf8_bytes": 384, + "lines": 2, + "compact_payload_chars": 128, +} + # Two reviewed causes grow the Turn plan readback once, and both are consequences # of the same declared behavior change: # @@ -249,6 +263,30 @@ def _reward_memory_outcome_prompt_allowance( return 0 +def _vision_writeback_decision_prompt_allowance( + row_id: str, + base: Mapping[str, Any], + candidate: Mapping[str, Any], + metric: Metric, +) -> int: + surface = row_id.partition("/")[2].partition("/")[0] + if ( + row_id.startswith(("surface/", "variant/")) + and surface + in { + "heartbeat_prompt_thin", + "heartbeat_prompt_brief", + "heartbeat_prompt_compact", + "heartbeat_prompt_full", + } + and base.get("vision_writeback_decision_prompt_revision") is None + and candidate.get("vision_writeback_decision_prompt_revision") + == "vision_writeback_decision_prompt_v1" + ): + return _VISION_WRITEBACK_DECISION_PROMPT_V1_MIGRATION_ALLOWANCE[metric] + return 0 + + # loopx_guided_todo_delta_v0 adds the continuation-aware Todo authoring # decision contract (reuse/update/link_successor/add_new plus a bounded # runnable-frontier summary) to the guided start-goal packet when an @@ -585,6 +623,12 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A candidate, metric, ), + _vision_writeback_decision_prompt_allowance( + row_id, + base, + candidate, + metric, + ), _turn_host_and_managed_executor_binding_allowance( row_id, base, diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index fa32f1f19f..ed25e96525 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -63,6 +63,30 @@ def managed_executor_binding_revision(text: str) -> str | None: else None ) + +def vision_writeback_decision_prompt_revision(text: str) -> str | None: + """Attribute the one-time material-closeout vision writeback revision. + + This is qualification evidence for the exact guidance sentence, never a + runtime classifier: it requires the decision, the original-turn repair + through the command the CLI returns, the terminal gate, and the ban on + fabricating an unchanged reason, so a partial or paraphrased prompt cannot + claim the one-time allowance. + """ + + required = ( + "material closeout->actual outcome", + "自己的 vision 决定", + "按返回的修复命令同 turn 补齐", + "补齐前不 terminal", + "勿自动填 unchanged", + ) + return ( + "vision_writeback_decision_prompt_v1" + if all(fragment in text for fragment in required) + else None + ) + _MARKDOWN_HEADING = re.compile(r"^#{1,6}\s+.+$") _RUNTIME_ROOT_COMMAND_ROUTE = re.compile( r"(?m)(?:^|[\"'`])[^\r\n\S]*loopx\s+--runtime-root\s+" diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 2a3a72b372..d8dcf84d3c 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -172,6 +172,99 @@ def test_reward_memory_outcome_prompt_budget_is_one_time_bounded_and_prompt_only assert _compare_row(other, {**current, "row_id": other["row_id"]})["failures"] +@pytest.mark.parametrize("row_kind", ["surface", "variant"]) +@pytest.mark.parametrize("mode", ["thin", "brief", "compact", "full"]) +def test_vision_writeback_decision_prompt_allowance_is_exact_and_prompt_only( + row_kind, mode +): + from loopx.control_plane.heartbeat.rules import ( + HEARTBEAT_VISION_WRITEBACK_RULE_SHORT, + ) + from loopx.control_plane.testing.cli_output_differential import ( + _VISION_WRITEBACK_DECISION_PROMPT_V1_MIGRATION_ALLOWANCE as ALLOWANCE, + _compare_row, + _vision_writeback_decision_prompt_allowance, + ) + from loopx.control_plane.testing.cli_output_semantics import ( + vision_writeback_decision_prompt_revision, + ) + + # The revision claims the one-time allowance only for the whole sentence. + assert ( + vision_writeback_decision_prompt_revision(HEARTBEAT_VISION_WRITEBACK_RULE_SHORT) + == "vision_writeback_decision_prompt_v1" + ) + assert ( + vision_writeback_decision_prompt_revision( + HEARTBEAT_VISION_WRITEBACK_RULE_SHORT.replace( + "勿自动填 unchanged", "best effort" + ) + ) + is None + ) + + row_id = f"{row_kind}/heartbeat_prompt_{mode}/small/json" + unbound = _row(row_id=row_id) + migrated = { + **unbound, + "vision_writeback_decision_prompt_revision": ( + "vision_writeback_decision_prompt_v1" + ), + } + for metric in ("chars", "utf8_bytes", "compact_payload_chars", "lines"): + assert ( + _vision_writeback_decision_prompt_allowance(row_id, unbound, migrated, metric) + == ALLOWANCE[metric] + ) + # An already-migrated baseline receives nothing. + assert ( + _vision_writeback_decision_prompt_allowance(row_id, migrated, migrated, "chars") + == 0 + ) + # No other surface qualifies, even with the revision present. + other_id = f"{row_kind}/status/small/json" + assert ( + _vision_writeback_decision_prompt_allowance( + other_id, + _row(row_id=other_id), + { + **_row(row_id=other_id), + "vision_writeback_decision_prompt_revision": ( + "vision_writeback_decision_prompt_v1" + ), + }, + "chars", + ) + == 0 + ) + # On the tight hot-path surface the migration allowance is the whole budget. + thin_id = f"{row_kind}/heartbeat_prompt_thin/small/markdown" + # The real thin Markdown row is a few thousand characters, which is why its + # ratio allowance stays at the 32-character floor. + base = _row( + row_id=thin_id, + format="markdown", + chars=6_400, + utf8_bytes=6_400, + lines=200, + ) + already_migrated = { + **base, + "vision_writeback_decision_prompt_revision": ( + "vision_writeback_decision_prompt_v1" + ), + } + current = { + **already_migrated, + "chars": base["chars"] + ALLOWANCE["chars"], + } + assert not _compare_row(base, current)["failures"] + assert _compare_row(base, {**current, "chars": current["chars"] + 1})["failures"] + assert _compare_row( + already_migrated, {**current, "chars": current["chars"] + 129} + )["failures"] + + def test_managed_executor_binding_budget_is_one_time_bounded_and_turn_only() -> None: from loopx.control_plane.testing.cli_output_differential import ( _TURN_HOST_AND_MANAGED_EXECUTOR_BINDING_V0_GROWTH_ALLOWANCE as ALLOWANCE, diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index 22438afc6b..644bd234a9 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -90,7 +90,10 @@ def test_heartbeat_envelope_and_body_overflow_are_both_rejected() -> None: check("heartbeat_prompt_json", payload) # Passing the inner body check cannot hide extra envelope metadata. envelope = {**payload, "extra": ""} - envelope["extra"] = "x" * (4800 - smoke["json_size"](envelope)) + ceiling = int( + smoke["SURFACE_BUDGETS"]["heartbeat_prompt_json"]["max_json_chars"] + ) + envelope["extra"] = "x" * (ceiling - smoke["json_size"](envelope)) check("heartbeat_prompt_json", envelope) envelope["extra"] += "x" with pytest.raises(AssertionError):