diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md index f892c0aa09..60c8f08467 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md @@ -1231,6 +1231,17 @@ The TurnEnvelope owner retains the top-level decision-field vocabulary and supplies it to Progressive selection. Moving the selector does not duplicate that vocabulary across owners or relax its semantic module budget. +Native Progressive supporting-material discovery now has a bounded precursor: +an unconfirmed index binds exact deferred-field pointers to the current complete +projection. Initial reads and file-backed warm deltas expose the complete index, +without requiring an old packet or model retention. Selected reads do not clear +its conservative coverage status or create a second ledger. Current-source CLI +reads qualify transport into a synthetic next artifact, including changed and +reordered material and unavailable predecessors; they do not establish natural +model adoption. All-host defaults, other omitted material, trusted Host coverage, +writer/reader cutover and §9's outcome/full-cost comparison remain open. Index +overhead participates in the existing full-versus-folded byte guard. + C1's inventory is part of its usable replacement, not a standalone architecture framework. C3/C4/C5 can proceed independently once C1's contract is stable; C7 can prepare measurements before C6 but cannot claim two-session outcomes. 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 610c5dd2ed..726a30386c 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 @@ -929,6 +929,13 @@ backoff 继续归当前 scheduler owner。本文不引入模型空轮询或新 顶层 decision 字段清单保留在 TurnEnvelope owner,由其传给 Progressive 选读;迁移 selector 不跨 owner 重复声明该词汇,也不放宽语义模块预算。 +原生 Progressive 支持材料发现增加有界前置实现:保守未确认索引将延迟字段的精确 +指针绑定当前完整 projection,首读和文件 warm delta 均完整呈现,不依赖旧包或模型 +retention。选读不会清除覆盖未知状态或新增第二账本。当前源 CLI 读取覆盖变化、重排 +及前序不可用,并进入合成后继产物;这只资格化运输,不证明自然模型采用。跨宿主默认、 +其他省略材料、可信 Host 覆盖、writer/reader 切换及第9节成果/完整成本对照仍开放。 +索引开销计入原完整/折叠字节守卫。 + C1 盘点是可用替换的一部分,不单独建设架构框架。C1 契约稳定后,C3/C4/C5 可以独立 推进;C7 可以提前准备测量,但不能提前宣称双会话成果。实现 PR 列明替换的调用方、剩余 缺口、实际测试与回退。完整语言迁移或无关 manager 重写不是前置条件;新抽象能编译 diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index eca0546eb6..ae6c1a7e7d 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -249,6 +249,38 @@ Agent、所选 Todo、成果版本及 provider revision 一致时,首读完整 原索引/详情路线;折叠的支持材料仍遵守原读取义务。此选读使用无需 Session Hook 的 通用 reader,不改变启用默认值、落盘文件、Delta 或完整读取回执。 +For supporting fields folded by the existing native Progressive owner, +`context_details.unread_context_index` contains the current complete +`projection.json` source path/digest and exact `pointers`. The initial reader +shows that index; a file-backed warm delta repeats it in full even when its +pointers are unchanged. Each pointer uses the ordinary `context-read` +`--capture-file`, `--capture-sha256` and `--select` route, independently of any +previous packet, retained model input or patch chain. These addresses belong to +the complete source, not the derived `required-context.json` recovery view. + +`coverage_confirmed=false` conservatively reports no trusted model-visible +coverage witness. A previous select, reader EOF or file receipt does not clear +an entry or create `confirmed_read_sections`/`consumed_pointers` state. +`read_before_dependent_work` preserves the original obligation to read material +when the decision or verification needs it; it is not an automatic full-read or +execution gate. Updated/reordered material binds to its current addresses; +removed material does not retain a predecessor route. The actual index and +guidance count toward the existing byte guard, including fallback to the +complete file when folding is not smaller. In-view deduplication alone adds no +index. This bounded native slice changes no activation defaults or off path; +cross-host rollout and trusted coverage observation remain C1/C2 work. + +中文:现有原生 Progressive owner 折叠的支持字段,在 +`context_details.unread_context_index` 中列出当前完整 `projection.json` 的路径、 +摘要和精确 `pointers`。首读呈现该索引;文件 warm delta 即使指针未变也完整重发。 +各指针通过普通 `context-read --capture-file ... --capture-sha256 ... --select ...` +独立读取,不依赖旧包、模型保留或补丁链;它们属于完整源,不属于派生 required view。 +`coverage_confirmed=false` 表示没有可信模型可见覆盖见证,选读、EOF或文件回执不会 +清除条目,也不新建已读账本。`read_before_dependent_work` 保留原先按决策/验证需要 +读取的义务,不成为自动全读或执行门。变化/重排绑定当前地址,移除材料不沿用前序入口。 +索引和指导计入原字节守卫,不更小时回退完整文件;仅视图内去重不新增索引。本原生切片 +不改启用默认值或 off 路径,跨宿主推广和可信覆盖观察仍属 C1/C2 未完成工作。 + Checkpoint Host preflight uses the hidden `--defer-context-presentation` option with an explicitly enabled native JSON `should-run` capture. It collects the same complete Goal source for admitted work-context reads and writes the ordinary diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index 2d77c1f044..7853f79249 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -588,7 +588,7 @@ export function prepareContextDelivery(request: JsonObject, legacyQuotaFields: r /** Shared encoding within one presentation transaction; callers publish once * against their original source so numeric facts never round-trip through JS. */ export function encodeContextDelivery(request: JsonObject, legacyQuotaFields: readonly string[], - values: ProjectionSourceValues): JsonObject { + values: ProjectionSourceValues, fileNavigation: Path[] = []): JsonObject { const context = requireJsonObject(request.context, "current projection"); const identity = contextSourceIdentity(request, request.source_number_tokens); const surface = surfaceOf(request.surface ?? "work_context"), target = targetBinding(request, context, surface); @@ -612,7 +612,9 @@ export function encodeContextDelivery(request: JsonObject, legacyQuotaFields: re if (!Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) >= MAX_ANCESTRY) return fullResult("full_refresh_due"); const before = freshPaths(prior, surface, legacyQuotaFields, binding), after = freshPaths(context, surface, legacyQuotaFields, target); if (!fileBacked && (!before.valid || !after.valid)) return fullResult("source_not_reusable"); - const changes = changeTree(prior, context, fileBacked ? [] : after.paths, fileBacked, + // The presentation owner may require navigation without opening the base. + // This does not turn file continuity into confirmed model delivery. + const changes = changeTree(prior, context, fileBacked ? fileNavigation : after.paths, fileBacked, {identity, before: ["baseline", "context"], after: ["context"], ...(request.source_edits === true ? {values} : {})}); const delta: JsonObject = {schema_version: VERSION, mode: "delta", target, base: binding, diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 71010ab27d..1a8f2ffd63 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -9,6 +9,8 @@ import {quotaSummaryReferences, contextSourceIdentity, contextProjectionChanges, import {encodeContextDelivery} from "./context_delivery.ts"; type Path = (string | number)[]; +const pointer = (path: Path): string => "/" + path.map(key => + String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/"); 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)); @@ -138,8 +140,7 @@ function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSour for (const edit of deferred) { const path = edit.path as Path, owner = edit.parent as Path; delete parent(path)[path.at(-1)!]; - parent([...owner, String(edit.index_key)])[String(edit.index_key)] = "/" + owner.map(key => - String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/"); + parent([...owner, String(edit.index_key)])[String(edit.index_key)] = pointer(owner); removed.push(["context", ...path]); } for (const edit of instructions) { @@ -147,12 +148,21 @@ function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSour parent(path)[path.at(-1)!] = edit.value; } if (deferred.length) required.context_details = {...details, + unread_context_index: { + source: {path: details.path, sha256: details.sha256}, + coverage_confirmed: false, + read_before_dependent_work: true, + pointers: [...new Set(deferred.map(edit => pointer(edit.path as Path)))], + }, 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. " + + "unread_context_index lists unconfirmed supporting fields in the current complete source. " + + "Use its source path and sha256 with quota context-read --select for each exact pointer. " + + "A prior selected read, EOF or saved file does not confirm model-visible coverage or retention. " + "Detail reads never establish a full baseline. " + "Uncertain or missing necessary facts must be recovered before dependent action; Agent review is not independent acceptance."}; } @@ -189,7 +199,10 @@ export function prepareProgressiveContextDelivery(request: JsonObject, legacyFie const required = requiredNativeContext(request, sourceValues); const codec = nativeContextContinuity({...request, context: required.context, ...(request.source_number_tokens === undefined ? {} : {source_number_tokens: required.tokens})}); - const delivery = encodeContextDelivery(codec, legacyFields, sourceValues); + const details = jsonObject(required.context.context_details); + const navigation: Path[] = details && Object.hasOwn(details, "unread_context_index") + ? [["context_details", "unread_context_index"]] : []; + const delivery = encodeContextDelivery(codec, legacyFields, sourceValues, navigation); const {operation: _operation, source_edits: _edits, source_number_tokens: _numbers, ...source} = request; return contextProjectionChanges(source, {context: required.context, delivery, projected: required.projected}, sourceValues); } @@ -210,6 +223,7 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: pointers.push("/" + path.map(key => String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")); }; for (const field of decisionFields) include(field); + include("context_details", "unread_context_index"); include("agent_lane_next_action", "next_step"); include("agent_lane_next_action", "next_action_basis"); include("interaction_contract", "user_channel"); diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 0c64de729a..9704539fa9 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -246,7 +246,8 @@ def test_initial_read_preserves_empty_and_unknown_current_preference_values(tmp_ page, values = read_selection(view["first_read_command"]) pointer = f"/interaction_contract/agent_channel/work_context/sources/{len(sources) - 1}/content/current" assert values[pointer] == current - assert not any(key.startswith(pointer + "/") for key in values) # No duplicate delivered descendants. + pointers = {row["pointer"] for row in json.loads(page["content"])["selections"]} + assert not any(key.startswith(pointer + "/") for key in pointers) # No duplicate delivered descendants. raw = Path(view["recovery"]["path"]).read_bytes() assert json.loads(raw) == before == decision assert capture_page_range(page, raw=raw, digest=page["sha256"]) is None diff --git a/tests/control_plane/test_context_unread_reachability.py b/tests/control_plane/test_context_unread_reachability.py new file mode 100644 index 0000000000..b645662cb5 --- /dev/null +++ b/tests/control_plane/test_context_unread_reachability.py @@ -0,0 +1,126 @@ +"""Current-capture detail discovery stays separate from delivery and retention.""" +import argparse +import copy +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 +from test_progressive_native_context import payload, outcome +from test_quota_settlement_cli import _run_cli + + +@pytest.mark.parametrize("previous_detail_read", [False, True]) +@pytest.mark.parametrize("change", ["unchanged", "updated", "reordered"]) +def test_unconfirmed_material_reaches_next_turn_work_from_current_capture( + tmp_path, monkeypatch, previous_detail_read, change, +): + from loopx.cli_commands.quota_capture import native_context_capture_view + + setup(tmp_path, monkeypatch) + original = payload() + original["interaction_contract"] = {"schema_version": "loopx_interaction_contract_v0", + "agent_channel": {"work_context": {"sources": []}}} + # Necessary inline text makes a file delta useful independently of the + # heavy supporting material whose delivery has never been confirmed. + outcome(original)["requirements"][0]["description"] = "Keep this whole acceptance clause. " * 300 + outcome(original)["requirements"][0]["agent_review"]["note"] += "Tail constraint: keep the report local." + peer = copy.deepcopy(outcome(original)) + peer["id"] = "peer" + peer["owner_agent_id"] = "other-agent" + original["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["outcomes"].append(peer) + _, first = project(tmp_path, payload=original) + first_details = first["context"]["context_details"] + first_index = first_details["unread_context_index"] + assert first_index["coverage_confirmed"] is False + assert first_index["source"] == {key: first_details[key] for key in ("path", "sha256")} + registry, runtime = tmp_path / "no-registry.json", tmp_path / "no-runtime" + navigation = native_context_capture_view(first, Path(first["recovery"]["path"]).parent, + context_directory=str(tmp_path)) + argv = shlex.split(navigation["first_read_command"]) + code, initial = _run_cli(registry, runtime, *argv[argv.index("quota"):], cwd=tmp_path) + assert code == 0 and initial["eof"], initial + shown = {row["pointer"]: row["value"] for row in json.loads(initial["content"])["selections"]} + assert shown["/context_details/unread_context_index"] == first_index + + def read_detail(source, pointer): + words = ["quota", "context-read", "--capture-file", source["path"], + "--capture-sha256", source["sha256"], "--select", pointer, + "--page-bytes", "1200", "--max-page-output-bytes", "2600"] + body, offset = [], 0 + while True: + code, page = _run_cli(registry, runtime, *words, cwd=tmp_path) + assert code == 0 and page["ok"] and page["offset"] == offset, page + body.append(page["content"]) + if page["eof"]: + return json.loads("".join(body))["selections"][0]["value"] + offset = page["next_offset"] + argv = shlex.split(page["next_command"]) + words = argv[argv.index("quota"):] + + if previous_detail_read: + previous_note = next(pointer for pointer in first_index["pointers"] + if pointer.endswith("/outcomes/0/requirements/0/agent_review/note")) + assert read_detail(first_index["source"], previous_note).endswith("keep the report local.") + current = copy.deepcopy(original) + if change == "updated": + outcome(current)["requirements"][0]["agent_review"]["note"] += " Updated constraint: include the contrary result." + if change == "reordered": + current["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["outcomes"].reverse() + _, warm = project(tmp_path, "next-turn", payload=current) + assert warm["mode"] == "delta" + required = json.loads(Path(warm["recovery"]["path"]).read_bytes()) + index = required["context_details"]["unread_context_index"] + assert index["coverage_confirmed"] is False + assert Path(index["source"]["path"]).name == "projection.json" + assert index["source"]["path"] != first_index["source"]["path"] + + def literal_index(value): + if isinstance(value, dict): + found = value.get("unread_context_index") + if found == index or isinstance(found, dict) and found.get("value") == index: + return True + return any(literal_index(child) for child in value.values()) + return isinstance(value, list) and any(literal_index(child) for child in value) + + assert literal_index(warm["changes"]), "Warm discovery must not require an inherited index." + rows = current["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["outcomes"] + position = next(i for i, row in enumerate(rows) if row["id"] == "outcome") + pointer = (f"/planning_horizon/core_goal/acceptance_contract/outcome_requirements/outcomes/{position}" + "/requirements/0/agent_review/note") + assert pointer in index["pointers"] + peer_position = next(i for i, row in enumerate(rows) if row["id"] == "peer") + assert (f"/planning_horizon/core_goal/acceptance_contract/outcome_requirements/outcomes/{peer_position}/sources" + in index["pointers"]) + # No previous packet, retained model input or patch chain is available to + # this no-hook reader. Its actual next artifact must use the recovered tail. + Path(first["recovery"]["path"]).unlink() + Path(first_details["path"]).unlink() + material = read_detail(index["source"], pointer) + artifact = tmp_path / "next-turn-report.txt" + artifact.write_text(material) + assert artifact.read_text() == rows[position]["requirements"][0]["agent_review"]["note"] + assert "Tail constraint: keep the report local." in artifact.read_text() + assert not {"confirmed_read_sections", "consumed_pointers"} & context._state(tmp_path).keys() + assert not registry.exists() and not runtime.exists() + + +def test_removed_material_does_not_keep_a_predecessor_discovery_route(tmp_path, monkeypatch): + setup(tmp_path, monkeypatch) + original = payload() + _, first = project(tmp_path, payload=original) + assert first["context"]["context_details"]["unread_context_index"]["pointers"] + current = copy.deepcopy(original) + outcome(current)["sources"] = [] + outcome(current)["requirements"][0].pop("agent_review") + _, next_turn = project(tmp_path, "removed", payload=current) + recovery = next_turn["recovery"] + assert json.loads(Path(recovery["path"]).read_bytes()) == current + missing = read_context_capture(argparse.Namespace(capture_file=recovery["path"], + capture_sha256=recovery["sha256"], select=["/context_details/unread_context_index"], + offset=0, page_bytes=12000, max_page_output_bytes=18000, format="json")) + assert missing["ok"] is False