diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index 790e1ea979..b21a280cae 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 <= 3600` 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 <= 4800` 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 <= 13000` | `nested_keys <= 330` | `top_level_keys <= 52` | | `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 18500` | `nested_keys <= 260` | `top_level_keys <= 25` | @@ -19,6 +19,20 @@ projections, repeated commands, and Markdown wrappers can make emitted output materially larger. The emitted-output qualification matrix below measures that separate boundary through the real CLI entry point. +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. +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. +Do not remove safety or settlement semantics to fit the envelope, and do not copy +dynamic quota decisions into the static prompt. No prompt text, saved automation, +scheduler cadence, or spending policy changes with this qualification adjustment. + The quota budget includes the typed action portfolio, one shared bound CLI route, pending-selection qualification, and hard-lane preemption evidence. The budget retains modest headroom for those enforceable semantics; repeated action diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 0151fa42b7..04fa2cd49f 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -45,7 +45,9 @@ "owner": "heartbeat automation", "consumer": "wake and route one bounded turn", "cold_path": "quota should-run, status, or review-packet --handoff-only", - "max_json_chars": 3_600, + # 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, "max_nested_keys": 40, "max_top_level_keys": 30, "budget_field": "interface_budget", @@ -393,6 +395,18 @@ def main() -> int: thin=True, runtime_profile="codex_app_heartbeat", ) + # Real automations normally bind an agent; the unbound fixture alone + # misses repeated identity/scope arguments in the generator envelope. + for binding in ( + {"agent_id": "worker-a"}, + {"agent_id": "worker-a", "agent_scopes": ["implementation", "review"]}, + ): + assert_surface("heartbeat_prompt_json", build_heartbeat_prompt( + goal_id=GOAL_ID, + thin=True, + runtime_profile="codex_app_heartbeat", + **binding, + )) assert quota_payload["should_run"] is True, quota_payload reset_policy = quota_payload["scheduler_hint"]["reset_policy"] diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index eab2e3cd62..97d3429be2 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -1,5 +1,8 @@ from __future__ import annotations +import runpy +from pathlib import Path + import pytest from loopx.control_plane.heartbeat.agent import ( @@ -59,6 +62,43 @@ def test_interface_budget_uses_visible_goal_mode() -> None: assert budget["within_budget"] is True +@pytest.mark.parametrize("mode,limit", [("thin", 2500), ("visible_goal", 4000)]) +def test_prompt_body_limit_remains_independent_of_json_envelope(mode: str, limit: int) -> None: + for size in (limit, limit + 1): + budget = build_interface_budget( + task_body="x" * size, + goal_id="fixture-goal", + active_state="fixture-state", + native_goal_host=mode == "visible_goal", + ) + assert budget["max_chars"] == limit + assert budget["within_budget"] is (size <= limit) + + +def test_heartbeat_envelope_and_body_overflow_are_both_rejected() -> None: + smoke = runpy.run_path(str( + Path(__file__).resolve().parents[2] + / "examples/control_plane/hot-path-interface-budget-smoke.py" + )) + check = smoke["assert_surface"] + payload = build_heartbeat_prompt( + goal_id="interface-budget-goal", thin=True, + runtime_profile="codex_app_heartbeat", agent_id="worker-a", + agent_scopes=["implementation", "review"], + ) + 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)) + check("heartbeat_prompt_json", envelope) + envelope["extra"] += "x" + with pytest.raises(AssertionError): + check("heartbeat_prompt_json", envelope) + # A small envelope cannot waive the independently evaluated body budget. + with pytest.raises(AssertionError): + check("heartbeat_prompt_json", {"interface_budget": {"within_budget": False}}) + + def test_agent_scope_normalization_dedupes_and_rejects_angle_brackets() -> None: assert normalize_agent_scopes(["a b", "a b", "c"]) == ["a b", "c"] with pytest.raises(ValueError, match="angle brackets"):