From 26a02676b01fdc8f9c34807f082acc7c0e0a7ff3 Mon Sep 17 00:00:00 2001 From: Huashuai Qu <256761+huashuai@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:06:40 -0700 Subject: [PATCH 1/8] fix(heartbeat): follow the user language Signed-off-by: Huashuai Qu <256761+huashuai@users.noreply.github.com> Committed-By-Agent: codex Co-authored-by: codex --- .../control_plane/heartbeat-prompt-smoke.py | 21 +++++--- loopx/control_plane/heartbeat/rules.py | 9 ++-- loopx/control_plane/heartbeat/task_body.py | 3 +- .../test_heartbeat_notification_rule.py | 51 +++++++++++++++++++ 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index dd260dda10..ca3b9dbad1 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -116,7 +116,9 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: return assert mode == "thin", mode - assert "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出。" in body + assert ( + "`user_channel.notify` OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output." + ) in body def assert_peer_scope_notification_authority(task_body: str) -> None: @@ -737,6 +739,10 @@ def main() -> int: 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id ', "project non-basic capabilities that are actually present", "without guessing capabilities the host does not have", + "All generic heartbeat and native Goal bodies match the user's current language", + "fall back to English when no user language is available", + "do not mix languages unless the user asks or a scoped capability requires bilingual", + "Capability-specific bilingual contracts remain authoritative", "If that preflight still fails", "should_run=false", "state=operator_gate", @@ -753,8 +759,8 @@ def main() -> int: "non-blocking means the agent may continue independent work", 'Never say only "owner gate"', "Only when `notify=DONT_NOTIFY`", - '"无用户待办/无需通知"', - "具体 user todo 未投影,需修复 LoopX 状态投影", + '"no user action required" in the user\'s language', + "specific user Todo is not projected; repair LoopX state projection", "NOTIFY", "notify_user_on_open_todo=true", "blocker-push opportunity", @@ -868,11 +874,12 @@ def main() -> int: "user_todo_summary", "user_todo_summary.open_count > 0", "never say \"no new user action\"", + "Language=user; fallback=English; mix only if asked/scoped-bilingual", "`interaction_contract.user_channel.notify` controls output", - "`should_run`/due monitor and other-agent scoped todos", - "are not user prompts", - "`action_required` without an action", - "具体 user todo 未投影,需修复 LoopX 状态投影", + "Due/peer work is not a prompt", + "Missing action: specific user Todo is not projected", + "repair LoopX state projection", + "repair the projection internally and stay quiet", "NOTIFY", "notify_user_on_open_todo=true", "blocker-push", diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index dcea72c7e0..5bf3daed6b 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -8,10 +8,11 @@ "操作/写回不自动结束;遵守停止/重规划。" ) USER_TODO_FINAL_MESSAGE_RULE = ( - "`interaction_contract.user_channel.notify` controls output: `NOTIFY` -> concrete " - "action; otherwise quiet. `should_run`/due monitor and other-agent scoped todos " - "are not user prompts. Only inside `NOTIFY`, `action_required` without an action -> " - '"具体 user todo 未投影,需修复 LoopX 状态投影"; with `DONT_NOTIFY`, repair ' + f"{OPERATOR_LANGUAGE_RULE} " + "`interaction_contract.user_channel.notify` controls output: " + "NOTIFY=action; DONT_NOTIFY=quiet. " + "Due/peer work is not a prompt. Missing action: specific user Todo " + "is not projected; repair LoopX state projection. With `DONT_NOTIFY`, repair " "the projection internally and stay quiet." ) HEARTBEAT_NOTIFICATION_RULE_SHORT = ( diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 1243c8834c..55517489a2 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -15,6 +15,7 @@ HOST_LOOP_SAFETY_RULE, HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE, HOST_LOOP_TODO_CLOSEOUT_RULE, + OPERATOR_LANGUAGE_RULE, RUNTIME_CAPABILITY_PROJECTION_THIN_RULE, RUNTIME_EXECUTION_ROUTING_RULE, RUNTIME_REPAIR_ROUTING_RULE, @@ -114,7 +115,7 @@ def render_heartbeat_task_body( - Only if `user_channel.notify=NOTIFY`, interpret `state=operator_gate` or `notify_user_on_open_todo=true` as a user prompt. Read `gate_prompt`, `operator_question`, `user_todo_summary`, and `open_todo_notify_reason`; - ask one concise Chinese action/question with reply format. If + ask one concise action/question with reply format. If `user_todo_summary.open_count > 0`, include up to three `first_open_items`; never say "no new user action". Honor `open_todo_notification_policy=repeat_until_resolved`; when diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index 63bda2a32b..d3743f4437 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -13,8 +13,13 @@ HEARTBEAT_NOTIFICATION_RULE_SHORT, ) from loopx.control_plane.heartbeat.task_body import ( + render_ark_managed_agent_goal_task_body, render_brief_heartbeat_task_body, + render_compact_heartbeat_task_body, + render_heartbeat_task_body, render_thin_heartbeat_task_body, + render_traex_visible_goal_task_body, + render_visible_goal_task_body, ) from loopx.control_plane.scheduler.execution_context import ( GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT, @@ -29,6 +34,10 @@ GOAL_ID = "heartbeat-notify-obligation-fixture" AGENT_ID = "codex-notify-agent" +LANGUAGE_POLICY = ( + "Language=user; fallback=English; mix only if asked/scoped-bilingual." +) +LANGUAGE_POLICY_THIN = "Lang=user; default=en; mix=asked/scoped." def test_short_rule_qualifies_dont_notify_as_output_only() -> None: @@ -78,6 +87,48 @@ def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: assert "DONT_NOTIFY=quiet." not in body +def test_generic_task_bodies_follow_user_language_without_forcing_chinese() -> None: + kwargs = dict( + goal_id="fixture-goal", + active_state="active", + cli_preflight="", + pr_review_pre_quota_command="", + quota_guard_command="loopx quota should-run", + quota_spend_command="loopx quota spend-slot", + refresh_state_command="loopx refresh-state", + progress_refresh_state_command="loopx refresh-state --classification delivery", + material_queue_rule="", + permission_rule="", + cli_bin="loopx", + agent_scope_instruction="", + expanded_prompt_command="loopx heartbeat-prompt", + compact_prompt_command="loopx heartbeat-prompt --compact", + brief_prompt_command="loopx heartbeat-prompt --brief", + thin_prompt_command="loopx heartbeat-prompt --thin", + ) + renderers = ( + render_heartbeat_task_body, + render_compact_heartbeat_task_body, + render_brief_heartbeat_task_body, + render_thin_heartbeat_task_body, + render_visible_goal_task_body, + render_traex_visible_goal_task_body, + render_ark_managed_agent_goal_task_body, + ) + + for renderer in renderers: + body = renderer(**kwargs) + expected_policy = ( + LANGUAGE_POLICY_THIN + if renderer is render_brief_heartbeat_task_body + else LANGUAGE_POLICY + ) + assert expected_policy in body, renderer.__name__ + assert "Chinese action" not in body, renderer.__name__ + assert "concrete Chinese" not in body, renderer.__name__ + assert re.search(r"[\u3400-\u9fff]", body) is None, renderer.__name__ + + def test_heartbeat_recommendation_mirrors_execution_obligation_in_replan() -> None: obligation = { "schema_version": "autonomous_replan_obligation_v0", From 1977fc39de31d799a6c029dd7219f0b1a66524e1 Mon Sep 17 00:00:00 2001 From: Huashuai Qu <256761+huashuai@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:06:40 -0700 Subject: [PATCH 2/8] docs(heartbeat): document language selection policy Signed-off-by: Huashuai Qu <256761+huashuai@users.noreply.github.com> Committed-By-Agent: codex Co-authored-by: codex --- docs/heartbeat-automation-prompt.md | 21 ++++++++++++------- .../references/repair-patterns.md | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index a55cfbfac5..25428f8d2f 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -35,6 +35,13 @@ differential also grants that allowance once during the none-to-v1 migration; unrelated later growth still uses the ordinary gate. Keep the explanation readable, while visible Goal prompts and other surfaces retain their own limits. +All generic heartbeat and native Goal bodies match the user's current language, +fall back to English when no user language is available, and do not mix +languages unless the user asks or a scoped capability requires bilingual +output. This policy applies to full, compact, brief, thin, visible Codex and +TraeX Goal, and Ark Managed Agent bodies. Capability-specific bilingual +contracts remain authoritative for their own output surfaces. + Do not paste the full lifecycle protocol into the visible goal text, and do not use a short goal text such as "advance TODO" as the recurring automation body. The short text names the goal; the generated task body enforces quota, gates, @@ -429,17 +436,17 @@ If the result says should_run=false: user_todo_summary, and agent_todo_summary from the payload. If the same unresolved gate has not already been asked in the recent visible thread, return heartbeat NOTIFY with - one concise Chinese question that lists the gate and the expected reply - format. Treat `interaction_contract.user_channel.notify` as the final + one concise question in the user's language that lists the gate and the + expected reply format. Treat `interaction_contract.user_channel.notify` as the final notification signal. When it is `NOTIFY`, name concrete projected `actions`, todos, or questions even when `action_required=false`, `user_todo_summary.open_count=0`, and `non_blocking=true`; non-blocking means the agent may continue independent work, not that the user action is silent. Never say only "owner gate". If required user-facing items are not projected, - say "具体 user todo 未投影,需修复 LoopX 状态投影"; never say "no new user - action" for this case. Only when `notify=DONT_NOTIFY`, + say "specific user Todo is not projected; repair LoopX state projection" in + the user's language; never say "no new user action" for this case. Only when `notify=DONT_NOTIFY`, `action_required=false`, and `open_count=0` may the heartbeat say - "无用户待办/无需通知" or stay quiet. Do not execute agent_command, adapter + "no user action required" in the user's language or stay quiet. Do not execute agent_command, adapter work, write-control, production actions, or the gated path while asking. - If the payload says notify_user_on_open_todo=true, treat the existing open user_todo_summary as a blocker-push opportunity, not as a silent skip. This @@ -451,8 +458,8 @@ If the result says should_run=false: user_gate_notification_cooldown.notification_suppressed=true, preserve the pending gate but return quiet DONT_NOTIFY until its bounded reminder window or a material gate/host change. Otherwise, if the same blocker ask has not already been surfaced in - the recent visible thread, return heartbeat NOTIFY with one concise Chinese - ask listing at most three first_open_items, the open_todo_notify_reason, and + the recent visible thread, return heartbeat NOTIFY with one concise ask in + the user's language listing at most three first_open_items, the open_todo_notify_reason, and the expected reply format: done, defer/not now, or a new evidence link/date/conclusion. Do not do implementation work, adapter work, file edits, research, project exploration, or quota spend for that blocker-push diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index d480a22027..537149f145 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -38,6 +38,7 @@ teaches a reusable control-plane lesson. | `turn_replay_recovery_semantic_gap` | A real Turn resumes from a safe Journal prefix, but `inspect-journal` presents `replay_legal=false` as if recovery were forbidden; scheduler-only or saved-Host-result recovery is especially misleading. | Journal integrity fields, replay decision, executor-adopted recovery decision, completed phase prefix, complete typed settlement identity, authoritative selected-Todo lineage, conditional Host Session Binding check, prepared-effect presence, and bounded recovery outcome. | Effect-free terminal replay and effectful executor recovery were collapsed into one user-facing decision even though the executor used separate status/phase rules; initial repairs also omitted either the settlement identity or its binding-to-Turn cross-check, allowing a drifted goal, agent, or canonical Todo to reach a later effect. | Keep replay legality and Journal consistency distinct. Make one typed recovery decision the source used by both executor and inspection; validate and cross-bind the canonical settlement goal, agent, Turn instance, binding, and effect id before authorization, including the selected Todo or adaptive primary Todo; project continue/resume phase/Host reinvocation/reason plus only participating checks, and persist a bounded planned-versus-actual audit. Cover identity and binding drift with zero-provider-call regressions. Preserve the existing prepared-effect readback owner and do not claim general exactly-once semantics. | | `managed_chat_resume_turn_identity_gap` | A managed Chat Session resumes after its adapter becomes unhealthy, returns to `ready`, and clears `active_turn_id`, but the interrupted Turn remains nonterminal and unowned. | Pre-resume Session snapshot, post-prepare persisted Session, adapter health, interrupted Turn status and error code, final Session state. | Resume preparation cleared the persisted active Turn reference before adapter recovery, then recovery re-read only the mutated Session and lost the pre-resume Turn identity. | Carry the pre-mutation Turn id through the fresh closed-state check, terminalize that Turn as `failed/server_restarted` before restoring the Session, and cover unhealthy-adapter resume with a focused regression while retaining the per-Session lifecycle lock. | | `dashboard_chat_stdio_encoding_gap` | Dashboard Chat completes ASCII turns but Codex app-server closes on non-ASCII input on Windows; direct stderr reports invalid UTF-8. | Failing multilingual request, app-server stderr, Python locale encoding, app-server `Popen` text-pipe options, and the same request under an explicit UTF-8 control. | The text-mode stdio pipe inherited the Windows system code page even though the app-server JSON-RPC transport requires UTF-8. | Set `encoding="utf-8"` explicitly on the app-server subprocess pipes and retain a focused launch-contract regression so host locale cannot change the protocol encoding. | +| `heartbeat_prompt_language_override_gap` | A generic LoopX heartbeat or native Goal starts answering in a mix of English and Chinese even though the user did not request bilingual output. | Generated full, compact, brief, thin, visible Codex and TraeX Goal, and Ark Managed Agent bodies; shared notification rules; user-language context; scoped capability output contract. | Shared heartbeat instructions embedded Chinese notification text, so prompt wording overrode the conversation language and leaked into otherwise English responses. | Centralize a generic language rule that follows the user, falls back to English when unknown, and permits mixed output only when requested or required by a scoped bilingual capability. Remove hard-coded language fragments from generic bodies and cover every renderer plus canonical docs with a no-Han regression. | | `provider_capacity_terminalization_gap` | A model provider reports temporary capacity exhaustion or overload, but the adapter stores only a generic nonzero exit and every failed Turn routes directly to manual repair; repeated restarts may also retry without a durable limit. | Provider error event and stderr adapters, public failure category, Turn Journal attempt count, recovery decision checks, controller disposition, quota/writeback receipts, and selected model before and after recovery. | Structured provider codes, model capacity, server overload, timed rate limits, and hard plan/billing quota exhaustion were collapsed into prose and then into one generic repair or retry result. | Prefer exact structured codes at the adapter boundary, fail closed on an unknown present code, and use bounded diagnostic text only when no code exists. Keep model capacity, provider overload, timed rate limit or code-less HTTP 429, and non-retryable hard quota distinct; a specific quota code must win over HTTP 429. Persist the attempt before Host invocation, attach same-configuration bounded retry only to transient classes, let the TypeScript Journal decision reject forged or exhausted budgets, and project an effect-free controller wait with explicit backoff. Preserve Session Binding when an opaque session was observed, spend no quota on failure, never switch models implicitly, and cover real protocol envelope shapes, precedence conflicts, replay, exhaustion, and private-text non-persistence. | | `host_output_budget_terminalization_gap` | A host reaches `max-tokens`, returns no trustworthy final response, yet the SDK call looks completed and the adapter degrades it to an ordinary wait or accepts a parseable fragment; a retry can repeat the whole expensive run. | Exact SDK/runtime version, configured max-token scope, terminal reason, output/reasoning counts, final-response length, retained local event/tool counts, Turn failure receipt, retry decision, and quota/writeback effects. | A per-model-request output cap was mistaken for a whole-run budget; reasoning consumed the cap, while no hard tool budget or final-response reserve existed. Terminal success of the transport was conflated with a complete typed result. | Resolve the effective limit once for both managed Turn and Chat execution, then project that same scope and value; a truthful binding alone does not prove its sibling consumer adopted the limit. Treat every `max-tokens` terminal as `output_budget_exhausted`; distinguish `no_final` from untrusted `partial`, admit neither as progress, spend no quota, and make same-configuration retry unavailable. Preserve raw evidence only in the local host session. Permit a fresh bounded recovery only with explicit remaining budget and proven reusable evidence; do not invent reserve/tool enforcement the SDK does not supply. Cover high-reasoning exhaustion, empty and partial finals, subprocess type preservation, Journal retry rejection, and public-text bounds. | | `host_plugin_async_bootstrap_readiness_gap` | A host plugin is installed and its row activates, but the first user-visible command or skill catalog arrives before an asynchronous CLI or skill bootstrap finishes; manual retry or restart appears necessary even though the eventual files are correct. | Packed plugin manifest and lifecycle patch, host loader settlement contract, startup service graph, bounded CLI invocation order, generated skill files, and the first host-native skill catalog readback. | Package installation, plugin-row activation, asynchronous bootstrap completion, and host-visible readiness were treated as one event; an awaited function in a unit harness was mistaken for a host startup barrier. | Publish a typed readiness service only after bootstrap success or safe failure, make the user-facing host rows depend on that service, and keep an explicit repair command registered synchronously. On success require install plus readback before the host advertises readiness; on failure release the host without raw diagnostics and retain repair. Cover a fresh packed-profile start, first catalog readback, no-restart hot load, failure isolation, and no extra idle calls. | From 3132eea02a884f14597472d840faf5715e326e82 Mon Sep 17 00:00:00 2001 From: Huashuai Qu <256761+huashuai@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:24:28 -0700 Subject: [PATCH 3/8] fix(heartbeat): keep goal prompts within budget Signed-off-by: Huashuai Qu <256761+huashuai@users.noreply.github.com> Committed-By-Agent: codex Co-authored-by: codex --- loopx/control_plane/heartbeat/task_body.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 55517489a2..a36a411994 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -479,8 +479,8 @@ def render_visible_goal_task_body( goal_id=goal_id, active_state=active_state, host_preamble=( - "in this visible Codex `/goal`. It is interactive, not a heartbeat " - "automation: no automation/RRULE/`LOOPX_TURN`." + "in visible Codex `/goal`; interactive, no heartbeat/RRULE/" + "`LOOPX_TURN`." ), completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, From fb201d0f6187bbf356ed0cceded582fb683f335f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:00:27 +0800 Subject: [PATCH 4/8] fix(heartbeat): preserve work semantics in user-language prompts Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/blocker-push-runtime-smoke.py | 8 +- .../control_plane/heartbeat-prompt-smoke.py | 92 ++++++++++--------- .../hot-path-interface-budget-smoke.py | 2 +- loopx/control_plane/heartbeat/budget.py | 2 +- loopx/control_plane/heartbeat/rules.py | 41 +++++---- loopx/control_plane/heartbeat/task_body.py | 38 +++++--- .../test_heartbeat_notification_rule.py | 57 ++++++++++-- .../test_heartbeat_prompt_support.py | 4 +- .../test_host_prompt_behavior.py | 2 +- 9 files changed, 154 insertions(+), 92 deletions(-) diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index ccf3689fd8..cfcf9c2a6d 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -211,9 +211,9 @@ def main() -> int: assert "state=operator_gate" not in compact_prompt, prompt assert "Normal turns use CLI `interaction_contract`" in compact_prompt, prompt assert "`user_channel.notify` controls OUTPUT only" in compact_prompt, prompt - assert "NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出" in compact_prompt, prompt - assert "Due/peer非用户动作" in compact_prompt, prompt - assert "NOTIFY缺动作→具体user todo未投影" in compact_prompt, prompt + assert "NOTIFY=show; DONT_NOTIFY=no output" in compact_prompt, prompt + assert "Due/peer work is not a user prompt" in compact_prompt, prompt + assert "Missing NOTIFY action: user Todo unprojected" in compact_prompt, prompt # The bootstrap rule is shared from heartbeat.rules after #4201; assert the # current compact sentence instead of the retired per-shell phrasing. assert "reuse the value on retries" in compact_prompt, prompt @@ -222,7 +222,7 @@ def main() -> int: # The shipped writeback sentence moved to the mixed-language form in the # same change that updated examples/control_plane/heartbeat-prompt-smoke.py; # this assertion keeps the blocker-runtime path pinned to the same text. - assert "unchanged→真实--vision-unchanged-reason" in compact_prompt, prompt + assert "unchanged->truthful --vision-unchanged-reason" in compact_prompt, prompt print("blocker-push-runtime-smoke ok") return 0 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index ca3b9dbad1..46660eac8b 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -66,14 +66,14 @@ def user_output_policy(task_body: str, *, mode: str) -> dict[str, str]: ) else: assert "`user_channel.notify` controls OUTPUT only" in body - assert "NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出" in body - assert "Due/peer非用户动作" in body - assert "NOTIFY缺动作→" in body - assert "具体user todo未投影" in body - assert "需修复LoopX状态投影" in body - assert "静默时内部修复" in body + assert "NOTIFY=show; DONT_NOTIFY=no output" in body + assert "Due/peer work is not a user prompt" in body + assert "Missing NOTIFY action:" in body + assert "user Todo unprojected" in body + assert "repair LoopX state projection" in body + assert "under DONT_NOTIFY repair internally" in body if mode == "brief": - assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body + assert "Output only under `user_channel.notify=NOTIFY`; otherwise stay quiet." in body return { "authority": "interaction_contract.user_channel.notify", "external": "NOTIFY", @@ -86,9 +86,9 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: body = normalized(task_body) assert "no-change=surface_only/no spend" in body, mode assert "material=outcome+vision" in body, mode - assert "缺则同轮checkpoint-context重判" in body, mode - assert "按凭据仅补vision;过期重读" in body, mode - assert "unchanged→真实--vision-unchanged-reason" in body, mode + assert "same-turn checkpoint-context recheck" in body, mode + assert "add only evidenced vision; stale->reread" in body, mode + assert "unchanged->truthful --vision-unchanged-reason" in body, mode if mode == "full": assert ( @@ -112,12 +112,13 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: return if mode == "brief": - assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body + assert "Output only under `user_channel.notify=NOTIFY`; otherwise stay quiet." in body return assert mode == "thin", mode assert ( - "`user_channel.notify` OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output." + "`user_channel.notify` controls OUTPUT only: NOTIFY=show; " + "DONT_NOTIFY=no output." ) in body @@ -588,20 +589,20 @@ def main() -> int: "no scope in todo metadata", "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", - "use selection_command when required", + "selection_command if needed", "heartbeat-prequota", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run ' "--goal-id loopx-meta --agent-id codex-product-capability --available-capability network " "--available-capability external_evidence_poll", - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", - "Due/peer非用户动作", - "NOTIFY缺动作→", - "具体user todo未投影", + "`user_channel.notify` controls OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output", + "Due/peer work is not a user prompt", + "Missing NOTIFY action:", + "user Todo unprojected", "Observed capabilities -> `--available-capability`; never user gates", "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "unchanged→真实--vision-unchanged-reason", + "unchanged->truthful --vision-unchanged-reason", "guard; 2 stalls->replan", "`agent_read_required`", "drain/read/triage before work; settle/ACK", @@ -645,33 +646,33 @@ def main() -> int: ) brief_task = normalized(str(brief_payload["task_body"])) for phrase in ( - "Brief 详情:", + "Brief detail:", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", "Run assignment and guard as separate statements in one shell", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", - "Due/peer非用户动作", - "Todo验收非结算", - "NOTIFY缺动作→", - "具体user todo未投影", - "按 user channel", + "`user_channel.notify` controls OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output", + "Due/peer work is not a user prompt", + "Todo acceptance is not settlement", + "Missing NOTIFY action:", + "user Todo unprojected", + "follow user channel", "monitor_quiet_skip", - "记 receipt/stall", - "同 id 重试", - "只读一次", - "outcome-floor recovery", - "推进 evidence", + "records receipt/stall", + "retry the same id", + "one read-only poll", + "Outcome-floor recovery", + "evidence or blocker", "status --limit 3", "review-packet --handoff-only", "heartbeat_recommendation.agent_must_attempt", - "遵守 quota 权限/结果/handoff", - "交付并验证", + "obey quota authority/outcome/handoff", + "After validated delivery", "execution_obligation.must_attempt_work", "interaction_contract.cli_channel.settlement_plan.ordered_steps", - "精确 identity/effect 顺序结算", - "不使用旧 refresh/spend 配方", - "仅 terminal no-follow-up 收尾", - "静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额", + "exact identity/effect order", + "never old refresh/spend", + "Finish only on terminal no-follow-up", + "Do not spend for quiet skips, preflight failures, blocker-push questions", "No learning queue unless asked.", "No permission asks in a trusted session.", ): @@ -694,16 +695,16 @@ def main() -> int: "Advance `public-heartbeat-goal` from /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", - "use selection_command when required", + "selection_command if needed", "quota should-run", - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", - "Due/peer非用户动作", - "NOTIFY缺动作→", - "具体user todo未投影", + "`user_channel.notify` controls OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output", + "Due/peer work is not a user prompt", + "Missing NOTIFY action:", + "user Todo unprojected", "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "unchanged→真实--vision-unchanged-reason", + "unchanged->truthful --vision-unchanged-reason", "guard; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", @@ -876,8 +877,9 @@ def main() -> int: "never say \"no new user action\"", "Language=user; fallback=English; mix only if asked/scoped-bilingual", "`interaction_contract.user_channel.notify` controls output", - "Due/peer work is not a prompt", - "Missing action: specific user Todo is not projected", + "`should_run`/due monitor/other-agent todos are not user prompts", + "Only under NOTIFY, `action_required` without an action", + "specific user Todo is not projected", "repair LoopX state projection", "repair the projection internally and stay quiet", "NOTIFY", @@ -936,7 +938,7 @@ def main() -> int: "2 consecutive eligible heartbeats are no-progress loops", "self-cancel turn", "repair path is", - "授权/预算内推进可验证结果", + "Within authority/budget, deliver verifiable results", "a focused correction may suffice", "Stay inside `goal_boundary` when present", "Follow user authority and repository rules", diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 1910a7183a..bb4b69953c 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -50,7 +50,7 @@ "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, + "max_json_chars": 5_400, "max_nested_keys": 40, "max_top_level_keys": 30, "budget_field": "interface_budget", diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 4bd5297daa..013216a651 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -8,7 +8,7 @@ INTERFACE_BUDGET_CHARS = { "full": 12_000, "compact": 6_500, - "brief": 3_500, + "brief": 3_700, "thin": 2_500, "visible_goal": 4_000, } diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 5bf3daed6b..22f21ddfe2 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -3,30 +3,37 @@ DEFAULT_MATERIAL_QUEUE_RULE = "Do not consume the learning material queue unless the user explicitly asks." DEFAULT_PERMISSION_RULE = "Do not ask for permissions when the current host session is already trusted." +OPERATOR_LANGUAGE_RULE = "Language=user; fallback=English; mix only if asked/scoped-bilingual." +OPERATOR_LANGUAGE_RULE_THIN = "Lang=user; default=en; mix=asked/scoped." SCOPE_BOUNDED_WORK_RULE = ( - "授权/预算内推进可验证结果;按任务/证据/风险定规模,不按操作/文件数/心跳间隔。" - "操作/写回不自动结束;遵守停止/重规划。" + "Within authority/budget, deliver verifiable results sized by " + "task/evidence/risk, not ops/files/wakes. Calls/writeback aren't " + "completion; obey stop/replan." ) USER_TODO_FINAL_MESSAGE_RULE = ( f"{OPERATOR_LANGUAGE_RULE} " "`interaction_contract.user_channel.notify` controls output: " - "NOTIFY=action; DONT_NOTIFY=quiet. " - "Due/peer work is not a prompt. Missing action: specific user Todo " - "is not projected; repair LoopX state projection. With `DONT_NOTIFY`, repair " - "the projection internally and stay quiet." + "NOTIFY=concrete action; DONT_NOTIFY=quiet. " + "`should_run`/due monitor/other-agent todos are not user prompts. " + "Only under NOTIFY, `action_required` without an action: say the specific " + "user Todo is not projected and repair LoopX state projection. Under " + "DONT_NOTIFY, repair the projection internally and stay quiet." ) HEARTBEAT_NOTIFICATION_RULE_SHORT = ( - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; " - "DONT_NOTIFY=安静输出。见 `heartbeat_recommendation.agent_must_attempt`/" - "`execution_obligation.must_attempt_work`:true须推进并写回,false才可no-op。" - "Due/peer非用户动作;NOTIFY缺动作→" - "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" + f"{OPERATOR_LANGUAGE_RULE_THIN} " + "`user_channel.notify` controls OUTPUT only: NOTIFY=show; " + "DONT_NOTIFY=no output. " + "`heartbeat_recommendation.agent_must_attempt`/" + "`execution_obligation.must_attempt_work`: true=work+writeback; only " + "false permits no-op. Due/peer work is not a user prompt. Missing NOTIFY " + "action: user Todo unprojected; repair LoopX state projection; under " + "DONT_NOTIFY repair internally." ) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( - "本轮精确monitor-poll提交→不refresh/spend;" - "其余no-change=surface_only/no spend;writeback material=outcome+vision;" - "缺则同轮checkpoint-context重判,按凭据仅补vision;" - "过期重读;unchanged→真实--vision-unchanged-reason。" + "Exact monitor-poll settlement->no refresh/spend; else " + "no-change=surface_only/no spend; writeback material=outcome+vision. " + "Missing vision: same-turn checkpoint-context recheck, add only evidenced " + "vision; stale->reread; unchanged->truthful --vision-unchanged-reason." ) REWARD_MEMORY_OUTCOME_RULE = ( "`reward_memory_recall.experiment.automatic_ingest=true`: reusable Todo outcomes " @@ -79,8 +86,8 @@ "statements in one shell, not a command-prefix assignment; reuse the value on retries." ) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "Quota: use selection_command when required; " - "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" + "Quota: selection_command if needed; re-enter as instructed, do/verify " + "authorized work, then follow next_cli_actions for writeback/spend." ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index a36a411994..39f4f35e7b 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -296,9 +296,9 @@ def render_brief_heartbeat_task_body( permission_rule=permission_rule, include_default_permission=True, ) - return f"""推进 `{goal_id}`;状态 `{active_state}`。 + return f"""Advance `{goal_id}` from `{active_state}`. -Brief 详情: +Brief detail: `{compact_prompt_command}`. {scope_block} @@ -316,22 +316,27 @@ def render_brief_heartbeat_task_body( {SCOPE_BOUNDED_WORK_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} -`should_run=false`:按 user channel;`monitor_quiet_skip` 记 receipt/stall; -wait 只读一次,新证据才 writeback/spend,同 id 重试。 +`should_run=false`: follow user channel; `monitor_quiet_skip` records +receipt/stall. Wait: one read-only poll; only new evidence permits +writeback/spend; retry the same id. {SCHEDULER_HINT_THIN_RULE} `agent_read_required`: drain/read/triage before work; settle/ACK. -`should_run=true`:读 compact、`status --limit 3`、`review-packet --handoff-only`; -遵守 quota 权限/结果/handoff;outcome-floor recovery 推进 evidence 或写 blocker。 +`should_run=true`: read compact, `status --limit 3`, and +`review-packet --handoff-only`; obey quota authority/outcome/handoff. +Outcome-floor recovery: evidence or blocker. {HOST_LOOP_QUOTA_DISPATCH_RULE} {reward_memory_rule} -交付并验证后,按当前 `interaction_contract.cli_channel.settlement_plan.ordered_steps` -的精确 identity/effect 顺序结算;无 plan 时按当前 `next_cli_actions`,不使用旧 refresh/spend 配方。 -Todo验收非结算;外部等待须 open+monitor_changed+successor→重跑/继续,且不扣额; -仅 terminal no-follow-up 收尾。 +After validated delivery, settle via current +`interaction_contract.cli_channel.settlement_plan.ordered_steps` in exact +identity/effect order; without a plan follow current `next_cli_actions`, never +old refresh/spend. Todo acceptance is not settlement. External wait: +open + monitor_changed + successor -> rerun quota/continue, no spend. +Finish only on terminal no-follow-up. -静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额。 -仅 `user_channel.notify=NOTIFY` 时输出,否则静默。 +Do not spend for quiet skips, preflight failures, blocker-push questions, +dry runs, or duplicate accounting. Output only under +`user_channel.notify=NOTIFY`; otherwise stay quiet. {HOST_LOOP_SAFETY_RULE} {RUNTIME_REPAIR_ROUTING_RULE} @@ -379,6 +384,7 @@ def render_compact_heartbeat_task_body( {SCHEDULER_HINT_COMPACT_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} +{OPERATOR_LANGUAGE_RULE} `agent_read_required`: drain/read/triage before work; settle/ACK. @@ -399,8 +405,9 @@ def render_compact_heartbeat_task_body( `run_history.latest_runs` as drill-down only. {reward_memory_rule} -2. Goal-owned blocker: stop its path. Under `NOTIFY`, send a concrete Chinese - blocker-push; under `DONT_NOTIFY`, repair internally and stay quiet. +2. Goal-owned blocker: stop its path. Under `NOTIFY`, send a concrete + blocker-push in the user's language; + under `DONT_NOTIFY`, repair internally and stay quiet. Dependency/sibling todos: record; continue audit. 3. If `effective_action=outcome_floor_recovery` or `recovery_delivery_allowed=true` or @@ -561,6 +568,7 @@ def _render_goal_task_body( {RUNTIME_EXECUTION_ROUTING_RULE} {HOST_LOOP_SAFETY_RULE} +{OPERATOR_LANGUAGE_RULE} {prequota_block}Each work iteration, read complete successful JSON from: `{quota_guard_command}` @@ -579,7 +587,7 @@ def _render_goal_task_body( continue. After settlement recheck quota; use current continuation/wait guidance, not repeated unchanged polling. Complete {completion_subject} only on `should_run=false` + terminal no-follow-up; -other no-work states mean wait, not completion.{host_wait_rule} +otherwise obey the next action or wait guidance, not completion.{host_wait_rule} {policy_tail}""" def render_ark_managed_agent_goal_task_body( diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index d3743f4437..1479a3ed7d 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -9,6 +9,8 @@ from __future__ import annotations +import re + from loopx.control_plane.heartbeat.rules import ( HEARTBEAT_NOTIFICATION_RULE_SHORT, ) @@ -52,9 +54,11 @@ def test_short_rule_qualifies_dont_notify_as_output_only() -> None: def test_shared_rule_keeps_projection_repair_and_quiet_boundary() -> None: rule = HEARTBEAT_NOTIFICATION_RULE_SHORT - assert "NOTIFY缺动作→具体user todo未投影" in rule - assert "需修复LoopX状态投影" in rule - assert "静默时内部修复" in rule + assert "Missing NOTIFY action: user Todo unprojected" in rule + assert "repair LoopX state projection" in rule + assert "under DONT_NOTIFY repair internally" in rule + assert "true=work+writeback" in rule + assert "only false permits no-op" in rule def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: @@ -81,8 +85,8 @@ def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: assert "heartbeat_recommendation.agent_must_attempt" in body assert "execution_obligation.must_attempt_work" in body assert "OUTPUT only" in body - assert "需修复LoopX状态投影" in body - assert "静默时内部修复" in body + assert "repair LoopX state projection" in body + assert "under DONT_NOTIFY repair internally" in body # A bare "DONT_NOTIFY=quiet" no-op mapping must never appear in the prompt. assert "DONT_NOTIFY=quiet." not in body @@ -120,7 +124,7 @@ def test_generic_task_bodies_follow_user_language_without_forcing_chinese() -> N body = renderer(**kwargs) expected_policy = ( LANGUAGE_POLICY_THIN - if renderer is render_brief_heartbeat_task_body + if renderer in (render_brief_heartbeat_task_body, render_thin_heartbeat_task_body) else LANGUAGE_POLICY ) assert expected_policy in body, renderer.__name__ @@ -129,6 +133,47 @@ def test_generic_task_bodies_follow_user_language_without_forcing_chinese() -> N assert re.search(r"[\u3400-\u9fff]", body) is None, renderer.__name__ +def test_language_policy_preserves_blocker_and_native_goal_continuation() -> None: + kwargs = dict( + goal_id="fixture-goal", + active_state="active", + cli_preflight="", + pr_review_pre_quota_command="", + quota_guard_command="loopx quota should-run", + quota_spend_command="loopx quota spend-slot", + refresh_state_command="loopx refresh-state", + progress_refresh_state_command="loopx refresh-state --classification delivery", + material_queue_rule="", + permission_rule="", + cli_bin="loopx", + agent_scope_instruction="", + expanded_prompt_command="loopx heartbeat-prompt", + compact_prompt_command="loopx heartbeat-prompt --compact", + brief_prompt_command="loopx heartbeat-prompt --brief", + thin_prompt_command="loopx heartbeat-prompt --thin", + ) + compact = render_compact_heartbeat_task_body(**kwargs) + assert "Goal-owned blocker: stop its path" in compact + assert "blocker-push in the user's language" in compact + assert "Dependency/sibling todos: record; continue audit" in compact + assert "must_attempt_work=true` requires work even with" in compact + + brief = " ".join(render_brief_heartbeat_task_body(**kwargs).split()) + assert "exact identity/effect order" in brief + assert "External wait: open + monitor_changed + successor" in brief + assert "Finish only on terminal no-follow-up" in brief + + for renderer in ( + render_visible_goal_task_body, + render_traex_visible_goal_task_body, + render_ark_managed_agent_goal_task_body, + ): + body = renderer(**kwargs) + assert "After settlement recheck quota" in body, renderer.__name__ + assert "should_run=false` + terminal no-follow-up" in body, renderer.__name__ + assert "otherwise obey the next action or wait guidance" in body, renderer.__name__ + + def test_heartbeat_recommendation_mirrors_execution_obligation_in_replan() -> None: obligation = { "schema_version": "autonomous_replan_obligation_v0", diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index 8367ee5318..87684b0681 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -91,7 +91,7 @@ def test_heartbeat_envelope_and_body_overflow_are_both_rejected() -> None: # Check the envelope boundary independently of the real prompt's remaining # headroom: adding a metadata key can already put a valid prompt over budget. envelope = {"interface_budget": payload["interface_budget"], "extra": ""} - envelope["extra"] = "x" * (4800 - smoke["json_size"](envelope)) + envelope["extra"] = "x" * (5400 - smoke["json_size"](envelope)) check("heartbeat_prompt_json", envelope) envelope["extra"] += "x" with pytest.raises(AssertionError): @@ -173,7 +173,7 @@ def test_public_facade_still_builds_and_renders_prompts() -> None: @pytest.mark.parametrize( ("mode", "base_budget"), - [("full", 12000), ("compact", 6500), ("brief", 3500), ("thin", 2500)], + [("full", 12000), ("compact", 6500), ("brief", 3700), ("thin", 2500)], ) def test_reward_memory_prompt_headroom_is_fixed_and_feature_scoped( mode: str, base_budget: int diff --git a/tests/control_plane/test_host_prompt_behavior.py b/tests/control_plane/test_host_prompt_behavior.py index fd29fc1e9e..f284aaa395 100644 --- a/tests/control_plane/test_host_prompt_behavior.py +++ b/tests/control_plane/test_host_prompt_behavior.py @@ -49,7 +49,7 @@ def test_probe_uses_current_production_prompts_and_hidden_independent_oracle(): assert ( "monitor_changed:" in body or "wait->monitor+successor/work" in body - or "外部等待须 open+monitor_changed+successor" in body + or "open + monitor_changed + successor" in body ) assert "--codex-app" in body assert "LOOPX_TURN=" in body From 05a94017d6bd3d1704846c9656e00bf9350937e6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:00:27 +0800 Subject: [PATCH 5/8] docs(heartbeat): record language-aware prompt budgets Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/heartbeat-automation-prompt.md | 2 +- .../contracts/interface-budget-contract.md | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 25428f8d2f..43396474a6 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -61,7 +61,7 @@ static refresh/spend sequence: after validated work it follows the current `interaction_contract.cli_channel.settlement_plan.ordered_steps`, or current `next_cli_actions` when there is no plan. Generator command fields remain for compatibility, not as a stale fallback. Todo acceptance alone is not Turn -settlement or terminal vision closure. The brief budget remains 3,500 characters. +settlement or terminal vision closure. The brief budget is 3,700 characters. Brief 与 thin 共用完整执行义务路径;这次有意移除 brief 固定结算配方,而不是 删除结算义务。真实 App preflight、registry scope、完整 guard 和静态安全规则 diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index efc5d1358c..a6094ea23a 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 <= 5400` 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 <= 14500` | `nested_keys <= 360` | `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` | @@ -35,17 +35,20 @@ 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**, +not only the execution prompt. On the same scoped fixture, current main uses +4,791 JSON characters and the language-aware body uses 5,167 while retaining +static safety, repair routing, work obligation, and settlement instructions. +The 5,400-character ceiling leaves 233 characters of fixture headroom, 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. +dynamic quota decisions into the static prompt. The brief body allowance rises +from 3,500 to 3,700 characters so its English wording can retain blocker, +continuation, and settlement rules. Saved automation, scheduler cadence, and +spending policy do not change with these budget adjustments. The quota budget includes the typed action portfolio, one shared bound CLI route, pending-selection qualification, and hard-lane preemption evidence. The From dcfaf2d5dd7ff6fbe91f5d21daf9dd177a161b33 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:22:44 +0800 Subject: [PATCH 6/8] test(heartbeat): qualify one-time language prompt growth Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/cli-output-probe-runner.py | 3 + .../testing/cli_output_differential.py | 41 +++++++++++++ .../testing/cli_output_semantics.py | 15 +++++ .../test_cli_output_differential.py | 58 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index c530dd49bd..e8b89b1159 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -105,6 +105,9 @@ def _receipt_row( semantics.runtime_root_command_route_count(text) ), "host_prompt_static_safety_revision": semantics.host_prompt_static_safety_revision(text), + "heartbeat_user_language_prompt_revision": ( + semantics.heartbeat_user_language_prompt_revision(text) + ), "reward_memory_outcome_prompt_revision": ( semantics.reward_memory_outcome_prompt_revision(text) ), diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 305f96409c..a8af349b04 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -186,6 +186,22 @@ class GrowthAllowance: "compact_payload_chars": 640, } +# Replacing fixed Chinese heartbeat instructions with user-language policy +# grows the installed prompt once. These per-mode limits cover the measured +# same-fixture base/head delta plus 24-28 characters of variation; the brief +# renderer also adds five lines. Absolute output ceilings still apply, and +# once v1 is in the baseline ordinary growth limits apply again. +_HEARTBEAT_USER_LANGUAGE_V1_MIGRATION_ALLOWANCE: dict[str, dict[Metric, int]] = { + "heartbeat_prompt_thin": {"chars": 400, "compact_payload_chars": 400}, + "heartbeat_prompt_brief": { + "chars": 720, + "lines": 6, + "compact_payload_chars": 720, + }, + "heartbeat_prompt_compact": {"chars": 288, "compact_payload_chars": 288}, + "heartbeat_prompt_full": {"chars": 288, "compact_payload_chars": 288}, +} + # Two reviewed causes grow the Turn plan readback once, and both are consequences # of the same declared behavior change: # @@ -260,6 +276,25 @@ def _reward_memory_outcome_prompt_allowance( return 0 +def _heartbeat_user_language_migration_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 base.get("heartbeat_user_language_prompt_revision") is None + and candidate.get("heartbeat_user_language_prompt_revision") + == "heartbeat_user_language_v1" + ): + return _HEARTBEAT_USER_LANGUAGE_V1_MIGRATION_ALLOWANCE.get(surface, {}).get( + metric, 0 + ) + 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 @@ -644,6 +679,12 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A candidate, metric, ), + _heartbeat_user_language_migration_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 603d0fc68f..542fcb62ba 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -5,6 +5,21 @@ import re from typing import Any + +def heartbeat_user_language_prompt_revision(text: str) -> str | None: + """Attribute the one-time user-language prompt transition in CLI probes. + + This recognizes the exact rendered policy, not runtime language or authority. + Full/compact/Goal and thin/brief prompts use different bounded wording. + """ + + rules = ( + "Language=user; fallback=English; mix only if asked/scoped-bilingual.", + "Lang=user; default=en; mix=asked/scoped.", + ) + return "heartbeat_user_language_v1" if any(rule in text for rule in rules) else None + + def host_prompt_static_safety_revision(text: str) -> str | None: """Exact renderer evidence for the one-time static-safety budget transition. diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 8d930201f2..ae8c50af43 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -174,6 +174,64 @@ 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( + ("mode", "row_kind", "limit"), + [ + ("thin", "surface", 400), + ("brief", "variant", 720), + ("compact", "variant", 288), + ("full", "variant", 288), + ], +) +def test_user_language_prompt_budget_is_one_time_and_mode_scoped( + mode: str, row_kind: str, limit: int +) -> None: + from loopx.control_plane.testing.cli_output_differential import _compare_row + from loopx.control_plane.testing.cli_output_semantics import ( + heartbeat_user_language_prompt_revision, + ) + + rendered_rule = ( + "Lang=user; default=en; mix=asked/scoped." + if mode in {"thin", "brief"} + else "Language=user; fallback=English; mix only if asked/scoped-bilingual." + ) + assert heartbeat_user_language_prompt_revision(rendered_rule) == ( + "heartbeat_user_language_v1" + ) + assert heartbeat_user_language_prompt_revision(rendered_rule.replace("mix", "omit")) is None + + base = _row( + row_id=f"{row_kind}/heartbeat_prompt_{mode}/small/json", + qualification_policy=( + "absolute_hot_path" if mode == "thin" else "explicit_opt_in_cold_path" + ), + chars=1_000, + utf8_bytes=1_000, + lines=20, + compact_payload_chars=1_000, + ) + candidate = { + **base, + "chars": 1_000 + limit, + "compact_payload_chars": 1_000 + limit, + "heartbeat_user_language_prompt_revision": "heartbeat_user_language_v1", + } + assert not _compare_row(base, candidate)["failures"] + assert _compare_row(base, {**candidate, "chars": 1_001 + limit})["failures"] + assert _compare_row(candidate, {**candidate, "chars": 1_000 + 2 * limit})[ + "failures" + ] + assert _compare_row(base, {**candidate, "heartbeat_user_language_prompt_revision": None})[ + "failures" + ] + other = {**base, "row_id": "surface/status/small/json"} + assert _compare_row(other, {**candidate, "row_id": other["row_id"]})["failures"] + if mode == "brief": + assert not _compare_row(base, {**candidate, "lines": 26})["failures"] + assert _compare_row(base, {**candidate, "lines": 27})["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, From 3ff691a77db3159bdc6f50124252a6dcaf21b1d9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:22:49 +0800 Subject: [PATCH 7/8] docs(heartbeat): explain CLI language migration budget Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../contracts/interface-budget-contract.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index a6094ea23a..259da6404a 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -105,6 +105,19 @@ packet, and the brief/compact/full heartbeat prompt modes. These remain opt-in cold paths, but their exact stdout size and semantic anchors are regression contracts too. +The user-language prompt transition is one measured exception to ordinary +base/head growth, scoped to heartbeat rows and only when the base lacks the +rendered language-policy revision. On the same small CLI fixture, `origin/main` +to this branch grew by 376 characters for thin, 695 JSON / 690 Markdown +characters and five Markdown lines for brief, 264 / 262 characters for +compact, and 258 / 260 for full. Replacing fixed Chinese instructions and +restoring blocker/next-action continuation gives the worker usable language +and work guidance; removing those clauses solely to fit the old delta would +lose that consumer value. The one-time per-mode allowances are 400, 720, 288, +and 288 characters respectively, plus six lines for brief. The absolute +surface ceilings, UTF-8 byte limits, quota/status budgets, and normal growth +limits after this revision becomes the baseline remain unchanged. + `todo list --thin` is an explicit bounded projection, not a new filtering or ordering mode. After the normal role, status, Todo-id, and agent filters run, it keeps at most two matched items per role in one top-level `todos` container. From 59e5711b3657335b5d442ec1df5e52425390a73a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:33:37 +0800 Subject: [PATCH 8/8] fix(heartbeat): keep the selection dispatch obligation and size the brief budget Restore 'use selection_command when required', which names the typed cli_channel.selection_required obligation, and translate only the Chinese clause after it. Update the goal-host assertion to the English wording. The English brief body is about 700 characters longer than the Chinese one but uses fewer o200k_base tokens (927 vs 962), so the 3,700-character ceiling left every brief body over budget. Raise it to 4,300 and record the measurement in the interface-budget contract. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/heartbeat-automation-prompt.md | 2 +- docs/reference/contracts/interface-budget-contract.md | 11 ++++++++--- examples/control_plane/heartbeat-prompt-smoke.py | 4 ++-- loopx/control_plane/heartbeat/budget.py | 2 +- loopx/control_plane/heartbeat/rules.py | 5 +++-- tests/control_plane/test_heartbeat_prompt_support.py | 2 +- tests/control_plane/test_host_bootstrap_lifecycle.py | 2 +- tests/test_host_loop_activation.py | 5 ++++- 8 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 43396474a6..a4df37c954 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -61,7 +61,7 @@ static refresh/spend sequence: after validated work it follows the current `interaction_contract.cli_channel.settlement_plan.ordered_steps`, or current `next_cli_actions` when there is no plan. Generator command fields remain for compatibility, not as a stale fallback. Todo acceptance alone is not Turn -settlement or terminal vision closure. The brief budget is 3,700 characters. +settlement or terminal vision closure. The brief budget is 4,300 characters. Brief 与 thin 共用完整执行义务路径;这次有意移除 brief 固定结算配方,而不是 删除结算义务。真实 App preflight、registry scope、完整 guard 和静态安全规则 diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index 259da6404a..ee53ca079b 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -46,9 +46,14 @@ 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. The brief body allowance rises -from 3,500 to 3,700 characters so its English wording can retain blocker, -continuation, and settlement rules. Saved automation, scheduler cadence, and -spending policy do not change with these budget adjustments. +from 3,500 to 4,300 characters. Translating its fixed Chinese instructions to +English grows the Codex App brief body from 3,282 to 3,980 characters (3,494 to +4,192 with two agent-profile scopes) while its `o200k_base` token count falls +from 962 to 927 (994 to 959) and UTF-8 bytes grow about 2%. The character +ceiling therefore moves with the script, not with prompt cost, and keeps about +8% headroom for the unscoped fixture, close to the previous 7%. Saved +automation, scheduler cadence, and spending policy do not change with these +budget adjustments. The quota budget includes the typed action portfolio, one shared bound CLI route, pending-selection qualification, and hard-lane preemption evidence. The diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 46660eac8b..46ee8c6d34 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -589,7 +589,7 @@ def main() -> int: "no scope in todo metadata", "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", - "selection_command if needed", + "use selection_command when required", "heartbeat-prequota", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run ' "--goal-id loopx-meta --agent-id codex-product-capability --available-capability network " @@ -695,7 +695,7 @@ def main() -> int: "Advance `public-heartbeat-goal` from /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", - "selection_command if needed", + "use selection_command when required", "quota should-run", "`user_channel.notify` controls OUTPUT only: NOTIFY=show; DONT_NOTIFY=no output", "Due/peer work is not a user prompt", diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 013216a651..e256b6e680 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -8,7 +8,7 @@ INTERFACE_BUDGET_CHARS = { "full": 12_000, "compact": 6_500, - "brief": 3_700, + "brief": 4_300, "thin": 2_500, "visible_goal": 4_000, } diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 22f21ddfe2..091dce8068 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -86,8 +86,9 @@ "statements in one shell, not a command-prefix assignment; reuse the value on retries." ) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "Quota: selection_command if needed; re-enter as instructed, do/verify " - "authorized work, then follow next_cli_actions for writeback/spend." + "Quota: use selection_command when required; " + "re-enter as instructed, do/verify authorized work, then follow " + "next_cli_actions for writeback/spend." ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index 87684b0681..764aa0d2c2 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -173,7 +173,7 @@ def test_public_facade_still_builds_and_renders_prompts() -> None: @pytest.mark.parametrize( ("mode", "base_budget"), - [("full", 12000), ("compact", 6500), ("brief", 3700), ("thin", 2500)], + [("full", 12000), ("compact", 6500), ("brief", 4300), ("thin", 2500)], ) def test_reward_memory_prompt_headroom_is_fixed_and_feature_scoped( mode: str, base_budget: int diff --git a/tests/control_plane/test_host_bootstrap_lifecycle.py b/tests/control_plane/test_host_bootstrap_lifecycle.py index ae6799f037..10fcfb5a08 100644 --- a/tests/control_plane/test_host_bootstrap_lifecycle.py +++ b/tests/control_plane/test_host_bootstrap_lifecycle.py @@ -188,7 +188,7 @@ def test_app_brief_with_registry_profile_keeps_budget_and_current_settlement(reg body = packet["task_body"] assert all(scope.rstrip(".!?") in body for scope in scopes) assert packet["agent_scope_source"] == "agent_profile_v1" - assert packet["interface_budget"]["max_chars"] == 3500 + assert packet["interface_budget"]["max_chars"] == 4300 assert packet["interface_budget"]["within_budget"], packet["interface_budget"] assert packet["cli_preflight"] in body assert "--codex-app" in body diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index 73d520fb25..f19913c61e 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -435,7 +435,10 @@ def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: for payload in goal_hosts: assert "selection_command" in payload["task_body"] assert "No learning queue unless asked." in payload["task_body"] - assert "完成获准工作并验证后,再按 next_cli_actions 写回和记账" in payload["task_body"] + assert ( + "do/verify authorized work, then follow next_cli_actions for writeback/spend" + in payload["task_body"] + ) assert payload["interface_budget"]["budget_char_count"] <= 2_800 assert payload["interface_budget"]["within_budget"] is True