From 7076f2c9bd529cc27eb0a8edfe8967ef8d8461a0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:10:16 +0800 Subject: [PATCH] feat(context): read digest-bound batches from captured JSON Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 44 +++++++ loopx/cli_commands/quota_capture.py | 53 ++++++-- loopx/cli_commands/quota_registration.py | 5 + .../control_plane/quota/context_selection.ts | 34 +++++ loopx/control_plane/quota/turn_envelope.ts | 2 + .../turn_driver/decision_capture.py | 9 +- .../test_context_capture_selection.py | 117 ++++++++++++++++++ .../context_selection.test.ts | 30 +++++ 8 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 loopx/control_plane/quota/context_selection.ts create mode 100644 tests/control_plane/test_context_capture_selection.py create mode 100644 tests/control_plane_ts/context_selection.test.ts diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 8d0532e939..a69071845d 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -683,6 +683,50 @@ journal,防止短暂 IO 故障恢复后复活旧基线。不可变 capture 与 重新生成。此开关不重启停用 Goal、不改变额度或 lease;正常 Desktop、全部 Goal 的实际 采用和真实 solver 完整成本及效果仍须分别验收。 +### Selected capture details / 按需读取捕获详情 + +`quota context-read --select /path --select /another/path` reads an explicit +batch of RFC 6901 JSON pointers from the original digest-bound capture. Supply +the existing `--capture-file` and `--capture-sha256` reference, or start through +the existing session lookup. Omit `--select` for unchanged full-capture reads. +Selection is JSON-only; `~1` escapes `/`, `~0` escapes `~`, and the empty pointer +selects the root. Missing pointers or invalid array indices reject the whole +batch; a present JSON `null` is a valid value. Duplicate pointers are removed +and the remaining pointers are sorted. + +The existing page envelope keeps the original `sha256` and adds +`selected_pointers` and `selection_sha256`. Concatenated `content` is a +`context_capture_selection_v1` object with `selections: [{pointer, value}]`. +Its offsets, total bytes and EOF describe only that derived view. Follow +`next_command`, which retains both hashes and the exact selection; a nonzero +offset requires `--selection-sha256`. The reader verifies both hashes, preserves +original JSON values (including large integers), and performs no admission, +settlement or live Goal lookup. Selection requires the existing typed runtime; +ordinary full reads retain their file-only path. + +Selected EOF, including a root selection, cannot establish a full-context +baseline in the native hook. This shared reader enables exact detail expansion; +it does not change which facts a caller must read, relax required reads, or +switch existing native input to a necessary-only view. Those consumers must +integrate their scope, instructions and recovery together before claiming a +progressive-delivery or total-cost improvement. + +`quota context-read --select /path --select /another/path` 用 RFC 6901 JSON +pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--capture-file` 与 +`--capture-sha256`,或从已有会话查询入口开始;省略 `--select` 保持原有全量读取。 +选择模式仅支持 JSON:`~1` 表示 `/`,`~0` 表示 `~`,空 pointer 选择根对象。 +任一路径不存在或数组索引无效时整批失败;已存在的 `null` 合法。路径去重并排序。 + +分页保留原始 `sha256`,新增 `selected_pointers` 与 `selection_sha256`。 +拼接 `content` 后得到带 `selections: [{pointer, value}]` 的 +`context_capture_selection_v1`;偏移、总字节数与 EOF 仅描述此派生视图。 +沿 `next_command` 继续,它保留两个摘要及精确路径;非零偏移必须携带 +`--selection-sha256`。读取器校验两者,保留包括大整数在内的原 JSON 值, +不准入、不结算、不查询实时 Goal。选择模式依赖已有 typed runtime;普通全量 +读取仍只走文件路径。包括根对象选择在内的详情 EOF 均不能被原生 hook 记为 +完整上下文基线。此共享读取器提供精确展开能力,不改变调用方必读事实或自动切换 +必要视图;调用方须共同接入范围、指引与恢复后,再验收渐进投递及完整成本收益。 + ### Saved context cache / 已保存上下文缓存 For `quota context-project`, enabling context delivery no longer requires a diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 277ad1cbe0..d2d9c74bc4 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -14,7 +14,7 @@ from typing import Any from ..control_plane.content_digest import BARE_SHA256_PATTERN -from ..control_plane.effect_runtime import EffectRuntimeRejected +from ..control_plane.effect_runtime import EffectRuntimeRejected, EffectRuntimeRemoteError, EffectRuntimeStartupError from ..control_plane.quota.error_codes import QuotaCommandValidationError, QuotaDecisionTransportError from ..control_plane.turn_driver.decision_capture import ( validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, CONTEXT_CAPTURE_PAGE_GUIDANCE, context_capture_read_command, @@ -37,7 +37,9 @@ def _capture_page(path: Path, raw: bytes, digest: str, *, offset: int, size: int output_limit: int | None, instruction: bool, presentation: dict[str, Any] | None = None, next_page_bytes: int | None = None, - next_page_output_bytes: int | None = None) -> dict[str, Any]: + next_page_output_bytes: int | None = None, + select: list[str] | None = None, + selection_sha256: str | None = None) -> dict[str, Any]: end = min(offset + size, len(raw)) while end < len(raw) and raw[end] & 0xC0 == 0x80: end -= 1 @@ -48,11 +50,17 @@ def page(text: str) -> dict[str, Any]: result: dict[str, Any] = {**(presentation or {}), "ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, "offset": offset, "next_offset": cursor if cursor < len(raw) else None, "total_bytes": len(raw), "eof": cursor == len(raw), "content": text} - if instruction: + if select is not None: + result.update(selection_sha256=selection_sha256, selected_pointers=select) + result["read_instruction"] = ("This is selected detail from the original capture, not a complete context read. " + "Offsets and EOF describe only this selected view. Follow next_command to finish it; " + "keep both digests. Detail delivery does not establish a full-context baseline or grant authority.") + elif instruction: result["read_instruction"] = CONTEXT_CAPTURE_PAGE_GUIDANCE if cursor < len(raw): result["next_command"] = context_capture_read_command(path, digest, offset=cursor, page_bytes=next_page_bytes or size, - max_page_output_bytes=next_page_output_bytes or output_limit) + max_page_output_bytes=next_page_output_bytes or output_limit, + select=select, selection_sha256=selection_sha256) return result def fits(result: dict[str, Any]) -> bool: @@ -85,7 +93,8 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An """Bounded local IO over immutable JSON, not a new context/authority owner. Verify the complete byte digest on every page. The cursor addresses bytes - in that exact capture, so a replacement cannot silently mix two decisions. + in that capture or its separately digest-bound selection, so a replacement + cannot silently mix two decisions or detail views. EOF describes this page, never a model's complete-consumption receipt. """ try: @@ -124,18 +133,44 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= maximum: raise ValueError("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192; " "larger pages require a sufficient max-page-output-bytes within the capture limit") - path, raw, _ = _read_capture(value, digest) + path, raw, payload = _read_capture(value, digest) + selectors = getattr(args, "select", None) + selection_digest = getattr(args, "selection_sha256", None) + if selectors is None and selection_digest is not None: + raise ValueError("selection-sha256 requires --select") + if selectors is not None: + if getattr(args, "format", None) != "json": + raise ValueError("selected context-read requires JSON output") + if offset > 0 and selection_digest is None: + raise ValueError("selected continuation requires selection-sha256; follow next_command") + from ..control_plane.quota.turn_envelope import _turn_envelope_result + + plan = _turn_envelope_result("context_select", context=payload, select=selectors) + views: list[dict[str, Any]] = [] + for item in plan["selections"]: + selected: Any = payload + for key in item["path"]: + selected = selected[key] + views.append({"pointer": item["pointer"], "value": selected}) + raw = json.dumps({"schema_version": "context_capture_selection_v1", "selections": views}, + ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + expected = hashlib.sha256(raw).hexdigest() + if selection_digest is not None and selection_digest != expected: + raise ValueError("selected view digest changed; restore the original selection and reader") + selection_digest = expected + selectors = [item["pointer"] for item in views] if offset > len(raw) or (offset < len(raw) and raw[offset] & 0xC0 == 0x80): raise ValueError("capture offset is outside the file or not a UTF-8 boundary; use the returned next offset") return _capture_page(path, raw, digest, offset=offset, size=size, output_limit=output_limit, instruction=bool(context_dir), presentation=presentation, next_page_bytes=next_page_bytes, - next_page_output_bytes=next_page_output_bytes) - except (OSError, ValueError, RecursionError) as exc: + next_page_output_bytes=next_page_output_bytes, select=selectors, selection_sha256=selection_digest) + except (OSError, ValueError, RecursionError, EffectRuntimeRemoteError, EffectRuntimeStartupError) as exc: # Recovery is presentation-only: keep failures separate from quota's # collection error path and retain the existing receipt/capture. return {"ok": False, "schema_version": "context_capture_page_v1", - "reason": str(exc) if isinstance(exc, ValueError) else "original capture could not be read", + "reason": str(exc) if isinstance(exc, (ValueError, EffectRuntimeRemoteError, EffectRuntimeStartupError)) + else "original capture could not be read", "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 1506ca397f..446ce24702 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -228,6 +228,11 @@ def register_quota_command( "Use for shared Codex App hook configuration; cannot combine with --codex-context-dir. " "context-config renders native configuration for the exact Goal/Agent; no host configuration, trust, guard or spend are changed.") quota_parser.add_argument("--capture-sha256", help="For context-read/context-project: exact SHA-256 supplied with the capture reference.") + quota_parser.add_argument("--select", action="append", + help="For context-read: repeat an exact RFC 6901 JSON pointer to read a batch from the original capture. " + "Selected detail pages never acknowledge full-context retention; omit for unchanged full capture reads.") + quota_parser.add_argument("--selection-sha256", + help="For selected context-read continuations: exact derived-view digest from the preceding page.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") quota_parser.add_argument("--page-bytes", type=int, help="For context-read: maximum UTF-8 content bytes per page (JSON default: 64000 under a 64000-byte stdout budget; Markdown: 4096). " diff --git a/loopx/control_plane/quota/context_selection.ts b/loopx/control_plane/quota/context_selection.ts new file mode 100644 index 0000000000..3538deb75f --- /dev/null +++ b/loopx/control_plane/quota/context_selection.ts @@ -0,0 +1,34 @@ +/** Pure RFC 6901 selection over one captured JSON object. No state or admission. */ +import {requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import type {JsonObject} from "../effect_program.ts"; + +export function capturedContextSelection(request: JsonObject): JsonObject { + const context = requireJsonObject(request.context, "captured context"); + const selectors = request.select; + if (!Array.isArray(selectors) || !selectors.length || selectors.some(value => typeof value !== "string")) + throw new EffectRuntimeRequestError("context selection requires a nonempty array of JSON pointers"); + const pointers = [...new Set(selectors as string[])].sort(); + const selections = pointers.map(pointer => { + if (pointer !== "" && !pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) + throw new EffectRuntimeRequestError("context selection requires RFC 6901 pointers"); + const tokens = pointer === "" ? [] : pointer.slice(1).split("/").map(token => token.replace(/~1/g, "/").replace(/~0/g, "~")); + const path: (string | number)[] = []; + let value: unknown = context; + for (const token of tokens) { + if (Array.isArray(value)) { + if (!/^(0|[1-9][0-9]*)$/.test(token) || !Number.isSafeInteger(Number(token)) || Number(token) >= value.length) + throw new EffectRuntimeRequestError("context selection array index is missing or invalid"); + path.push(Number(token)); + value = value[Number(token)]; + } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { + path.push(token); + value = (value as JsonObject)[token]; + } else throw new EffectRuntimeRequestError("context selection pointer does not exist"); + } + // Return the verified address, not a JS-number-coerced copy of the value. + // The file adapter retains original JSON values and owns byte serialization. + return {pointer, path}; + }); + return {selections}; +} diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index bfcf7c5c9d..4041613c69 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,3 +1,4 @@ +import {capturedContextSelection} from "./context_selection.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 evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; if (operation === "replan_view") return replanView(request); + if (operation === "context_select") return capturedContextSelection(request); if (operation === "replan_cli_view") { const payload = requireJsonObject(request.payload, "replan CLI payload"); const reads = replanReadCommands(payload); diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index 8964e03824..d1fba5fb5d 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -185,7 +185,9 @@ def native_context_page_budget() -> tuple[int, int]: def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int | None = None, - max_page_output_bytes: int | None = None) -> str: + max_page_output_bytes: int | None = None, + select: list[str] | None = None, + selection_sha256: str | None = None) -> str: from ...command_invocation import loopx_cli_argv if page_bytes is None: @@ -195,6 +197,8 @@ def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, return shlex.join([*loopx_cli_argv(), "--format", "json", "quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, "--offset", str(offset), "--page-bytes", str(page_bytes), + *(argument for pointer in select or [] for argument in ("--select", pointer)), + *(["--selection-sha256", selection_sha256] if selection_sha256 is not None else []), *(["--max-page-output-bytes", str(max_page_output_bytes)] if max_page_output_bytes is not None else [])]) @@ -228,7 +232,8 @@ def read_context_capture_file(value: str, digest: str, *, private: bool = False) def capture_page_range(page: dict[str, Any], *, raw: bytes, digest: str) -> tuple[int, int] | None: """Verify reported page bytes, independently of a host's retention claim.""" - if page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: + if ("selection_sha256" in page or "selected_pointers" in page + or page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest): return None start, content = page.get("offset"), page.get("content") if type(start) is not int or start < 0 or not isinstance(content, str) or page.get("ok") is not True: diff --git a/tests/control_plane/test_context_capture_selection.py b/tests/control_plane/test_context_capture_selection.py new file mode 100644 index 0000000000..f1a175b5cb --- /dev/null +++ b/tests/control_plane/test_context_capture_selection.py @@ -0,0 +1,117 @@ +"""Selected details stay pinned to one capture and never acknowledge the full input.""" +import argparse +import hashlib +import json +import shlex + +import pytest + +from loopx.cli_commands.quota_capture import read_context_capture +from loopx.control_plane.turn_driver.decision_capture import capture_page_range +from test_quota_settlement_cli import _run_cli + + +def fixture_capture(tmp_path): + original = {"rows": [{"text": "保留末尾约束🙂" * 90, "tail": "stop if unavailable"}], + "a/b": {"~": None}, "large": 9007199254740993, "optional": "other evidence"} + raw = (json.dumps(original, ensure_ascii=False) + "\n").encode() + path = tmp_path / "capture with ' quote.json" + path.write_bytes(raw) + return path, raw, original + + +def args(path, raw): + return argparse.Namespace(**dict(capture_file=str(path), capture_sha256=hashlib.sha256(raw).hexdigest(), + offset=0, page_bytes=256, max_page_output_bytes=2000, format="json", select=["/rows/0"])) + + +def test_actual_cli_batch_pages_preserve_values_without_live_state(tmp_path): + path, raw, original = fixture_capture(tmp_path) + digest = hashlib.sha256(raw).hexdigest() + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + command = ["quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, + "--select", "/rows/0", "--select", "/large", "--select", "/a~1b/~0", + "--page-bytes", "256", "--max-page-output-bytes", "2000"] + chunks, offset, selection_digest = [], 0, None + while True: + code, page = _run_cli(registry, runtime, *command, cwd=tmp_path) + assert code == 0 and page["ok"], page + assert page["sha256"] == digest and page["offset"] == offset + selection_digest = selection_digest or page["selection_sha256"] + assert page["selection_sha256"] == selection_digest + assert capture_page_range(page, raw=raw, digest=digest) is None + assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 2000 + chunks.append(page["content"].encode()) + offset += len(chunks[-1]) + if page["eof"]: + assert page["next_offset"] is None and offset == page["total_bytes"] + break + assert page["next_offset"] == offset + command = shlex.split(page["next_command"])[3:] + assert "--selection-sha256" in command + combined = b"".join(chunks) + assert hashlib.sha256(combined).hexdigest() == selection_digest + assert json.loads(combined) == {"schema_version": "context_capture_selection_v1", "selections": [ + {"pointer": "/a~1b/~0", "value": None}, {"pointer": "/large", "value": original["large"]}, + {"pointer": "/rows/0", "value": original["rows"][0]}]} + assert path.read_bytes() == raw and not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("failure", ["missing", "changed_selection", "changed_capture", "wrong_digest", "no_digest", "no_selection"]) +def test_selection_continuations_fail_closed(tmp_path, failure): + path, raw, _ = fixture_capture(tmp_path) + request = args(path, raw) + first = read_context_capture(request) + assert first["ok"] and not first["eof"], first + request.offset = first["next_offset"] + request.selection_sha256 = first["selection_sha256"] + if failure == "missing": + request.select = ["/rows/0", "/missing"] + elif failure == "changed_selection": + request.select = ["/rows"] + elif failure == "changed_capture": + path.write_bytes(raw.replace(b"stop if unavailable", b"different condition")) + elif failure == "wrong_digest": + request.selection_sha256 = "0" * 64 + elif failure == "no_digest": + request.selection_sha256 = None + else: + request.select = None + result = read_context_capture(request) + assert result["ok"] is False and "content" not in result + + +def test_root_selection_is_explicit_detail_and_default_read_stays_raw(tmp_path): + path, raw, original = fixture_capture(tmp_path) + request = args(path, raw) + request.select = [""] + request.page_bytes = 64000 + request.max_page_output_bytes = 64000 + page = read_context_capture(request) + assert page["ok"] and page["eof"] and page["selected_pointers"] == [""] + assert json.loads(page["content"])["selections"] == [{"pointer": "", "value": original}] + assert capture_page_range(page, raw=raw, digest=request.capture_sha256) is None + request.select = None + full = read_context_capture(request) + assert full["content"].encode() == raw and "selection_sha256" not in full + assert capture_page_range(full, raw=raw, digest=request.capture_sha256) == (0, len(raw)) + # Even a forged detail marker on byte-identical full content cannot promote it. + assert capture_page_range({**full, "selection_sha256": "0" * 64}, raw=raw, digest=request.capture_sha256) is None + + +def test_native_hook_does_not_promote_selected_eof(tmp_path, monkeypatch): + from loopx.control_plane.turn_driver import codex_context as context + from tests.control_plane.test_codex_context_lifecycle import setup, project + + event = setup(tmp_path, monkeypatch) + _, delivery = project(tmp_path) + reference = delivery["recovery"] + request = argparse.Namespace(capture_file=reference["path"], capture_sha256=reference["sha256"], + offset=0, page_bytes=64000, max_page_output_bytes=64000, format="json", select=[""]) + page = read_context_capture(request) + assert page["ok"] and page["eof"] + before = context._state(tmp_path) + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostToolUse", + "tool_use_id": "selected-detail", "tool_response": json.dumps(page)}) + after = context._state(tmp_path) + assert "candidate" not in after and after["recovery"] == before["recovery"] diff --git a/tests/control_plane_ts/context_selection.test.ts b/tests/control_plane_ts/context_selection.test.ts new file mode 100644 index 0000000000..f9a5302768 --- /dev/null +++ b/tests/control_plane_ts/context_selection.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {evaluateTurnEnvelope} from "../../loopx/control_plane/quota/turn_envelope.ts"; + +const select = (context: object, pointers: unknown) => evaluateTurnEnvelope({operation: "context_select", context, select: pointers}); + +test("batch selection resolves exact escaped addresses and does not mutate the capture", () => { + const context = {"": {"a/b": {"~": [null, {last: "stop"}]}}, other: true}; + const before = structuredClone(context); + assert.deepEqual(select(context, ["//a~1b/~0/1", "//a~1b/~0/0", "//a~1b/~0/1"]), + {selections: [{pointer: "//a~1b/~0/0", path: ["", "a/b", "~", 0]}, + {pointer: "//a~1b/~0/1", path: ["", "a/b", "~", 1]}]}); + assert.deepEqual(context, before); + assert.deepEqual(select(context, [""]), {selections: [{pointer: "", path: []}]}); +}); + +test("missing, inherited, malformed and noncanonical array addresses fail the whole batch", () => { + const context = {rows: [null], "01": true}; + for (const pointer of ["rows", "/rows/-", "/rows/01", "/rows/1", "/rows/0/x", "/toString", "/missing", "/~2", "/~"]) + assert.throws(() => select(context, ["/rows", pointer])); + for (const pointers of [[], null, [1], ["/rows", false]]) assert.throws(() => select(context, pointers)); + assert.deepEqual(select(context, ["/01"]), {selections: [{pointer: "/01", path: ["01"]}]}); +}); + +test("literal prototype-like keys are addressable only as own JSON properties", () => { + const context = JSON.parse('{"__proto__":{"constructor":false}}'); + assert.deepEqual(select(context, ["/__proto__/constructor"]), + {selections: [{pointer: "/__proto__/constructor", path: ["__proto__", "constructor"]}]}); + assert.throws(() => select({}, ["/__proto__"])); +});