diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md index 101bccb762..2bb8eab64b 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md @@ -449,6 +449,28 @@ counter folding in [#7587](https://github.com/loopx-project/loopx/pull/7587) is separate compatible slice, not this replacement. Each slice can have its own PR and rollback; no generic retrieval framework or two-session rollout is required. +The native quota caller now implements a bounded acceptance-material slice: +known cumulative objectives, complete requirement descriptions and current review +states remain in its required view; source history and review notes/evidence are +addressable in the original immutable capture. Its read obligation, full/delta +baseline and recovery move together. Selected detail reads never fulfill hidden +required content or establish a full baseline. Unknown semantics and signed +envelopes remain whole; the disabled path is unchanged. The original capture's +values survive the typed path plan without a JavaScript numeric round trip. +If the required view plus its detail index is not smaller in serialized UTF-8 +bytes, retain full delivery and its original obligations. Default detail examples +select source, note or evidence fields rather than replaying the whole outcome. + +Native exec and code-mode fixtures also qualify two compactions within one work +Turn: a sufficient working brief continues with the original work/settlement +identity without replaying its packet or re-entering admission. The baseline is +still invalidated; unfinished initial reads and missing necessary facts require +same-capture recovery, while a new work Turn obtains its fresh guard. This is +native protocol qualification with deterministic inference, not evidence of +model compliance or live outcome/cost benefit. C1/C2 remains incomplete: +candidate, preference and Explore staging, signed-envelope migration and matched +accepted outcomes with complete costs still need their own qualification. + ## 6. Alternatives and design choices | Alternative | Disposition | diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md index 7d5d5e4725..21435b9949 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md @@ -343,6 +343,21 @@ work-context/读取履行、capture/detail 与 delta owner,不新增候选存 [#7587](https://github.com/loopx-project/loopx/pull/7587) 的诊断计数折叠是独立兼容 切片,不代表本次替换。每个切片可有独立 PR 与回退,不要求通用检索框架或启用双会话。 +原生 quota caller 现已实现有界的验收材料切片:已知的累计目标、完整验收要求描述和 +当前评审状态保留在必读视图;来源历史与评审说明/证据可以从原始不可变 capture 按需 +读取。读取义务、全量/差分基线与恢复共同切换。选读详情不能履行隐藏的必读内容, +也不能建立完整基线。未知语义和签名 envelope 保持完整,关闭路径不变。Typed 路径 +计划不让原始 capture 数值经过 JavaScript 往返转换。 +若必读视图加详情索引的 UTF-8 序列化字节数并未减少,则保留全量交付和原始义务。 +默认详情示例选择来源、说明或证据字段,避免重放整个 outcome。 + +原生 exec 与 code-mode fixture 还验证了同一工作 Turn 内连续两次压缩:充分的工作 +摘要可以保留原始工作/结算身份继续推进,无须重放 packet 或重新准入。基线仍失效; +尚未完成的初次读取和缺失的必要事实仍需从同一 capture 恢复,新工作 Turn 则取得 +新的 guard。这是确定性 inference 下的原生协议资格,不是模型遵循性或真实成果/ +成本收益的证据。C1/C2 仍未完成:候选、偏好、Explore 分阶段交付、签名 envelope +迁移,以及可比验收成果和完整成本仍须分别验证。 + ## 6. 备选方案与取舍 | 备选方案 | 结论 | diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 70dc5954c4..209518c896 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1535,6 +1535,26 @@ failure; the installed-profile smoke and semantic assertions qualify the repair. or promotion thresholds stay fixed for that result; revised thresholds belong to a new qualification, never a relabeled historical pass. +Native progressive-context qualification found an existing default-output +budget failure: immutable base `fb331f45c` and candidate `edbd56b14` both emit +25,836 JSON characters for `quota_should_run/multi_agent` with the unchanged +18-Todo, 3-Agent, 12-run fixture. Recursive comparison differs only in observation +timestamps and the rollout event id. This is neither an incremental native-off +regression nor evidence of a saving. Preserve the previous 25,500-ceiling failure; +calibrate this one regression allowance to 26,500 (664 characters of headroom) +without dropping fields. Markdown, line, semantic, bootstrap duplication, +per-Todo/fixed growth and all other surface ceilings remain unchanged. This +changes no host hard limit, authorized execution quota or frozen experiment +criterion. Native-on material and recovery savings need separate qualification. + +原生渐进上下文资格检查发现已有默认输出超限:不可变 base `fb331f45c` 与 candidate +`edbd56b14` 在相同 18 个 Todo、3 个 Agent、12 条运行记录夹具上, +`quota_should_run/multi_agent` 都为 25,836 JSON 字符;递归对比只有观察时间和 +rollout event id 不同。这不是 native-off 新增回归,也不是节省证据。保留旧 25,500 +阈值失败,只将这一回归预算校准为 26,500,留 664 字符余量,不删除字段。Markdown、 +行数、语义、bootstrap 重复、每 Todo/固定增长及其他 surface 阈值不变;宿主硬限制、 +授权执行配额和冻结实验标准均不变。Native-on 材料和恢复收益须单独验证。 + The initial-delivery guidance change measured the same 96-row fixture at `f6cf9583794eb8c4cca41fa832a1c2f150059583` and `da01f996c820aad3bae85268963e64c6bbb19d7c`. The complete instruction adds diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index a69071845d..952d119c93 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -77,9 +77,56 @@ Marked sources retain their generated-region lineage and canonical Todo authorit Native delta delivery and source fulfillment share the same integrity predicate. Only exact verified Goal text can be inherited from the retained baseline; current source witnesses and unknown fields remain literal. Cold delivery and -context-loss recovery retain the complete original text, including arbitrary -sections, comments and final stops. This does not establish model retention, -execution authority, token savings or accepted outcome gains. +full-capture recovery retain the complete original text, including arbitrary +sections, comments and final stops. + +Enabled native quota delivery separates known acceptance requirements from their +supporting material before full/delta presentation. The TypeScript acceptance +projection keeps every scoped outcome objective, requirement description, +review conclusion, current-basis flag, independent-verification flag, work link +and source revision inline. Outcome source history and Agent review notes/evidence +references remain in the immutable original capture. Each projected outcome has +a `supporting_material_pointer`; `context_details` supplies the original path, +SHA-256 and `quota context-read --select` command. Read needed evidence before a +decision, verification or review that depends on it; repeated selectors batch +related source, note and evidence fields without replaying the outcome's inline +requirements. An unmet or stale review stays visible before its explanation +is opened. Agent review still does not establish independent acceptance. + +The archive remains `projection.json`; the required model view is +`required-context.json`. Required reads, full/delta preparation and recovery use +that same model view. A selected detail page cannot acknowledge the required +view, establish a full baseline or prove that other hidden details were read. +The adapter edits paths selected by the typed owner without round-tripping the +original JSON values through JavaScript. Unknown outcome fields, unknown source +read obligations, unsupported schemas, signed envelopes and unavailable planning +retain their original full content. Context delivery off remains unchanged. +If the required view and its index are not smaller in serialized UTF-8 bytes, +delivery retains the original full view and read obligations; small material +does not need a more expensive detail round trip. This is a transport-size +comparison, not a relevance heuristic or a model-token estimate. +Historical captures retain their original obligations. This acceptance-material +slice does not yet stage candidate selection, preferences or Explore history, +and a smaller body alone does not establish lower total model cost. + +A context epoch is not a work Turn. Compaction or a failed native hook invalidates +delta reuse; it does not require another admission or unconditional replay of the +old quota packet. Within the existing Turn, continue from the working brief with +the Goal, applicable requirements/stops, task and original capture/settlement +identity. Before an action that depends on missing or uncertain facts, recover +those facts from the same immutable capture. An unfinished initial required read +still requires its missing content; summarizing it does not fulfill that read. +Do not repeat completed work or settle the original Turn twice. If the original +reference is lost, repair it before dependent work; independently authorized work +can continue. A later work Turn obtains its own current guard and required +context, without prefetching an older packet to manufacture a delta baseline. + +The native lifecycle still invalidates the previous baseline. A working brief +is sufficient only for the work whose necessary facts it preserves; it is not +input-byte coverage, a new baseline, renewed authority or proof of freshness or +model retention. These separate boundaries prevent transport loss from becoming +either a repeated full-input loop or permission to act on missing requirements. +No token savings or accepted outcome gains follow from instruction delivery alone. Checkpoint Host preflight uses the hidden `--defer-context-presentation` option with an explicitly enabled native JSON `should-run` capture. It collects the diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index d2d9c74bc4..5f836e5ff5 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -244,7 +244,8 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: Publication is delayed until this native tool runs, so the lifecycle owner can select its current continuity basis. Optional codec failure returns the - original full read reference. An unsafe recovery selector fails explicitly + committed required-view reference when available, otherwise the original + full read reference. An unsafe recovery selector fails explicitly with that same reference, never a replacement guard. Invalid run-mode opt-ins fail before that optional transport path. """ @@ -278,6 +279,23 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: if not activation["enabled"]: raise ValueError("context delivery is off") + page_bytes, output_bytes = native_context_page_budget() + output_limit = getattr(args, "max_page_output_bytes", None) + if output_limit is not None: + if output_limit <= 0: + raise QuotaDecisionTransportError("Native context output limit must be positive.") + output_bytes = min(output_bytes, output_limit) + page_bytes = min(page_bytes, output_bytes) + + def recovery_reference(value: dict[str, Any]) -> dict[str, str]: + # Preserve the caller's read budget on success and failure without + # duplicating instructions already carried by the outer view. + return {"path": value["path"], "sha256": value["sha256"], + "command": context_capture_read_command(Path(value["path"]), value["sha256"], + page_bytes=page_bytes, max_page_output_bytes=output_bytes)} + + full_reference = recovery_reference(full_reference) + reference = dict(full_reference) directory = codex_context_directory(directory=args.codex_context_dir, root=args.codex_context_root, create=True) if not directory: @@ -303,6 +321,12 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: capture_id=hashlib.sha256(raw).hexdigest(), continuity_generation=getattr(args, "_context_delivery_generation", None)) native_prepared = True + if projected.get("schema_version") == "context_delivery_v1": + # Native publication owns the complete required view. Keep its + # committed recovery even if optional wire/inline delivery fails; + # the input capture digest below remains the original identity. + full_reference = recovery_reference(projected["recovery"]) + reference = dict(full_reference) native_view = native_context_capture_view(projected, cache, context_directory=directory, metadata={"recovery": full_reference, "capture_sha256": args.capture_sha256, "context_delivery": activation}, @@ -324,7 +348,7 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: "original captured decision and preserve its Turn receipt; do not rerun admission. " "Authority and freshness are unchanged. " + CONTEXT_CAPTURE_READ_GUIDANCE} # Off is inert; after native preparation an optional wire write can fall - # back to the original reference without leaving an older selector live. + # back to the committed required view without leaving an older selector live. result: dict[str, Any] = {"ok": True, "mode": mode, **reference, **({"target": target} if target else {}), "recovery": full_reference, "capture_sha256": args.capture_sha256, diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index c3a7c71ff7..47c092ce45 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -520,10 +520,13 @@ export function prepareContextDelivery(request: JsonObject, legacyQuotaFields: r + (assumed ? "After reading this delta completely, use it with the retained model context. " + "Do not also fetch the full projection just to materialize JSON or replace missing Code Mode variables; " + "tool-process memory is separate from retained model context. If required baseline content is unavailable " - + "or continuity is uncertain, recover this capture in full before dependent work. " : "") + + "or continuity is uncertain, do not reconstruct from that baseline. Recover the necessary current facts " + + "from this capture before dependent work; completing an unfinished required read still needs its missing content. " : "") + "A value replaces its subtree; from copies the exact string, array or object at that path in the base capture, never in current changes. " + (hasOverlay(changes) ? "An optional edit then applies to that copied value, with all nested from paths still rooted in the immutable base. " : "") - + "Values assigns complete fields (exact indices with items); changed/items recurse, removed deletes object keys, length sets array length. Lost context triggers automatic full recovery from this capture, never an owner question."}; + + "Values assigns complete fields (exact indices with items); changed/items recurse, removed deletes object keys, length sets array length. " + + "Within the same work Turn, compaction alone requires neither another guard nor replay of already-read context. " + + "Continue from the working brief and original work/settlement identity; missing facts use same-capture recovery."}; if (bytes(delta) >= bytes(full("delta_not_smaller"))) return full("delta_not_smaller"); if (!equal(applyTree(prior, changes), context)) return full("reconstruction_failed"); // Native consumers page the wire before they can use its baseline. Deliver diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts new file mode 100644 index 0000000000..c49ed716f3 --- /dev/null +++ b/loopx/control_plane/quota/progressive_context.ts @@ -0,0 +1,85 @@ +/** Native model presentation: requirements stay inline, supporting material is + * addressed in the immutable source. This plan changes no canonical state. */ +import type {JsonObject} from "../effect_program.ts"; +import {jsonObject, requireJsonObject} from "../runtime_decode.ts"; +import {GOAL_OUTCOMES_SCHEMA} from "../goals/outcome_requirements.ts"; +import {FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE} from "../work_items/interaction_contract.ts"; + +type Path = (string | number)[]; +const at = (root: unknown, path: Path): unknown => path.reduce((row, key) => + Array.isArray(row) ? row[Number(key)] : jsonObject(row)?.[key], root); +const known = (row: JsonObject, fields: string[]): boolean => Object.keys(row).every(key => fields.includes(key)); + +/** Return paths instead of round-tripping the caller's JSON numeric values + * through JS. The IO adapter performs only these typed-owner-selected edits. */ +export function progressiveContextPlan(request: JsonObject): JsonObject { + const payload = requireJsonObject(request.payload, "captured context"); + const deferred: JsonObject[] = [], instructions: JsonObject[] = []; + const unchanged = {deferred, instructions}; + // Signed envelopes need their own presentation contract. Unknown shapes and + // already projected packets retain their entire original content. + if (request.surface !== "quota_packet" || payload.ok !== true || payload.schema_version !== undefined + || payload.contract_binding !== undefined || payload.context_details !== undefined) return unchanged; + const carriers: Path[] = [ + ["planning_horizon", "core_goal", "acceptance_contract"], + ["autonomous_replan_obligation", "replan_context", "core_goal", "acceptance_contract"], + ["agent_todo_summary", "goal_acceptance_contract"], + ]; + const agent = ["interaction_contract", "agent_channel"]; + const sourcePath = [...agent, "work_context", "sources"]; + const sources = at(payload, sourcePath); + if (Array.isArray(sources)) sources.forEach((value, i) => { + const source = jsonObject(value), content = jsonObject(source?.content); + if (source?.source === "goal_acceptance" && content?.ok !== false && content?.read_complete !== false + && (content?.status === undefined || content.status === "loaded") + && (source.reason === undefined || typeof source.reason === "string" && source.reason.includes(FULL_OUTCOME_READ_GUIDANCE))) + carriers.push([...sourcePath, i, "content", "goal_acceptance_contract"]); + }); + for (const carrier of carriers) { + const path = [...carrier, "outcome_requirements"], declaration = jsonObject(at(payload, path)); + if (declaration?.schema_version !== GOAL_OUTCOMES_SCHEMA || !Array.isArray(declaration.outcomes)) continue; + declaration.outcomes.forEach((value, i) => { + const row = jsonObject(value); + if (!row || !known(row, ["id", "owner_agent_id", "objective", "revision", "requirements", "todo_ids", "sources", "basis_digest", "work"]) + || typeof row.id !== "string" || typeof row.objective !== "string" || !Array.isArray(row.requirements) + || !Array.isArray(row.sources) || row.sources.some(value => { + const source = jsonObject(value); + return !source || !known(source, ["ref", "summary"]) || typeof source.ref !== "string" || typeof source.summary !== "string"; + })) return; + const reviews: number[] = []; + for (const [j, value] of row.requirements.entries()) { + const requirement = jsonObject(value); + if (!requirement || !known(requirement, ["id", "description", "agent_review", "planning_satisfied", "status"]) + || typeof requirement.id !== "string" || typeof requirement.description !== "string") return; + if (requirement.agent_review === undefined) continue; + const review = jsonObject(requirement.agent_review); + if (!review || !known(review, ["id", "conclusion", "note", "evidence_refs", "basis_current", "independently_verified"]) + || typeof review.note !== "string" || !Array.isArray(review.evidence_refs) + || review.evidence_refs.some(ref => typeof ref !== "string") + || !["met", "unmet", "uncertain"].includes(String(review.conclusion)) + || typeof review.basis_current !== "boolean" || review.independently_verified !== false) return; + reviews.push(j); + } + const parent = [...path, "outcomes", i]; + const index_key = "supporting_material_pointer"; + if (row.sources.length) deferred.push({path: [...parent, "sources"], parent, index_key}); + for (const j of reviews) for (const field of ["note", "evidence_refs"]) + deferred.push({path: [...parent, "requirements", j, "agent_review", field], parent, index_key}); + }); + } + if (!deferred.length) return unchanged; + // Replace only the exact source-owned obligation. Other merged obligations, + // objective adoption, unknown provider prose and pending reads remain intact. + for (const listPath of [sourcePath, [...agent, "required_reads"]]) { + const rows = at(payload, listPath); + if (!Array.isArray(rows)) continue; + rows.forEach((value, i) => { + const row = jsonObject(value); + if (row?.source === "goal_acceptance" && typeof row.reason === "string" + && row.reason.includes(FULL_OUTCOME_READ_GUIDANCE)) + instructions.push({path: [...listPath, i, "reason"], value: + row.reason.replace(FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE)}); + }); + } + return {deferred, instructions}; +} diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 8c9a334bf4..d6ec35bb27 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -177,6 +177,11 @@ def bind_turn_decision_capture( ) +def plan_progressive_context(payload: Mapping[str, Any], *, surface: str) -> dict[str, Any]: + """Typed presentation edits; the source JSON and all work authority stay intact.""" + return _turn_envelope_result("progressive_context_plan", payload=dict(payload), surface=surface) + + def prepare_context_delivery( context: Mapping[str, Any], *, scope: Mapping[str, str], capture_id: str, baseline: Mapping[str, Any] | None = None, diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 094d8d57c4..214c8506fc 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,4 +1,5 @@ import {capturedContextSelection} from "./context_selection.ts"; +import {progressiveContextPlan} from "./progressive_context.ts"; import {contextProjectionChanges, projectQuotaDiagnostics} from "./diagnostic_projection.ts"; import type { LoopXTurnRouteValue } from "../turn_driver/turn_contract_generated.ts"; import { EffectiveAction, type QuotaEffectiveActionValue } from "./effective_action.generated.ts"; @@ -1407,6 +1408,7 @@ export function turnEnvelopeRoute(envelope: JsonObject, historicalText?: JsonObj export function evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; + if (operation === "progressive_context_plan") return progressiveContextPlan(request); if (operation === "replan_view") return replanView(request); if (operation === "context_select") return capturedContextSelection(request); if (operation === "replan_cli_view") { diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index cd0ab3a5f7..a4f96474f5 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -9,6 +9,7 @@ from contextlib import contextmanager from collections.abc import Iterator from typing import Any +import copy import json import os from pathlib import Path @@ -206,16 +207,21 @@ def read_codex_context_reference(directory: str, *, goal_id: str | None, agent_i def _context_continuation_instructions(command: str) -> str: - """Distinguish fresh full delivery from recovery of a dependent decision.""" + """Separate transport continuity from the lifetime of the admitted work. + + Losing a delta baseline says nothing about which facts the working brief + preserved. Do not turn every compaction into another full-input loop. + """ return ( - "For a new independent decision, run its normal guarded context command first. " - "Without a usable baseline it returns the current full capture; read it completely before dependent work. " - "Do not read an older capture merely to seed a delta. " - "If continuing a captured LoopX decision after context loss (including compaction or a failed hook), " - "first recover that exact original capture with: " + command + ". " - "A fresh guard cannot replace recovery of that decision. " - "If recovery fails, restore the original capture/reference; do not guess or ask the owner. " - "Recovery never renews authority/freshness or proves retention. Independent authorized work can continue. " + "A new work Turn needs its current guard and required reads; never prefetch an old packet to seed a delta. " + "Compaction or a failed hook invalidates delta reuse, not the existing Turn. " + "Continue from its working brief, preserving Goal, requirements/stops and original task/capture/settlement identity. " + "Do not replay read context or rerun admission solely because the baseline was invalidated. " + "Before dependent action, recover missing or uncertain necessary facts from the same capture with: " + command + ". " + "An unfinished required read still needs its missing content; a summary cannot declare it read. " + "Never repeat completed effects or settlement. If the original reference is lost, repair it first; " + "independently authorized work can continue. A brief or recovery grants no new authority/freshness, " + "delta baseline or retention proof. " ) @@ -231,7 +237,7 @@ def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_i "--max-page-output-bytes", str(page_output_bytes)]) return ( _context_continuation_instructions(command) + - "Follow the returned next_command through eof; verify complete bytes and digest. " + + "When recovering a required full read, follow next_command through eof and verify complete bytes/digest. " + ("The generated native configuration supports 64000-byte context-read outputs and a 20000-token history limit. " "Read one continuation per tool call with max_output_tokens=22000. In code-mode, also set the exec " "pragma max_output_tokens to 24000 and forward successful stdout directly with text(result.output). " @@ -336,7 +342,7 @@ def codex_context_hook(directory: str | None, event: dict[str, Any]) -> dict[str return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": "LoopX context baseline invalidated. " + _context_continuation_instructions(recovery["command"]) - + CONTEXT_CAPTURE_PAGE_GUIDANCE}} + + "When completing a required full-capture read: " + CONTEXT_CAPTURE_PAGE_GUIDANCE}} return {} @@ -456,6 +462,62 @@ def register_native_context_view(directory: str | None, view: dict[str, Any]) -> _save(root, state) +def _capture_required_context(payload: dict[str, Any], *, directory: Path, + surface: str) -> tuple[dict[str, Any], dict[str, Any]]: + """Publish the archive first, then the model view selected by the typed owner. + + Only the required view may become a full/delta baseline. Detail pointers + always address the immutable archive, never a later canonical-state read. + """ + from ..quota.turn_envelope import plan_progressive_context + from ..effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError + + archive = capture_context_projection(payload, directory=directory, + read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE) + try: + plan = plan_progressive_context(payload, surface=surface) + except (EffectRuntimeRemoteError, EffectRuntimeStartupError): + return payload, archive + if not plan["deferred"]: + return payload, archive + projected = copy.deepcopy(payload) + + def at(path): + row = projected + for key in path: + row = row[key] + return row + + for edit in plan["deferred"]: + path, parent = edit["path"], edit["parent"] + del at(path[:-1])[path[-1]] + at(parent)[edit["index_key"]] = "/" + "/".join( + str(key).replace("~", "~0").replace("/", "~1") for key in parent) + for edit in plan["instructions"]: + at(edit["path"][:-1])[edit["path"][-1]] = edit["value"] + page_bytes, output_bytes = native_context_page_budget() + projected["context_details"] = { + "path": archive["path"], "sha256": archive["sha256"], + "command_template": context_capture_read_command(Path(archive["path"]), archive["sha256"], + page_bytes=page_bytes, max_page_output_bytes=output_bytes) + " --select '/sources'", + "instruction": "This view retains complete requirements and current review/check states. " + "Source history, review reasoning and evidence references are supporting material. " + "Read them from this same immutable capture when a decision, verification or review depends on them. " + "For source history use supporting_material_pointer plus /sources. For review detail append " + "/requirements//agent_review/note or /requirements//agent_review/evidence_refs instead. " + "Repeat --select to batch needed fields without replaying the outcome's requirements. " + "Detail reads never establish a full baseline. " + "Uncertain or missing necessary facts must be recovered before dependent action; Agent review is not independent acceptance.", + } + # These are transport bytes, not a semantic relevance heuristic or a model + # token estimate. Small material is cheaper inline than an index plus IO. + required_bytes = len((json.dumps(projected, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8")) + if required_bytes >= Path(archive["path"]).stat().st_size: + return payload, archive + return projected, capture_context_projection(projected, directory=directory, + filename="required-context.json", read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE) + + def project_codex_context(payload: dict[str, Any], *, directory: str | None, capture_directory: Path, goal_id: str, agent_id: str, surface: str, capture_id: str, continuity_generation: str | None = None) -> dict[str, Any]: @@ -490,8 +552,7 @@ def project_codex_context(payload: dict[str, Any], *, directory: str | None, cap if continuity_generation is not None: scope["continuity_generation"] = continuity_generation request = _native_context_request(state, scope) - recovery = capture_context_projection(payload, directory=capture_directory, - read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE) + required, recovery = _capture_required_context(payload, directory=capture_directory, surface=surface) page_bytes, output_bytes = native_context_page_budget() recovery["command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"], page_bytes=page_bytes, max_page_output_bytes=output_bytes) @@ -505,7 +566,7 @@ def project_codex_context(payload: dict[str, Any], *, directory: str | None, cap _save(root, state) recovery_committed = True - result = project_captured_context(payload, directory=capture_directory, + result = project_captured_context(required, directory=capture_directory, request=request, captured_recovery=recovery, goal_id=goal_id, agent_id=agent_id, surface=surface, capture_id=capture_id) if result.get("schema_version") == "context_delivery_v1": diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index d1fba5fb5d..1495ac48c4 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -250,15 +250,18 @@ def capture_page_range(page: dict[str, Any], *, raw: bytes, digest: str) -> tupl def capture_context_projection(payload: dict[str, Any], *, directory: Path, - read_guidance: str = CONTEXT_CAPTURE_READ_GUIDANCE) -> dict[str, Any]: + read_guidance: str = CONTEXT_CAPTURE_READ_GUIDANCE, + filename: str = "projection.json") -> dict[str, Any]: """Save the full projection independently of the optional presentation codec.""" - projection = directory / "projection.json" + if filename not in ("projection.json", "required-context.json"): + raise ValueError("unsupported captured context filename") + projection = directory / filename write_decision_capture(projection, payload) digest = hashlib.sha256(projection.read_bytes()).hexdigest() return { "path": str(projection), "sha256": digest, - "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " - "truncated or uncertain, use this full projection before dependent work. " + "instruction": "Complete JSON for this captured view. Use it when required content is missing or uncertain; " + "compaction alone does not require replay within an existing work Turn. " + read_guidance, "command": context_capture_read_command(projection, digest), } diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index 70b85d9464..75122b1de3 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -770,6 +770,11 @@ export function selectedWorkTodoId(packet: JsonObject): string | null { return typeof selected.todo_id === "string" && selected.todo_id.trim() ? selected.todo_id : null; } +// These are source-owned obligations; native material projection replaces only +// this exact generated clause, preserving other independently applicable reads. +export const FULL_OUTCOME_READ_GUIDANCE = "Read the full acceptance basis and any supporting outcomes with cumulative requirements, work links and sources. Preserve the original Goal and earlier requirements when applying direct user refinements. Before switching work or declaring completion, reconcile unmet outcome requirements with subsequent work; batch changed requirements/evidence reviews through refresh-state --goal-outcome-update-json or goal-acceptance refine. Reuse complete inline context; unchanged work needs no extra inspect/write. Agent review is distinct from independent checks. Scoped acceptance never replaces Goal intent. Changed requirements need a fresh guard."; +export const PROGRESSIVE_OUTCOME_READ_GUIDANCE = "Read the complete acceptance basis, cumulative outcome requirements, work links and current review/check states. Supporting source history, review reasoning and evidence references are available on demand from the same capture; read them when the next decision, verification or review depends on them. Preserve the original Goal and earlier requirements when applying direct user refinements. Before switching work or declaring completion, reconcile unmet outcome requirements with subsequent work; batch changed requirements/evidence reviews through refresh-state --goal-outcome-update-json or goal-acceptance refine. Reuse complete inline context; unchanged work needs no extra inspect/write. Agent review is distinct from independent checks. Scoped acceptance never replaces Goal intent. Changed requirements need a fresh guard."; + /** Generate the shared pre-work reads after final admission. Hosts consume this * list; transport projections must not independently infer requirements. * Python supplies registered source routing, never a second admission rule. @@ -816,7 +821,7 @@ function interactionRequiredReads(request: JsonObject): JsonObject { if (request.goal_objective_present === true) add(`${prefix} --format json goal-acceptance inspect --goal-id ${goalArg}`, "goal_acceptance", "Use the canonical goal_objective before the older source text. Only if your adoption is missing/stale, review your own work, reconcile Todo/Vision changes, then use adopt-objective with your Agent id, exact revision/digest, own objective_work_digest and review_summary. New execution is held until your adoption. Other peers adopt independently; existing Turn settlement/release remains available. Preserve root acceptance, permissions and stops. Adopt once per objective revision, never per Turn or work/peer update. Reuse complete inline context without extra reads/writes."); if (checksPresent || request.goal_outcomes_present === true) add(`${prefix} --format json goal-acceptance inspect --goal-id ${goalArg}`, - "goal_acceptance", request.goal_outcomes_present === true ? "Read the full acceptance basis and any supporting outcomes with cumulative requirements, work links and sources. Preserve the original Goal and earlier requirements when applying direct user refinements. Before switching work or declaring completion, reconcile unmet outcome requirements with subsequent work; batch changed requirements/evidence reviews through refresh-state --goal-outcome-update-json or goal-acceptance refine. Reuse complete inline context; unchanged work needs no extra inspect/write. Agent review is distinct from independent checks. Scoped acceptance never replaces Goal intent. Changed requirements need a fresh guard." : "Read full configured objective, non-goals and criteria with scope/revision. Scoped acceptance does not replace Goal intent or prove completion; Removed or changed checks require a fresh guard."); + "goal_acceptance", request.goal_outcomes_present === true ? FULL_OUTCOME_READ_GUIDANCE : "Read full configured objective, non-goals and criteria with scope/revision. Scoped acceptance does not replace Goal intent or prove completion; Removed or changed checks require a fresh guard."); const todoId = hasReplan ? null : selectedWorkTodoId(request); if (todoId) add(`${prefix} --format json todo list --goal-id ${goalArg} --todo-id ${shellQuote(todoId)}`, "selected_todo", "Read full current requirements and status/claim. Require one matching active Todo; missing, ambiguous or changed work requires a fresh guard. A summary cannot replace this read."); diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index eb84be30dd..896beaa608 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -37,7 +37,7 @@ + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") for surface in ("quota_packet", "turn_envelope")] + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, checkpoint_handoff=False, handoff_socket=None, native_pragma=False, native_read_batch=2, native_history_tokens=None, resume_loss=None, bounded_pages=False, ssh_goal=False, reader_output_bytes=None, recovery_resume=False): +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, checkpoint_handoff=False, handoff_socket=None, native_pragma=False, native_read_batch=2, native_history_tokens=None, resume_loss=None, bounded_pages=False, ssh_goal=False, reader_output_bytes=None, recovery_resume=False, working_brief=False): # Real worker qualification follows returned cursors; output budgets can # shrink a page, so precomputed fixed offsets are not its production path. bounded_pages = bounded_pages or host == "benchmark" @@ -338,15 +338,33 @@ def response(request, number, message): cmd = "printf synthetic-work" elif phase == "compact": stages["phase"] = "recovery_ready" - message["content"][0]["text"] = "Summary deliberately omitting all packet source bodies." + message["content"][0]["text"] = ("Continue the same admitted work. " + + f"Goal {GOAL_ID}, task {TODO_ID}. Stop before publication. " + + "All initial required reads completed; remaining action is synthetic-work. " + + json.dumps({"capture": wires["warm"]["recovery"], + "receipt": json.loads(snapshots[-1][1])["heartbeat_receipt"]}) + if working_brief else "Summary deliberately omitting all packet source bodies.") return message elif phase == "recovery_ready": + if working_brief: + text = _input_text(request) + assert "Do not replay read context or rerun admission solely" in text + assert "Stop before publication." in text and GOAL_ID in text and TODO_ID in text + assert wires["warm"]["recovery"]["sha256"] in text + assert delivered(request, "cold") == "" and delivered(request, "warm") == "" + assert "candidate" not in native_state() + stages["brief_continuations"] = stages.get("brief_continuations", 0) + 1 + if stages["brief_continuations"] < 2: + stages.update(phase="compact", compact_response=number) + return tool("printf synthetic-work", number) + stages["phase"] = "done" + return message if hook_loss: text = _input_text(request) assert "LoopX context baseline invalidated." not in text # The recovery entry must really be present after compaction; # the fixture may not supply a hidden saved capture path. - prefix = "If continuing a captured LoopX decision" + prefix = "Before dependent action, recover missing or uncertain necessary facts" permanent = text[text.index(prefix):] # Read only the advertised command sentence. Subsequent # recovery guidance is not part of the shell invocation. @@ -790,3 +808,9 @@ def test_native_large_page_recovery(tmp_path, monkeypatch, surface): "code_mode", surface, False, True, True, "missing", shared_root=True, native_pragma=True, native_read_batch=1, native_history_tokens=10000, bounded_pages=True, reader_output_bytes=32000, ssh_goal=True) + + +@pytest.mark.parametrize("host", ["exec", "code_mode"]) +def test_native_same_turn_working_brief_survives_two_compactions_without_packet_replay(tmp_path, monkeypatch, host): + test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, + host, "quota_packet", False, False, True, bounded_pages=True, working_brief=True) diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index fe0ce53e01..5b11258d42 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -117,7 +117,9 @@ def test_lifecycle_invalidates_and_recovers_current_capture_without_bodies(tmp_p if event_name == "SessionStart": body = result["hookSpecificOutput"]["additionalContext"] assert first["recovery"]["sha256"] in body and "quota context-read" in body - assert payload["requirements"] not in body and len(body) < 2000 + # The additional work-Turn/read-obligation distinction is intentional; + # this synthetic allowance is not a host output or context limit. + assert payload["requirements"] not in body and len(body) < 2300 else: assert result == {} _, second = project(tmp_path, "two") @@ -389,8 +391,9 @@ def test_fresh_capture_after_resume_does_not_require_reading_old_original(tmp_pa before = Path(original["path"]).read_bytes() resumed = context.codex_context_hook(str(tmp_path), {**event, "source": "resume"}) guidance = resumed["hookSpecificOutput"]["additionalContext"] - assert guidance.index("new independent decision") < guidance.index(original["command"]) - assert "A fresh guard cannot replace recovery" in guidance + assert guidance.index("new work Turn") < guidance.index(original["command"]) + assert "Do not replay read context or rerun admission solely" in guidance + assert "An unfinished required read still needs its missing content" in guidance assert "candidate" not in context._state(tmp_path) # A fresh authoritative input must be full. No old reader pages are diff --git a/tests/control_plane/test_progressive_native_context.py b/tests/control_plane/test_progressive_native_context.py new file mode 100644 index 0000000000..83629f38b6 --- /dev/null +++ b/tests/control_plane/test_progressive_native_context.py @@ -0,0 +1,262 @@ +"""Necessary native context and immutable supporting detail have distinct coverage.""" +import argparse +import copy +import hashlib +import json +from pathlib import Path +import shlex + +import pytest + +from loopx.cli_commands.quota_capture import read_context_capture +from loopx.control_plane.turn_driver import codex_context as context +from test_codex_context_lifecycle import setup, project, deliver, read_pages +from test_goal_acceptance_cli import acceptance_goal as acceptance_goal +from test_quota_settlement_cli import _run_cli + + +def payload(): + return {"ok": True, "goal_id": "fixture", "stop": "Stop before publication.", + "planning_horizon": {"core_goal": {"acceptance_contract": {"outcome_requirements": { + "schema_version": "loopx_goal_outcomes_v0", "current_lifetime": True, "revision": 1, + "outcomes": [{"id": "outcome", "owner_agent_id": "agent", "revision": 1, + "objective": "Deliver the complete requested result.", "todo_ids": ["todo_work"], + "sources": [{"ref": "original", "summary": "Historical supporting evidence. " * 100}], + "requirements": [{"id": "last", "description": "Preserve this final requirement.", + "status": "unverified", "planning_satisfied": False, + "agent_review": {"id": "last", "conclusion": "unmet", "basis_current": False, + "independently_verified": False, "note": "Previous failure and its reasons. " * 100, + "evidence_refs": ["negative:original"]}}]}]}}}}} + + +def outcome(document): + return document["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["outcomes"][0] + + +def test_cold_warm_and_repeated_compaction_keep_requirements_without_replaying_material(tmp_path, monkeypatch): + event = setup(tmp_path, monkeypatch) + original = payload() + _, first = project(tmp_path, payload=original) + view = first["context"] + assert first["mode"] == "full" and view["stop"] == original["stop"] + assert outcome(view)["requirements"][0]["description"] == outcome(original)["requirements"][0]["description"] + review = outcome(view)["requirements"][0]["agent_review"] + assert review == {"id": "last", "conclusion": "unmet", "basis_current": False, "independently_verified": False} + assert "sources" not in outcome(view) + archive = view["context_details"] + assert json.loads(Path(archive["path"]).read_bytes()) == original + assert Path(first["recovery"]["path"]).name == "required-context.json" + assert json.loads(Path(first["recovery"]["path"]).read_bytes()) == view + + # Reading complete selected details cannot acknowledge the required view. + request = argparse.Namespace(capture_file=archive["path"], capture_sha256=archive["sha256"], + select=[outcome(view)["supporting_material_pointer"]], offset=0, + page_bytes=16000, max_page_output_bytes=20000, format="json") + page = read_context_capture(request) + assert page["ok"] and page["eof"] + assert json.loads(page["content"])["selections"][0]["value"] == outcome(original) + deliver(tmp_path, event, [page]) + assert "candidate" not in context._state(tmp_path) + deliver(tmp_path, event, read_pages(tmp_path, first)) + assert context._state(tmp_path)["candidate"]["capture_file"] == first["recovery"]["path"] + + _, warm = project(tmp_path, "warm", payload=original) + assert warm["target"]["context_epoch"] == first["target"]["context_epoch"] + deliver(tmp_path, event, read_pages(tmp_path, warm)) + before = context._state(tmp_path) + for _ in range(2): + result = context.codex_context_hook(str(tmp_path), {**event, "source": "compact"}) + after = context._state(tmp_path) + assert after["recovery"] == before["recovery"] and "candidate" not in after + assert "Do not replay read context or rerun admission solely" in result["hookSpecificOutput"]["additionalContext"] + assert "Previous failure" not in json.dumps(result) + _, fresh = project(tmp_path, "fresh", payload={**original, "stop": "Stop all delivery."}) + assert fresh["mode"] == "full" and fresh["context"]["stop"] == "Stop all delivery." + assert json.loads(Path(archive["path"]).read_bytes()) == original + + +@pytest.mark.parametrize("failure", [None, "wire", "inline_read"]) +@pytest.mark.parametrize("output_limit", [None, 4000]) +def test_saved_native_projection_recovers_the_required_view(tmp_path, monkeypatch, failure, output_limit): + from loopx.cli_commands import quota_capture + from loopx.control_plane.turn_driver import decision_capture + + event = setup(tmp_path, monkeypatch) + original = {**payload(), "agent_identity": {"agent_id": "agent"}, + "heartbeat_receipt": {"turn_instance_id": "original-turn"}} + source = tmp_path / "original.json" + raw = json.dumps(original).encode() + source.write_bytes(raw) + digest = hashlib.sha256(raw).hexdigest() + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + command = ["quota", "context-project", "--capture-file", str(source), "--capture-sha256", digest, + "--goal-id", "fixture", "--agent-id", "agent", "--turn-instance-id", "original-turn", + "--context-delivery", "on", "--codex-app", "--codex-context-dir", str(tmp_path)] + if output_limit is not None: + command += ["--max-page-output-bytes", str(output_limit)] + write_capture = decision_capture.write_decision_capture + + def fail_wire(path, value, **kwargs): + if path.name == "delivery.json": + raise OSError("optional wire unavailable") + return write_capture(path, value, **kwargs) + + with monkeypatch.context() as patch: + if failure == "wire": + patch.setattr(decision_capture, "write_decision_capture", fail_wire) + elif failure == "inline_read": + patch.setattr(quota_capture, "read_context_capture", lambda *args, **kwargs: {"ok": False}) + code, view = _run_cli(registry, runtime, *command, cwd=tmp_path) + assert code == 0 and view["ok"] and view["mode"] == "full", view + + def check_recovery(view): + journal = context._state(tmp_path)["recovery"] + assert view["capture_sha256"] == digest + assert {key: view["recovery"][key] for key in ("path", "sha256")} == { + key: journal[key] for key in ("path", "sha256")} + argv = shlex.split(view["recovery"]["command"]) + if output_limit is not None: + assert int(argv[argv.index("--max-page-output-bytes") + 1]) <= output_limit + pages, combined = [], b"" + while True: + code, recovered = _run_cli(registry, runtime, *argv[argv.index("quota"):], cwd=tmp_path) + assert code == 0 and recovered["offset"] == len(combined) + if output_limit is not None: + assert len((json.dumps(recovered, ensure_ascii=False, separators=(",", ":")) + "\n").encode()) <= output_limit + pages.append(recovered) + combined += recovered["content"].encode() + if recovered["eof"]: + break + argv = shlex.split(recovered["next_command"]) + assert combined == Path(view["recovery"]["path"]).read_bytes() + required = json.loads(combined) + assert Path(view["recovery"]["path"]).name == "required-context.json" + assert required["stop"] == original["stop"] + assert outcome(required)["requirements"][0]["description"] == outcome(original)["requirements"][0]["description"] + assert "sources" not in outcome(required) + assert json.loads(Path(required["context_details"]["path"]).read_bytes()) == original + return pages + + recovered = check_recovery(view) + if failure is None: + deliver(tmp_path, event, recovered) + assert context._state(tmp_path)["candidate"]["capture_file"] == view["recovery"]["path"] + code, warm = _run_cli(registry, runtime, *command, cwd=tmp_path) + assert code == 0 and warm["target"]["context_epoch"] == view["target"]["context_epoch"], warm + check_recovery(warm) + context.codex_context_hook(str(tmp_path), {**event, "source": "compact"}) + code, compacted = _run_cli(registry, runtime, *command, cwd=tmp_path) + assert code == 0 and compacted["mode"] == "full", compacted + check_recovery(compacted) + assert source.read_bytes() == raw and not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("change", ["unknown_outcome", "unknown_requirement", "unknown_review", "unknown_schema", "signed"]) +def test_unknown_semantics_are_never_hidden(tmp_path, monkeypatch, change): + setup(tmp_path, monkeypatch) + original = payload() + if change == "unknown_outcome": + outcome(original)["new_obligation"] = "Read this." + elif change == "unknown_requirement": + outcome(original)["requirements"][0]["new_obligation"] = "Read this." + elif change == "unknown_review": + outcome(original)["requirements"][0]["agent_review"]["new_obligation"] = "Read this." + elif change == "unknown_schema": + original["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["schema_version"] = "future" + else: + original["contract_binding"] = {"signature": "original"} + _, result = project(tmp_path, payload=original) + assert result.get("context", result) == original + assert "context_details" not in result.get("context", result) + + +def test_off_and_unsupported_surface_remain_exact(tmp_path, monkeypatch): + setup(tmp_path, monkeypatch) + original, unchanged = payload(), payload() + assert context.project_codex_context(original, directory=None, capture_directory=tmp_path, + goal_id="fixture", agent_id="agent", surface="quota_packet", capture_id="one") is original + required, _ = context._capture_required_context(original, directory=tmp_path, surface="turn_envelope") + assert required is original and original == unchanged + + +def test_projection_preserves_numbers_and_original_values(tmp_path, monkeypatch): + setup(tmp_path, monkeypatch) + original = payload() + original["opaque_counter"] = 9007199254740993 + before = copy.deepcopy(original) + required, _ = context._capture_required_context(original, directory=tmp_path, surface="quota_packet") + assert original == before and required["opaque_counter"] == before["opaque_counter"] + + +def test_unavailable_typed_plan_keeps_original_capture(tmp_path, monkeypatch): + from loopx.control_plane.quota import turn_envelope + from loopx.control_plane.effect_runtime import EffectRuntimeStartupError + + def unavailable(*args, **kwargs): + raise EffectRuntimeStartupError("Synthetic unavailable runtime", diagnostic_code="fixture_unavailable") + + monkeypatch.setattr(turn_envelope, "plan_progressive_context", unavailable) + original = payload() + required, recovery = context._capture_required_context(original, directory=tmp_path, surface="quota_packet") + assert required is original + assert Path(recovery["path"]).name == "projection.json" + assert json.loads(Path(recovery["path"]).read_bytes()) == original + assert not (tmp_path / "required-context.json").exists() + + +def test_small_material_keeps_full_capture_when_the_detail_index_costs_more(tmp_path): + original = payload() + outcome(original)["sources"][0]["summary"] = "Short supporting source." + review = outcome(original)["requirements"][0]["agent_review"] + review["note"], review["evidence_refs"] = "Failed.", ["negative:original"] + required, recovery = context._capture_required_context(original, directory=tmp_path, surface="quota_packet") + assert required is original + assert Path(recovery["path"]).name == "projection.json" + assert not (tmp_path / "required-context.json").exists() + + +def test_public_quota_cli_delivers_required_view_and_original_evidence(acceptance_goal, tmp_path, monkeypatch): + workspace, document, cli, run = acceptance_goal + original = {"outcome_id": "export", "owner_agent_id": "agent-a", "todo_ids": ["todo_export"], + "objective": "Deliver the complete export.", "requirements": [ + {"id": "first", "description": "Retain the original content."}, + {"id": "last", "description": "Stop before publication."}], + "source": {"ref": "message:original", "summary": ("Original supporting material. " * 16).strip()}} + sources = [] + # Enough valid history to outweigh the complete digest-bound read command; + # the small-material counterexample above deliberately keeps full delivery. + for i in range(8): + source = {**original["source"], "ref": f"message:original-{i}"} + sources.append(source) + document.write_text(json.dumps({**original, "source": source})) + code, refined = cli("refine", "--agent-id", "agent-a", "--document", str(document), + "--expected-provider-revision", cli("inspect")[1]["provider_revision"], + "--operation-id", f"original-outcome-{i}", "--execute") + assert code == 0, refined + before = cli("inspect")[1] + lifecycle = tmp_path / "native" + lifecycle.mkdir(mode=0o700) + (tmp_path / "captures").mkdir(mode=0o700) + setup(lifecycle, monkeypatch) + code, packet = run("quota", "should-run", "--goal-id", "goal-acceptance", "--agent-id", "agent-a", + "--todo-id", "todo_export", "--context-delivery", "on", "--codex-app", + "--codex-context-dir", str(lifecycle), "--decision-output-root", str(tmp_path / "captures"), + "--turn-instance-id", "original-turn") + assert code == 0, {key: packet.get(key) for key in ("error", "error_code", "error_detail", "reason", "reason_code")} + recovery = context._state(lifecycle)["recovery"] + assert Path(recovery["path"]).name == "required-context.json" + required = json.loads(Path(recovery["path"]).read_bytes()) + source = next(row for row in required["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row["source"] == "goal_acceptance") + shown = source["content"]["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0] + assert [row["description"] for row in shown["requirements"]] == [row["description"] for row in original["requirements"]] + assert "sources" not in shown and "on demand" in source["reason"] + details = required["context_details"] + code, page = run("quota", "context-read", "--capture-file", details["path"], + "--capture-sha256", details["sha256"], "--select", shown["supporting_material_pointer"] + "/sources", + "--page-bytes", "16000", "--max-page-output-bytes", "20000") + assert code == 0 and page["eof"], page + recovered = json.loads(page["content"])["selections"][0]["value"] + assert recovered == sources + assert "candidate" not in context._state(lifecycle) + assert cli("inspect")[1] == before, "presentation must not rewrite canonical acceptance" diff --git a/tests/control_plane/test_saved_context_project.py b/tests/control_plane/test_saved_context_project.py index c155e068e2..c171531f90 100644 --- a/tests/control_plane/test_saved_context_project.py +++ b/tests/control_plane/test_saved_context_project.py @@ -229,16 +229,17 @@ def fail_optional_wire(path, payload, **kwargs): "--codex-app", "--codex-context-dir", str(context), "--decision-output-root", str(root)]) result = project_saved_context_capture(args) assert result["ok"] and result["mode"] == "full" + recovered_path, recovered_digest = codex_context.read_codex_context_reference(str(context), + goal_id="saved-goal", agent_id="saved-agent") + assert result["capture_sha256"] == digest if failure == "wire": - assert result["path"] == str(path) and result["sha256"] == digest + assert result["path"] == recovered_path and result["sha256"] == recovered_digest else: - assert "content" not in result and result["recovery"]["sha256"] == digest + assert "content" not in result and result["recovery"]["sha256"] == recovered_digest argv = shlex.split(result["command"]) code, page = _run_cli(tmp_path / "absent.json", tmp_path / "absent-runtime", *argv[argv.index("quota"):], cwd=tmp_path) assert code == 0 and page["offset"] == 0 and json.loads(page["content"]) == original - recovered_path, recovered_digest = codex_context.read_codex_context_reference(str(context), - goal_id="saved-goal", agent_id="saved-agent") recovered = Path(recovered_path).read_bytes() assert hashlib.sha256(recovered).hexdigest() == recovered_digest assert json.loads(recovered) == original and path.read_bytes() == raw diff --git a/tests/control_plane_ts/progressive_context.test.ts b/tests/control_plane_ts/progressive_context.test.ts new file mode 100644 index 0000000000..71fca9d33c --- /dev/null +++ b/tests/control_plane_ts/progressive_context.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {progressiveContextPlan} from "../../loopx/control_plane/quota/progressive_context.ts"; +import {FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE} from "../../loopx/control_plane/work_items/interaction_contract.ts"; + +const outcome = {id: "o", objective: "Keep all requirements.", revision: 1, owner_agent_id: "a", todo_ids: ["t"], + sources: [{ref: "original", summary: "Supporting history."}], requirements: [{id: "r", description: "Final stop.", + status: "unverified", planning_satisfied: false, agent_review: {id: "r", conclusion: "unmet", note: "Why it failed.", + evidence_refs: ["negative"], basis_current: false, independently_verified: false}}]}; +const source = {source: "goal_acceptance", reason: "Adopt the current objective. " + FULL_OUTCOME_READ_GUIDANCE, + content: {status: "loaded", goal_acceptance_contract: {outcome_requirements: { + schema_version: "loopx_goal_outcomes_v0", outcomes: [outcome]}}}}; + +test("only supporting materials move; cumulative obligations and merged source guidance survive", () => { + const payload = {ok: true, interaction_contract: {agent_channel: {work_context: {complete: true, sources: [source]}}}}; + const before = structuredClone(payload); + const plan = progressiveContextPlan({payload, surface: "quota_packet"}); + const parent = ["interaction_contract", "agent_channel", "work_context", "sources", 0, + "content", "goal_acceptance_contract", "outcome_requirements", "outcomes", 0]; + assert.deepEqual(plan.deferred, [ + {path: [...parent, "sources"], parent, index_key: "supporting_material_pointer"}, + {path: [...parent, "requirements", 0, "agent_review", "note"], parent, index_key: "supporting_material_pointer"}, + {path: [...parent, "requirements", 0, "agent_review", "evidence_refs"], parent, index_key: "supporting_material_pointer"}, + ]); + assert.deepEqual(plan.instructions, [{path: ["interaction_contract", "agent_channel", "work_context", "sources", 0, "reason"], + value: "Adopt the current objective. " + PROGRESSIVE_OUTCOME_READ_GUIDANCE}]); + assert.deepEqual(payload, before); + assert.deepEqual(progressiveContextPlan({payload, surface: "turn_envelope"}), {deferred: [], instructions: []}); +}); + +test("unknown and failed source semantics remain whole rather than becoming guessed requirements", () => { + for (const changed of [{...source, content: {...source.content, ok: false}}, + {...source, content: {...source.content, status: "failed"}}, {...source, source: "unregistered"}]) { + assert.deepEqual(progressiveContextPlan({surface: "quota_packet", payload: {ok: true, + interaction_contract: {agent_channel: {work_context: {sources: [changed]}}}}}), {deferred: [], instructions: []}); + } + const custom = {...source, reason: "Unknown independently required instruction."}; + const plan = progressiveContextPlan({surface: "quota_packet", payload: {ok: true, + interaction_contract: {agent_channel: {work_context: {sources: [custom]}}}}}); + assert.deepEqual(plan.instructions, []); + assert.deepEqual(plan.deferred, []); +});