From f23d379024f38eb595e5484525628681b8f32c6f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:39:30 +0800 Subject: [PATCH] fix(turn): preserve quota capability refusal facts Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 22 ++++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 18 +++++ docs/reference/protocols/turn-envelope-v0.md | 21 ++++++ loopx/control_plane/quota/turn_envelope.ts | 4 +- .../test_turn_envelope_capability_facts.py | 72 +++++++++++++++++++ tests/control_plane_ts/turn_envelope.test.ts | 23 ++++++ 6 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/control_plane/test_turn_envelope_capability_facts.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index d240053cf9..762490ef14 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -449,6 +449,28 @@ shape merely because of its size. Optional memory participation now has one compact, signed envelope projection; the Codex CLI adapter preserves default-off isolation. This does not qualify installed heartbeat/App adoption or model value. +Current source-path audit for the first convergence slice: + +| Execution fact | Existing owner / projection | Remaining boundary | +|---|---|---| +| Goal/Agent/Todo identity | Quota selection and receipt; envelope actor, selected Todo and signed settlement identity | Capture identity and mutation-time validation remain necessary; display identity is not an execution grant | +| Full requirements | Interaction required reads; compact commands retained verbatim | Shared full Goal/Todo reads are proposed in #5794; neither a summary nor a source hash proves that a host read them | +| Capability refusal | Existing quota `capability_gate_v0`; compact boundary now retains exact `required`/`missing` arrays and historical source names | This repairs omitted facts, not readiness policy or capability activation | +| Selection / claim / lease | Selected Todo, action portfolio and current owning transactions | Compact selected ownership is not a fresh lease; retain the captured source and revalidate at the owning mutation | +| Replan / Goal closure | Replan action packet, contract capsule and vision audit | Full evidence remains on authorized detail paths; Todo completion is not Goal completion | +| Settlement / scheduler | Intact typed settlement plan and explicit host-owned scheduler projection | Scheduler detail, actual host readback and exactly-once recovery still need host adoption qualification | +| Optional memory | Verified boundary participation plus fresh host binding | Off/recall/ingest/stale/provider-failure isolation remains mandatory; transport parity is not model value | + +The capability-fact correction is a read-model change in the established +TypeScript owner. Real captured File/SQLite decisions cover refusal and admission +with memory off, recall-only, ingest-only and invalidated configuration. New +projections sign those retained facts without rewriting saved signatures; no +new coverage version, admission rule or Python policy is introduced. Host +provider-failure and settlement cases remain covered separately, and do not +qualify installed App convergence or model costs. Keep all three Todos below +open until their own acceptance is met; this inventory is not blanket permission +to delete the remaining Python IO adapters. + Remaining implementation Todos, in dependency order: | Todo | Observable outcome and decisive acceptance | diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 6d5cc94436..bc32460a85 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -320,6 +320,24 @@ R5 短包投影也完整保留已有 CLI 结算计划,包括 effect identity envelope 投影,Codex CLI 保持关闭隔离;这不代表安装态 heartbeat/App 或模型收益 已验收。 +第一批收敛的当前源码路径核对: + +| 执行事实 | 既有 owner/投影 | 保留的边界 | +|---|---|---| +| Goal/Agent/Todo 身份 | quota 选择及回执;envelope actor、selected Todo 和签名结算身份 | 捕获身份与写入时校验仍必要;展示身份不授予执行权 | +| 完整要求 | interaction 必读及原样保留的短包命令 | 完整 Goal/Todo 共用读取在 #5794 提案中;摘要或源 hash 不证明宿主已读 | +| 能力拒绝 | 既有 capability_gate_v0;短包保留原样 required/missing 与源中历史字段 | 修复事实遗漏,不改变就绪策略或能力开启 | +| 选择/claim/lease | selected Todo、action portfolio 及当前所属事务 | 短包归属不是新鲜 lease;保留捕获源并在所属写入入口复核 | +| replan/Goal 收尾 | replan action packet、contract capsule 和 vision audit | 完整证据仍走有权限的详情;Todo 完成不证明 Goal 完成 | +| 结算/scheduler | 完整 typed 结算计划及显式宿主调度投影 | 调度详情、实际宿主读回与一次恢复仍待宿主采用验收 | +| 可选 memory | 核验后的 boundary 参与事实及新鲜宿主绑定 | 关闭/recall/ingest/失效/provider 失败隔离持续保留;传输等价不是模型收益 | + +能力事实修复归既有 TS read-model owner。真实捕获的 File/SQLite 决策覆盖拒绝及 +准入,包含关闭、仅 recall、仅 ingest 与配置失效。新投影签名覆盖这些保留事实, +不重写历史签名、不增覆盖版本、准入规则或 Python 策略。宿主 provider 失败和 +结算另有覆盖,仍不证明安装态 App 收敛或模型成本。以下三项继续按各自验收保持 +未完成;这份核对不授权批量删除剩余 Python IO adapter。 + 后续实施 Todo 按依赖顺序推进: | Todo | 可观察结果与决定性验收 | diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 42b78a5920..ff7f5e6486 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -332,6 +332,27 @@ final packet, including diagnostics. The historical `source_json_bytes` and `envelope_json_bytes` fields still count Unicode code points for v0 compatibility; do not use them as wire-byte measurements. +### Capability refusal facts + +`boundary.capability_gate` carries the existing quota gate's `required` and +`missing` arrays intact, including an empty `missing` array after admission. +Earlier compact projection used only `required_capabilities` and +`missing_capabilities`, which could report matching projection signatures while +omitting the current refusal facts. Historical names are still carried when +present in the supplied source; they are not synthesized or rewritten. + +These facts participate in the existing boundary signature. Newly built +projections over current sources therefore have different hashes; saved +signatures are not rewritten and this is not a cross-version hash-equivalence +promise. Full quota output, admission, optional capability activation, claim, +lease and mutation checks are unchanged. No capability is granted by reading +these arrays. The default full heartbeat packet remains the source decision; +compact/Turn hosts retain its reason for refusal without recomputing readiness. + +中文:短包原样保留现有能力门禁的 required/missing(包括合法空数组),历史字段 +只在源中存在时保留。新投影签名覆盖这些事实,不改写历史签名;完整包、准入、 +可选能力开关和 claim/lease 权限不变。读取缺失项不授予能力。 + ### Optional memory participation The compact boundary retains the verified Goal/Agent Reward Memory automation diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index cfdbc752fa..a2d9a8f2a6 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -375,7 +375,9 @@ function boundary(payload: JsonObject): JsonObject { const capabilityGate = object(payload.capability_gate); if (Object.keys(capabilityGate).length > 0) { result.capability_gate = Object.fromEntries( - ["action", "reason", "required_capabilities", "missing_capabilities", "owner_action"] + // Current gate facts use required/missing; keep historical field names + // only when supplied by a stored source. Never rebuild the gate here. + ["action", "reason", "required", "missing", "required_capabilities", "missing_capabilities", "owner_action"] .filter((field) => capabilityGate[field] !== null && capabilityGate[field] !== undefined) .map((field) => [field, capabilityGate[field]]), ); diff --git a/tests/control_plane/test_turn_envelope_capability_facts.py b/tests/control_plane/test_turn_envelope_capability_facts.py new file mode 100644 index 0000000000..943fdc4b57 --- /dev/null +++ b/tests/control_plane/test_turn_envelope_capability_facts.py @@ -0,0 +1,72 @@ +"""One captured native decision must preserve its capability refusal facts.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, isolate_sqlite_runtime, +) +from tests.control_plane.reward_memory_host_fixture import enable_live_memory +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _write_fixture, +) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("mode", ["off", "recall_only", "ingest_only", "stale"]) +@pytest.mark.parametrize("network_available", [False, True]) +def test_real_captured_guard_preserves_required_and_missing_capabilities( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, + mode: str, network_available: bool, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path, required_capability="network") + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("action_kind=validate ", + "action_kind=validate continuation_policy=same_agent_non_delivery ")) + code, listed = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert code == 0, listed + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode="soft_claim", todos=listed["todos"], + ) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + if mode != "off": + config = enable_live_memory(registry, recall=mode == "recall_only", ingest=mode == "ingest_only") + if mode == "stale": + config.write_text(config.read_text() + "\n") + capabilities = ["--available-capability", "filesystem_read"] + if network_available: + capabilities.extend(["--available-capability", "network"]) + capture = tmp_path / "capture" + code, envelope = _run_cli(registry, runtime, "quota", "should-run", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "turn-capability-facts", "--codex-app", *capabilities, + "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) + assert code == (0 if network_available else 1), envelope + full = json.loads((capture / "decision.json").read_text()) + gate = full["capability_gate"] + assert "network" in gate["required"] + assert ("network" in gate["missing"]) is (not network_available) + assert full["interaction_contract"]["agent_channel"]["delivery_allowed"] is network_available + compact = envelope["boundary"]["capability_gate"] + assert compact["required"] == gate["required"] + assert compact["missing"] == gate["missing"] + assert compact["action"] == gate["action"] + assert envelope["action"]["delivery_allowed"] is network_available + assert envelope["action_signature"]["matches"] is True + # The source remains one full observation, not another admission evaluation. + from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, turn_envelope_action_signature_document, + ) + before = {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} + assert build_turn_envelope(full)["boundary"]["capability_gate"] == compact + assert {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} == before + changed = json.loads(json.dumps(envelope)) + changed["boundary"]["capability_gate"]["missing"] = ["different-capability"] + assert turn_envelope_action_signature_document(changed) != turn_envelope_action_signature_document(envelope) + if mode in {"off", "stale"}: + assert "reward_memory" not in envelope["boundary"].get("capabilities", {}) diff --git a/tests/control_plane_ts/turn_envelope.test.ts b/tests/control_plane_ts/turn_envelope.test.ts index 31f7293131..5862294792 100644 --- a/tests/control_plane_ts/turn_envelope.test.ts +++ b/tests/control_plane_ts/turn_envelope.test.ts @@ -73,6 +73,29 @@ const protocolActionFields = { agent_action: "advance one bounded segment", }; +test("capability facts retain exact current and historical source fields", () => { + for (const fields of [ + {required: ["network", "filesystem_write"], missing: ["network"]}, + {required: ["network"], missing: []}, + {required_capabilities: ["network"], missing_capabilities: ["network"]}, + ]) { + const source = payload(); + source.capability_gate = {action: "repair_bridge", reason: "Unavailable capability", + ...fields, runnable_candidates: [{private_detail: "not in compact context"}], available: ["shell"]}; + const before = structuredClone(source); + const envelope = buildTurnEnvelope({payload: source, protocol_action_fields: protocolActionFields, + scheduler_execution_args: ""}); + assert.deepEqual((envelope.boundary as JsonObject).capability_gate, + {action: "repair_bridge", reason: "Unavailable capability", ...fields}); + assert.deepEqual(source, before); + assert.deepEqual(quotaActionSignatureDocument(source, protocolActionFields), + turnEnvelopeActionSignatureDocument(envelope)); + const changed = structuredClone(envelope); + ((changed.boundary as JsonObject).capability_gate as JsonObject)[Object.keys(fields)[1]] = ["different"]; + assert.notDeepEqual(turnEnvelopeActionSignatureDocument(changed), turnEnvelopeActionSignatureDocument(envelope)); + } +}); + test("optional memory participation is compact, verified and signed", () => { const plain = payload(); const build = (source: JsonObject) => buildTurnEnvelope({payload: source,