From b2c84f74d8e282db102de6db3aae2d909345e9c3 Mon Sep 17 00:00:00 2001 From: fengyin-solo <292015025+fengyin-solo@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:57:08 +0800 Subject: [PATCH 1/5] feat(handoff): fragment over-budget project-agent handoffs losslessly Project-agent handoffs have a fixed 16 line / 1800 character interface budget. The old overflow pass first compacted the command block and then deleted whole sections by fixed prefixes, returning an over-budget text when deletion was insufficient: receivers lost content silently and could not tell whether more existed. Replace the lossy section drop with ordered, independently verifiable shards in control_plane/handoff/handoff_fragments.py: - split_handoff_text returns the input verbatim (no envelope) when it fits; over-budget text becomes shards, and shard 0 keeps the existing project_agent_handoff position and field semantics; - each shard carries an envelope with a content-derived stable set id, i/total sequence, per-shard payload checksum, previous-shard hash chain, and full-content SHA-256; - reassemble_handoff_shards/restore_handoff_text validate every payload, set consistency, the hash chain, and full digest, failing explicitly on missing, out-of-order, duplicate/conflicting, foreign- set, or tampered shards; - HandoffShardCollector imports shards idempotently (same set/index/ checksum is a no-op), so regeneration and re-import never duplicate fragments; - fenced command blocks are never split open (strip-only close/reopen markers balance every shard), and over-long single lines wrap at safe whitespace boundaries with hard-cut fallback via continuation markers, restoring byte-for-byte; - build_review_packet exposes project_agent_handoff_fragments plus a compact handoff_fragment_manifest only when fragmented; full packet and handoff-only markdown render every shard, and handoff-only JSON passes the fragments through. Within-budget output is byte-identical. Tests cover split/restore, missing/out-of-order/tamper/digest errors, idempotent regeneration and import, over-long lines and fence splitting, a budget matrix, packet/handoff-only integration, and the within-budget compatibility shape. Signed-off-by: fengyin-solo <292015025+fengyin-solo@users.noreply.github.com> --- loopx/cli_commands/status.py | 21 +- .../handoff/handoff_fragments.py | 736 ++++++++++++++++++ loopx/review_packet.py | 92 ++- tests/test_handoff_fragments.py | 560 +++++++++++++ 4 files changed, 1384 insertions(+), 25 deletions(-) create mode 100644 loopx/control_plane/handoff/handoff_fragments.py create mode 100644 tests/test_handoff_fragments.py diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index d5f642e5bf..a0531104fb 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -28,7 +28,11 @@ from ..handoff_budget import build_handoff_interface_budget from ..presentation.renderers.status_markdown import render_status_markdown from ..quota import build_quota_should_run -from ..review_packet import build_review_packet, render_review_packet_markdown +from ..review_packet import ( + build_review_packet, + render_handoff_only_text, + render_review_packet_markdown, +) from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status from .status_registration import register_status_commands as register_status_commands @@ -136,6 +140,12 @@ def review_packet_handoff_only_payload(payload: dict[str, object]) -> dict[str, "within_budget": handoff_budget.get("within_budget"), } ) + fragment_texts = payload.get("project_agent_handoff_fragments") + if isinstance(fragment_texts, list) and fragment_texts: + result["project_agent_handoff_fragments"] = fragment_texts + result["handoff_fragment_manifest"] = payload.get( + "handoff_fragment_manifest" + ) return result @@ -891,7 +901,14 @@ def handle_review_packet_command( if args.handoff_only: payload = review_packet_handoff_only_payload(payload) if args.handoff_only and selected_format != "json" and payload.get("ok"): - print(str(payload.get("handoff_text") or "")) + fragment_texts = payload.get("project_agent_handoff_fragments") + if not isinstance(fragment_texts, list): + fragment_texts = [] + print( + render_handoff_only_text( + str(payload.get("handoff_text") or ""), fragment_texts + ) + ) else: print_payload(payload, selected_format, render_review_packet_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/control_plane/handoff/handoff_fragments.py b/loopx/control_plane/handoff/handoff_fragments.py new file mode 100644 index 0000000000..5411168c27 --- /dev/null +++ b/loopx/control_plane/handoff/handoff_fragments.py @@ -0,0 +1,736 @@ +"""Lossless fragmentation for interface-budgeted project-agent handoffs. + +A handoff text normally fits the ``project_agent_handoff`` interface budget +(16 lines / 1800 characters by default). When the prepared text still exceeds +the budget, this module splits it into ordered, independently verifiable +shards instead of dropping sections: + +* shard 0 keeps the existing ``project_agent_handoff`` field position and + semantics; continuation shards are delivered alongside it; +* every shard starts with a single envelope line carrying a stable content + set id, its sequence index/total, a per-shard payload checksum, a previous + shard hash chain, and the full-content digest; +* reassembly validates every shard payload, the sequence/hash chain, set + consistency and the full-content digest, failing explicitly on missing + shards, out-of-order delivery, duplicate/conflicting imports, or tampered + content; +* fenced code blocks are never torn open across shards (an unfinished fence + is closed and re-opened with strip-only transport markers), and over-long + single lines are wrapped with a continuation marker and rejoined exactly. + +When the input fits the budget, :func:`split_handoff_text` returns it verbatim +as the only element, with no envelope, so the common path stays byte-identical +to the legacy single-text handoff. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Any, Iterable + +from ...handoff_budget import handoff_budget_contract + + +SHARD_FORMAT_VERSION = "1" + +ENVELOPE_PREFIX = "$" +) + +# Transport-only markers. They never occur in prepared handoff content; the +# splitter rejects input that already contains them (fail closed). +LINE_CONTINUATION_MARKER = "@@loopx-handoff:cont@@" +FENCE_OPEN_MARKER = "# loopx-handoff:fence-open" +FENCE_RESUME_MARKER = "# loopx-handoff:fence-resume" + +# The envelope is one physical line of bounded length; reserving a fixed +# header budget keeps payload packing independent of index/total digit width. +ENVELOPE_CHAR_RESERVE = 200 + +MANIFEST_SCHEMA_VERSION = "project_agent_handoff_shard_v1" + + +class HandoffShardError(ValueError): + """A handoff shard failed envelope, integrity, order, or set validation.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class HandoffShard: + set_id: str + index: int + total: int + payload: str + chunk_hash: str + prev_hash: str | None + digest_hex: str + + +def _sha256_hex(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _budget_limits(max_lines: int | None, max_chars: int | None) -> tuple[int, int]: + contract = handoff_budget_contract() + line_limit = int(max_lines if max_lines is not None else contract["max_lines"]) + char_limit = int(max_chars if max_chars is not None else contract["max_chars"]) + if line_limit < 2 or char_limit <= ENVELOPE_CHAR_RESERVE + 32: + raise HandoffShardError( + "budget", + f"handoff shard budget too small: max_lines={line_limit}, max_chars={char_limit}", + ) + return line_limit, char_limit + + +def _envelope_line( + *, + set_id: str, + index: int, + total: int, + chunk_hash: str, + prev_hash: str | None, + digest_hex: str, +) -> str: + prev_field = prev_hash or "-" + return ( + f"{ENVELOPE_PREFIX}v={SHARD_FORMAT_VERSION} id={set_id} " + f"i={index} n={total} c={chunk_hash} p={prev_field} d={digest_hex}-->" + ) + + +def _assert_no_transport_markers(lines: list[str]) -> None: + for line in lines: + if line.startswith(ENVELOPE_PREFIX) or line.startswith(LINE_CONTINUATION_MARKER): + raise HandoffShardError( + "reserved_marker", + "handoff content contains a reserved loopx-handoff transport marker", + ) + if line in (FENCE_OPEN_MARKER, FENCE_RESUME_MARKER): + raise HandoffShardError( + "reserved_marker", + "handoff content contains a reserved loopx-handoff fence marker", + ) + + +@dataclass +class _FenceUnit: + opener: str + content: list[str] + + +def _parse_units(lines: list[str]) -> list[_FenceUnit | str]: + units: list[_FenceUnit | str] = [] + index = 0 + while index < len(lines): + line = lines[index] + if line.startswith("```"): + closer = next( + (candidate for candidate in lines[index + 1 :] if candidate == "```"), + None, + ) + if closer is None: + raise HandoffShardError( + "structure", + "cannot fragment handoff with an unterminated fenced code block", + ) + closer_index = lines.index("```", index + 1) + units.append(_FenceUnit(opener=line, content=lines[index + 1 : closer_index])) + index = closer_index + 1 + else: + units.append(line) + index += 1 + return units + + +class _ShardPacker: + def __init__(self, *, max_lines: int, max_chars: int) -> None: + self.payload_max_lines = max_lines - 1 + self.payload_max_chars = max_chars - ENVELOPE_CHAR_RESERVE + self.shards: list[list[str]] = [] + self.current: list[str] = [] + self.fence_active = False + self.fence_opener = "" + + def _payload_text(self) -> str: + return "\n".join(self.current) + + def _reserved_lines(self) -> int: + return 2 if self.fence_active else 0 + + def _reserved_chars(self) -> int: + # Flushing an unfinished fence appends FENCE_OPEN_MARKER + closer. + if not self.fence_active: + return 0 + return len(FENCE_OPEN_MARKER) + 1 + len("```") + + def _fits(self, physical_line: str) -> bool: + if len(self.current) + 1 + self._reserved_lines() > self.payload_max_lines: + return False + added = len(physical_line) + (1 if self.current else 0) + return ( + len(self._payload_text()) + added + self._reserved_chars() + <= self.payload_max_chars + ) + + def _flush(self) -> None: + if not self.current: + return + if self.fence_active: + self.current.append(FENCE_OPEN_MARKER) + self.current.append("```") + self.shards.append(self.current) + self.current = [] + + def _begin_resume(self) -> None: + self.current.append(self.fence_opener) + self.current.append(FENCE_RESUME_MARKER) + + def place(self, physical_line: str, *, in_fence: bool) -> None: + if self._fits(physical_line): + self.current.append(physical_line) + return + self._flush() + if in_fence and self.fence_active: + self._begin_resume() + if not self._fits(physical_line): + raise HandoffShardError( + "structure", + "handoff line cannot fit into a single shard payload budget", + ) + self.current.append(physical_line) + + def _prepare_width(self, *, continuation: bool) -> int: + """Flush when necessary and return the max content width for one line.""" + + prefix_len = len(LINE_CONTINUATION_MARKER) if continuation else 0 + + def room_now() -> int: + used = len(self._payload_text()) + (1 if self.current else 0) + return self.payload_max_chars - used - self._reserved_chars() - prefix_len + + def slot_now() -> bool: + return ( + len(self.current) + 1 + self._reserved_lines() <= self.payload_max_lines + ) + + if not slot_now() or room_now() <= 0: + self._flush() + if self.fence_active: + self._begin_resume() + width = room_now() + if width <= 0: + raise HandoffShardError( + "structure", + "handoff line cannot fit into a single shard payload budget", + ) + return width + + def emit_line(self, line: str) -> None: + if line == "": + self.place("", in_fence=self.fence_active) + return + rest = line + continuation = False + while rest: + width = self._prepare_width(continuation=continuation) + cut = min(width, len(rest)) + if cut < len(rest): + window = rest[:cut] + break_at = max(window.rfind(" "), window.rfind("\t")) + if break_at >= cut // 2: + cut = break_at + 1 + chunk = rest[:cut] + rest = rest[cut:] + physical = (LINE_CONTINUATION_MARKER + chunk) if continuation else chunk + self.place(physical, in_fence=self.fence_active) + continuation = True + + def emit_fence(self, fence: _FenceUnit) -> None: + # The opener must land in a shard that still has room for the fence + # close framing (open marker + closer) if the fence spills later. + used_chars = len(self._payload_text()) + (1 if self.current else 0) + framing_chars = len(FENCE_OPEN_MARKER) + 1 + len("```") + needs_lines = len(self.current) + 1 + 2 <= self.payload_max_lines + needs_chars = used_chars + len(fence.opener) + framing_chars <= self.payload_max_chars + if self.current and not (needs_lines and needs_chars): + self._flush() + self.current.append(fence.opener) + self.fence_active = True + self.fence_opener = fence.opener + for content_line in fence.content: + self.emit_line(content_line) + self.place("```", in_fence=True) + self.fence_active = False + + def finish(self) -> list[str]: + if self.fence_active: + raise HandoffShardError("structure", "unterminated fence while finishing shards") + self._flush() + return ["\n".join(lines) for lines in self.shards] + + +def split_handoff_text( + text: str, + *, + max_lines: int | None = None, + max_chars: int | None = None, +) -> list[str]: + """Fragment an over-budget handoff text into verifiable ordered shards. + + Returns ``[text]`` unchanged (no envelope) when the text already fits the + budget. Otherwise returns two or more shard texts; each shard fits the + same interface budget and shard 0 is the prefix of the original content. + """ + + if not isinstance(text, str): + raise HandoffShardError("input", "handoff text must be a string") + if text.endswith("\n"): + raise HandoffShardError("input", "handoff text must not end with a newline") + line_limit, char_limit = _budget_limits(max_lines, max_chars) + lines = text.split("\n") + if len(lines) <= line_limit and len(text) <= char_limit: + return [text] + + _assert_no_transport_markers(lines) + units = _parse_units(lines) + max_opener_len = max( + (len(unit.opener) for unit in units if isinstance(unit, _FenceUnit)), + default=0, + ) + if max_opener_len > ENVELOPE_CHAR_RESERVE: + raise HandoffShardError("structure", "fence opener cannot fit shard framing budget") + + packer = _ShardPacker(max_lines=line_limit, max_chars=char_limit) + for unit in units: + if isinstance(unit, _FenceUnit): + packer.emit_fence(unit) + else: + packer.emit_line(unit) + payloads = packer.finish() + if len(payloads) < 2: + raise HandoffShardError("structure", "overflow handoff produced no continuation shard") + + digest_hex = _sha256_hex(text) + set_id = digest_hex[:16] + total = len(payloads) + shards: list[str] = [] + prev_hash: str | None = None + for index, payload in enumerate(payloads): + chunk_hash = _sha256_hex(payload)[:16] + envelope = _envelope_line( + set_id=set_id, + index=index, + total=total, + chunk_hash=chunk_hash, + prev_hash=prev_hash, + digest_hex=digest_hex, + ) + shard = envelope + "\n" + payload + if len(shard.split("\n")) > line_limit or len(shard) > char_limit: + raise HandoffShardError( + "structure", + f"generated shard {index}/{total} exceeds the interface budget", + ) + if len(envelope) >= ENVELOPE_CHAR_RESERVE: + raise HandoffShardError("structure", "shard envelope exceeds reserved header budget") + shards.append(shard) + prev_hash = chunk_hash + + # Encoder self-check: the generated shards must reassemble byte-for-byte. + restored = reassemble_handoff_shards(shards) + if restored != text: + raise HandoffShardError("structure", "fragment encoder round-trip mismatch") + return shards + + +def parse_handoff_shard(text: str) -> HandoffShard: + """Parse one shard text and verify its per-shard payload checksum.""" + + if not isinstance(text, str) or not text: + raise HandoffShardError("envelope", "handoff shard must be a non-empty string") + if text.endswith("\n"): + raise HandoffShardError("envelope", "handoff shard must not end with a newline") + first_line, separator, payload = text.partition("\n") + if not separator or not payload: + raise HandoffShardError("envelope", "handoff shard needs an envelope line and payload") + match = ENVELOPE_RE.match(first_line) + if match is None: + raise HandoffShardError("envelope", "malformed handoff shard envelope") + if match.group("v") != SHARD_FORMAT_VERSION: + raise HandoffShardError("envelope", "unsupported handoff shard format version") + if _sha256_hex(payload)[:16] != match.group("c"): + raise HandoffShardError( + "integrity", + f"handoff shard {match.group('i')} payload checksum mismatch", + ) + digest_hex = match.group("d") + set_id = match.group("id") + if set_id != digest_hex[:16]: + raise HandoffShardError("envelope", "handoff shard set id does not bind content digest") + prev_field = match.group("p") + return HandoffShard( + set_id=set_id, + index=int(match.group("i")), + total=int(match.group("n")), + payload=payload, + chunk_hash=match.group("c"), + prev_hash=None if prev_field == "-" else prev_field, + digest_hex=digest_hex, + ) + + +def is_handoff_shard_text(text: str) -> bool: + try: + parse_handoff_shard(text) + except HandoffShardError: + return False + return True + + +def _decode_fence_groups(physical: list[str]) -> list[str]: + """Collapse transport fence wrapping, then join wrapped physical lines.""" + + logical: list[str] = [] + pending: list[str] | None = None + index = 0 + while index < len(physical): + line = physical[index] + if line.startswith("```"): + closer_index = next( + (candidate for candidate in range(index + 1, len(physical)) + if physical[candidate] == "```"), + None, + ) + if closer_index is None: + raise HandoffShardError("structure", "unterminated fence in shard payload") + content = physical[index + 1 : closer_index] + if any( + marker in content[1:-1] + for marker in (FENCE_OPEN_MARKER, FENCE_RESUME_MARKER) + ): + raise HandoffShardError("structure", "fence transport marker at invalid position") + starts_resume = bool(content) and content[0] == FENCE_RESUME_MARKER + ends_open = bool(content) and content[-1] == FENCE_OPEN_MARKER + inner = content[1:] if starts_resume else list(content) + inner = inner[:-1] if ends_open else inner + if starts_resume: + if pending is None: + raise HandoffShardError( + "structure", "fence resume marker without a preceding fence part" + ) + pending.extend(inner) + else: + if pending is not None: + raise HandoffShardError( + "structure", "fence continuation marker was not resumed" + ) + pending = list(inner) + if not ends_open: + logical.append(line) + logical.extend(pending) + logical.append("```") + pending = None + index = closer_index + 1 + continue + if line in (FENCE_OPEN_MARKER, FENCE_RESUME_MARKER): + raise HandoffShardError("structure", "fence transport marker outside fenced block") + if pending is not None: + raise HandoffShardError( + "structure", "non-fence line interleaved with a split fenced block" + ) + logical.append(line) + index += 1 + if pending is not None: + raise HandoffShardError("structure", "fence open marker without a resume shard") + + joined: list[str] = [] + for line in logical: + if line.startswith(LINE_CONTINUATION_MARKER): + if not joined or joined[-1].startswith("```"): + raise HandoffShardError( + "structure", "line continuation marker without a preceding line part" + ) + joined[-1] += line[len(LINE_CONTINUATION_MARKER) :] + else: + joined.append(line) + return joined + + +def _decode_shards(shards: list[HandoffShard]) -> str: + if not shards: + raise HandoffShardError("missing", "no handoff shards provided") + set_ids = {shard.set_id for shard in shards} + if len(set_ids) > 1: + raise HandoffShardError( + "set_mismatch", + f"handoff shards belong to multiple fragment sets: {sorted(set_ids)}", + ) + totals = {shard.total for shard in shards} + if len(totals) > 1: + raise HandoffShardError("set_mismatch", "handoff shards disagree on total count") + digest_values = {shard.digest_hex for shard in shards} + if len(digest_values) > 1: + raise HandoffShardError("set_mismatch", "handoff shards disagree on content digest") + total = shards[0].total + set_id = shards[0].set_id + + ordered = sorted(shards, key=lambda shard: shard.index) + indices = [shard.index for shard in ordered] + expected = list(range(total)) + if indices != expected: + missing = [index for index in expected if index not in indices] + extra = [index for index in indices if index < 0 or index >= total] + if missing: + raise HandoffShardError( + "missing", + f"handoff fragment set {set_id} missing shard index/indices {missing}", + ) + raise HandoffShardError( + "out_of_order", + f"handoff fragment set {set_id} has unexpected indices {extra}", + ) + + previous_hash: str | None = None + for shard in ordered: + if shard.prev_hash != previous_hash: + expected_label = previous_hash or "-" + raise HandoffShardError( + "out_of_order", + f"handoff shard {shard.index} hash chain breaks " + f"(expected p={expected_label}, got {shard.prev_hash or '-'})", + ) + previous_hash = shard.chunk_hash + + physical = "\n".join(shard.payload for shard in ordered).split("\n") + logical = _decode_fence_groups(physical) + restored = "\n".join(logical) + if _sha256_hex(restored) != shards[0].digest_hex: + raise HandoffShardError( + "digest", + f"reassembled handoff fragment set {set_id} failed the full-content digest check", + ) + return restored + + +def reassemble_handoff_shards( + shard_texts: Iterable[str], + *, + strict_order: bool = True, +) -> str: + """Parse, verify and concatenate handoff shards back to the original text. + + With ``strict_order=True`` (default) shard texts must arrive in sequence + order (0, 1, ..., n-1); out-of-order arrival raises + :class:`HandoffShardError`. Missing shards, duplicate shards, foreign-set + shards, checksum and hash-chain failures, and full-content digest + mismatches always raise. + """ + + parsed = [parse_handoff_shard(text) for text in shard_texts] + if parsed: + arrival = [shard.index for shard in parsed] + total = parsed[0].total + if any(index < 0 or index >= total for index in arrival): + raise HandoffShardError( + "unexpected_index", + f"handoff import has shard indices outside 0..{total - 1}: {arrival}", + ) + if len(set(arrival)) != len(arrival): + raise HandoffShardError( + "duplicate", + f"duplicate handoff shard index in import: {arrival}", + ) + if len(parsed) > total: + raise HandoffShardError( + "unexpected_index", + f"more handoff shards than declared total {total}: {arrival}", + ) + present = set(arrival) + missing = [index for index in range(total) if index not in present] + if missing: + raise HandoffShardError( + "missing", + f"handoff fragment set {parsed[0].set_id} missing shard index/indices {missing}", + ) + if strict_order and arrival != sorted(arrival): + raise HandoffShardError( + "out_of_order", + f"handoff shards arrived out of sequence: {arrival}", + ) + return _decode_shards(parsed) + + +def restore_handoff_text(value: str | Iterable[str]) -> str: + """Restore a handoff from either the legacy single text or shard form. + + A string without a shard envelope is returned verbatim (within-budget + handoffs carry no envelope). A string containing one or more embedded + shards is extracted and reassembled with full verification; an iterable + of shard texts is reassembled strictly. + """ + + if isinstance(value, str): + if any(ENVELOPE_RE.match(line) for line in value.split("\n")): + return reassemble_handoff_shards(extract_handoff_shards(value)) + return value + shard_texts = list(value) + if not shard_texts: + raise HandoffShardError("missing", "no handoff text or shards provided") + first_line = shard_texts[0].split("\n", 1)[0] + if ENVELOPE_RE.match(first_line): + return reassemble_handoff_shards(shard_texts) + if len(shard_texts) == 1: + return shard_texts[0] + raise HandoffShardError( + "envelope", + "multiple handoff parts provided without shard envelopes", + ) + + +class HandoffShardCollector: + """Accumulate imported shards idempotently and reassemble once complete. + + Importing the same shard text twice is a no-op (stable set id + index + + payload checksum). Re-importing the same index with different content + raises ``conflict``; shards from a different fragment set raise + ``set_mismatch``. + """ + + def __init__(self) -> None: + self._shards: dict[int, HandoffShard] = {} + self._arrival: list[int] = [] + self.set_id: str | None = None + self.total: int | None = None + self.digest_hex: str | None = None + + def ingest(self, shard: HandoffShard) -> HandoffShard: + if self.set_id is None: + if shard.index >= shard.total: + raise HandoffShardError( + "envelope", + f"handoff shard index {shard.index} >= total {shard.total}", + ) + self.set_id = shard.set_id + self.total = shard.total + self.digest_hex = shard.digest_hex + elif ( + shard.set_id != self.set_id + or shard.total != self.total + or shard.digest_hex != self.digest_hex + ): + raise HandoffShardError( + "set_mismatch", + f"shard set {shard.set_id} does not match collector set {self.set_id}", + ) + if shard.index < 0 or shard.index >= shard.total: + raise HandoffShardError( + "envelope", + f"handoff shard index {shard.index} outside total {shard.total}", + ) + existing = self._shards.get(shard.index) + if existing is not None: + if existing.chunk_hash != shard.chunk_hash: + raise HandoffShardError( + "conflict", + f"handoff shard {shard.index} re-imported with different content", + ) + return shard + self._shards[shard.index] = shard + self._arrival.append(shard.index) + return shard + + def ingest_text(self, text: str) -> HandoffShard: + return self.ingest(parse_handoff_shard(text)) + + @property + def received_indices(self) -> list[int]: + return sorted(self._shards) + + @property + def arrival_indices(self) -> list[int]: + return list(self._arrival) + + @property + def missing_indices(self) -> list[int]: + if self.total is None: + return [] + return [index for index in range(self.total) if index not in self._shards] + + @property + def complete(self) -> bool: + return self.total is not None and not self.missing_indices + + def reassemble(self) -> str: + if self.total is None: + raise HandoffShardError("missing", "no handoff shards imported") + ordered = [self._shards[index] for index in sorted(self._shards)] + return _decode_shards(ordered) + + +def extract_handoff_shards(text: str) -> list[str]: + """Extract shard blocks embedded in a larger text (e.g. a full packet). + + Each shard spans from its envelope line to the line before the next + envelope; surrounding relay framing is trimmed using the payload + checksum, so framing text cannot contaminate reassembly. + """ + + lines = text.split("\n") + starts = [index for index, line in enumerate(lines) if ENVELOPE_RE.match(line)] + if not starts: + raise HandoffShardError("envelope", "no handoff shard envelope found") + extracted: list[str] = [] + for position, start in enumerate(starts): + limit = starts[position + 1] if position + 1 < len(starts) else len(lines) + candidate: str | None = None + for end in range(limit, start, -1): + probe = "\n".join(lines[start:end]) + try: + parse_handoff_shard(probe) + except HandoffShardError: + continue + candidate = probe + break + if candidate is None: + raise HandoffShardError( + "integrity", + f"could not verify handoff shard starting at line {start + 1}", + ) + extracted.append(candidate) + return extracted + + +def build_handoff_shard_manifest(original_text: str, shard_texts: list[str]) -> dict[str, Any]: + """Structured projection of a fragmented handoff for JSON surfaces.""" + + parsed = [parse_handoff_shard(text) for text in shard_texts] + if len(parsed) < 2: + raise HandoffShardError("envelope", "manifest requires a fragmented handoff") + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "set_id": parsed[0].set_id, + "total": parsed[0].total, + "original_line_count": len(original_text.split("\n")), + "original_char_count": len(original_text), + "shards": [ + { + "index": shard.index, + "line_count": len(shard_text.split("\n")), + "char_count": len(shard_text), + } + for shard, shard_text in zip(parsed, shard_texts) + ], + } diff --git a/loopx/review_packet.py b/loopx/review_packet.py index 3b7fa4f529..f5c088ca20 100644 --- a/loopx/review_packet.py +++ b/loopx/review_packet.py @@ -20,6 +20,10 @@ handoff_delivery_contract, handoff_delivery_contract_summary, ) +from .control_plane.handoff.handoff_fragments import ( + build_handoff_shard_manifest, + split_handoff_text, +) from .handoff_budget import build_handoff_interface_budget @@ -73,30 +77,41 @@ def compact_last_bash_command_block(text: str) -> str: return "\n".join([*lines[: start + 1], compact_command, *lines[end:]]) -def fit_project_agent_handoff_budget(text: str) -> str: +def normalize_project_agent_handoff_text(text: str) -> str: + """Apply the lossless bash-block compaction normalization when oversized. + + Unlike the former prefix-dropping fit pass, this never removes content: + if the normalized text still exceeds the interface budget, the caller + fragments it into verifiable continuation shards instead. + """ + if build_handoff_interface_budget(text)["within_budget"]: return text + return compact_last_bash_command_block(text) - candidate = compact_last_bash_command_block(text) - if build_handoff_interface_budget(candidate)["within_budget"]: - return candidate - lines = candidate.splitlines() - for prefixes in ( - ("Agent 待办候选 ",), - ("材料上下文:",), - ("交付观测:",), - ("交付合同:",), - ): - lines = [ - line - for line in lines - if not any(line.startswith(prefix) for prefix in prefixes) - ] - candidate = "\n".join(lines) - if build_handoff_interface_budget(candidate)["within_budget"]: - return candidate - return candidate +def prepare_project_agent_handoff_shards(text: str) -> list[str]: + """Return the handoff as one in-budget text or multiple verified shards.""" + + normalized = normalize_project_agent_handoff_text(text) + return split_handoff_text(normalized) + + +def handoff_shard_section_header(index: int, total: int) -> str: + return ( + f"【给项目 Agent · 交接分片 {index + 1}/{total}:整段转发,收齐全部 {total} 片" + "并按序号校验通过后再执行;缺片、乱序或内容改动都会明确报错】" + ) + + +def render_handoff_only_text(project_agent_handoff: str, continuation_shards: list[str]) -> str: + """Render the handoff-only relay text: shard 0 plus continuation shards.""" + + parts = [project_agent_handoff] + total = len(continuation_shards) + 1 + for offset, shard in enumerate(continuation_shards, start=1): + parts.append(handoff_shard_section_header(offset, total) + "\n" + shard) + return "\n\n".join(parts) def build_status_command(status_payload: dict[str, Any]) -> str: @@ -610,7 +625,7 @@ def project_agent_section( "", command_block(command), ] - return fit_project_agent_handoff_budget("\n".join(line for line in lines if line)) + return normalize_project_agent_handoff_text("\n".join(line for line in lines if line)) def build_review_packet( @@ -678,7 +693,7 @@ def build_review_packet( reply = "转发下方【给项目 Agent】即可。" boundary = "这只是执行已批准的只读/dry-run agent_command;如需写入或更高权限,项目 Agent 必须再次停下。" owner_blocker_text = user_todo_text if kind == "focus_wait" else None - agent_text = project_agent_section( + prepared_agent_text = project_agent_section( kind, command, goal_id, @@ -693,6 +708,15 @@ def build_review_packet( approved_operator_gate=approved_handoff, connected_delivery=delivery_handoff, ) + handoff_shards = split_handoff_text(prepared_agent_text) + agent_text = handoff_shards[0] + continuation_shards = handoff_shards[1:] + fragmented_handoff = bool(continuation_shards) + handoff_fragment_manifest = ( + build_handoff_shard_manifest(prepared_agent_text, handoff_shards) + if fragmented_handoff + else None + ) handoff_interface_budget = build_handoff_interface_budget(agent_text) type_label = { "reward": "Reward", @@ -736,11 +760,29 @@ def build_review_packet( "", "【给项目 Agent】", agent_text, + ] + ) + if fragmented_handoff: + total_shards = len(handoff_shards) + lines.append( + f"交接分片提示:本段为第 1/{total_shards} 片(信封在首行 HTML 注释中);" + f"请收齐并按序号校验全部 {total_shards} 片后再执行,缺片、乱序或内容改动都会报错。" + ) + for shard_index, shard_text in enumerate(continuation_shards, start=1): + lines.extend( + [ + "", + handoff_shard_section_header(shard_index, total_shards), + shard_text, + ] + ) + lines.extend( + [ "", "回报:用中文说明 changed files、validation 和 next safe action。", ] ) - return { + result = { "ok": True, "goal_id": goal_id, "kind": effective_kind, @@ -772,6 +814,10 @@ def build_review_packet( "project_asset_source": asset_source, "packet": "\n".join(line for line in lines if line), } + if fragmented_handoff: + result["project_agent_handoff_fragments"] = continuation_shards + result["handoff_fragment_manifest"] = handoff_fragment_manifest + return result def render_review_packet_markdown(payload: dict[str, Any]) -> str: diff --git a/tests/test_handoff_fragments.py b/tests/test_handoff_fragments.py new file mode 100644 index 0000000000..929b74484e --- /dev/null +++ b/tests/test_handoff_fragments.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import pytest + +from loopx.cli_commands.status import review_packet_handoff_only_payload +from loopx.control_plane.handoff.handoff_fragments import ( + FENCE_OPEN_MARKER, + FENCE_RESUME_MARKER, + LINE_CONTINUATION_MARKER, + HandoffShardCollector, + HandoffShardError, + build_handoff_shard_manifest, + extract_handoff_shards, + is_handoff_shard_text, + parse_handoff_shard, + reassemble_handoff_shards, + restore_handoff_text, + split_handoff_text, +) +from loopx.review_packet import render_handoff_only_text + + +# --------------------------------------------------------------------------- +# Within-budget compatibility +# --------------------------------------------------------------------------- + + +def test_within_budget_text_returned_verbatim() -> None: + text = "目标校验:本段只适用于 goal_id=`g`\n上下文规则:保持最小当前指令" + shards = split_handoff_text(text) + + assert shards == [text] + assert is_handoff_shard_text(text) is False + assert split_handoff_text(text) == [text] + + +def test_small_custom_budget_still_identity_when_fit() -> None: + text = "short\ntext" + assert split_handoff_text(text, max_lines=16, max_chars=400) == [text] + + +# --------------------------------------------------------------------------- +# Split and restore +# --------------------------------------------------------------------------- + + +def _line_overflow_text(lines: int = 60) -> str: + return "\n".join(f"第 {index} 行:" + "甲乙丙丁" * 12 for index in range(lines)) + + +def test_line_overflow_splits_into_verifiable_ordered_shards() -> None: + text = _line_overflow_text() + shards = split_handoff_text(text) + + assert len(shards) >= 2 + parsed = [parse_handoff_shard(shard) for shard in shards] + assert [shard.index for shard in parsed] == list(range(len(shards))) + assert all(shard.total == len(shards) for shard in parsed) + assert len({shard.set_id for shard in parsed}) == 1 + assert len({shard.digest_hex for shard in parsed}) == 1 + + prev_hash = None + for shard in parsed: + assert shard.prev_hash == prev_hash + prev_hash = shard.chunk_hash + + assert all(len(shard.split("\n")) <= 16 for shard in shards) + assert all(len(shard) <= 1800 for shard in shards) + assert shards[0].split("\n", 1)[1].startswith("第 0 行:") + + assert reassemble_handoff_shards(shards) == text + + +def test_shard_zero_keeps_prefix_semantics() -> None: + text = ( + "目标校验:本段只适用于 goal_id=`g`\n" + "上下文规则:最小指令\n" + + _line_overflow_text(28) + ) + shards = split_handoff_text(text) + assert len(shards) >= 2 + first_payload = shards[0].split("\n", 1)[1] + assert first_payload.startswith("目标校验:本段只适用于 goal_id=`g`") + assert reassemble_handoff_shards(shards) == text + + +def test_lossless_restore_preserves_legacy_dropped_sections() -> None: + text = "\n".join( + [ + "目标校验:本段只适用于 goal_id=`g`", + "Agent 待办:推进当前 bounded segment", + "Agent 待办候选 2:观察 sibling controller", + "Agent 待办候选 3:保持 canary 可观测", + "材料上下文:authority/material: topics=2, materials=4", + "交付观测:post_handoff_run=impl, scale=implementation", + "交付合同:下一轮回到 active state P0/P1 outcome", + "转发条件:只有用户同意 safe local path 后才转发", + "执行边界:只读或 dry-run", + "停止条件:需要写入时停下等授权", + "```bash", + "loopx status", + "```", + *[f"补充说明行 {index}:" + "内容" * 30 for index in range(20)], + ] + ) + shards = split_handoff_text(text) + assert len(shards) >= 2 + restored = reassemble_handoff_shards(shards) + assert restored == text + for prefix in ( + "Agent 待办候选 2:", + "Agent 待办候选 3:", + "材料上下文:", + "交付观测:", + "交付合同:", + ): + assert prefix in restored + + +# --------------------------------------------------------------------------- +# Missing / out-of-order / tamper errors +# --------------------------------------------------------------------------- + + +def test_missing_shard_raises_named_error() -> None: + shards = split_handoff_text(_line_overflow_text()) + assert len(shards) >= 3 + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards([shards[0], *shards[2:]]) + assert excinfo.value.code == "missing" + assert "1" in str(excinfo.value) + + +def test_shuffled_delivery_raises_out_of_order() -> None: + shards = split_handoff_text(_line_overflow_text()) + reordered = [shards[0], shards[2], shards[1], *shards[3:]] + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards(reordered) + assert excinfo.value.code == "out_of_order" + + +def test_non_strict_order_restores_after_sequence_validation() -> None: + shards = split_handoff_text(_line_overflow_text()) + text = reassemble_handoff_shards(list(reversed(shards)), strict_order=False) + assert text == _line_overflow_text() + + +def test_payload_tampering_raises_integrity_error() -> None: + shards = split_handoff_text(_line_overflow_text()) + tampered = shards[1].replace("甲乙", "丙丁", 1) + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards([shards[0], tampered, *shards[2:]]) + assert excinfo.value.code == "integrity" + + +def test_broken_hash_chain_raises_out_of_order() -> None: + from loopx.control_plane.handoff import handoff_fragments as hf + + shards = split_handoff_text(_line_overflow_text()) + parsed = [parse_handoff_shard(shard) for shard in shards] + rogue_payload = parsed[2].payload + rogue = hf._envelope_line( + set_id=parsed[2].set_id, + index=2, + total=parsed[2].total, + chunk_hash=parsed[2].chunk_hash, + prev_hash="0" * 16, + digest_hex=parsed[2].digest_hex, + ) + "\n" + rogue_payload + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards([shards[0], shards[1], rogue, *shards[3:]]) + assert excinfo.value.code == "out_of_order" + + +def test_full_content_digest_mismatch_raises() -> None: + from loopx.control_plane.handoff import handoff_fragments as hf + + text = _line_overflow_text() + shards = split_handoff_text(text) + foreign_digest = hf._sha256_hex("something else") + forged = [] + for shard_text in shards: + parsed = parse_handoff_shard(shard_text) + envelope = hf._envelope_line( + set_id=foreign_digest[:16], + index=parsed.index, + total=parsed.total, + chunk_hash=parsed.chunk_hash, + prev_hash=parsed.prev_hash, + digest_hex=foreign_digest, + ) + forged.append(envelope + "\n" + parsed.payload) + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards(forged) + assert excinfo.value.code == "digest" + + +def test_duplicate_shard_in_batch_raises() -> None: + shards = split_handoff_text(_line_overflow_text()) + with pytest.raises(HandoffShardError) as excinfo: + reassemble_handoff_shards([*shards, shards[-1]]) + assert excinfo.value.code == "duplicate" + + +def test_collector_incomplete_reassemble_raises_missing() -> None: + shards = split_handoff_text(_line_overflow_text()) + collector = HandoffShardCollector() + collector.ingest_text(shards[0]) + assert collector.complete is False + assert collector.missing_indices == list(range(1, len(shards))) + with pytest.raises(HandoffShardError) as excinfo: + collector.reassemble() + assert excinfo.value.code == "missing" + + +# --------------------------------------------------------------------------- +# Idempotent regeneration and import +# --------------------------------------------------------------------------- + + +def test_regeneration_is_byte_stable() -> None: + text = _line_overflow_text() + first = split_handoff_text(text) + second = split_handoff_text(text) + assert first == second + assert parse_handoff_shard(first[0]).set_id == parse_handoff_shard(second[0]).set_id + + +def test_collector_repeated_import_is_idempotent() -> None: + text = _line_overflow_text() + shards = split_handoff_text(text) + collector = HandoffShardCollector() + for shard in shards: + collector.ingest_text(shard) + for shard in shards: + collector.ingest_text(shard) + assert collector.arrival_indices == list(range(len(shards))) + assert collector.received_indices == list(range(len(shards))) + assert collector.complete is True + assert collector.reassemble() == text + + +def test_collector_rejects_foreign_set() -> None: + shards = split_handoff_text(_line_overflow_text()) + other = split_handoff_text("另一条交接:" + "内容" * 1500) + collector = HandoffShardCollector() + collector.ingest_text(shards[0]) + with pytest.raises(HandoffShardError) as excinfo: + collector.ingest_text(other[1]) + assert excinfo.value.code == "set_mismatch" + + +# --------------------------------------------------------------------------- +# Over-long lines and fenced blocks +# --------------------------------------------------------------------------- + + +def test_long_spaced_line_splits_at_safe_boundaries_and_restores() -> None: + text = " ".join(f"token{index}" for index in range(500)) + shards = split_handoff_text(text, max_chars=400) + assert len(shards) >= 2 + restored = reassemble_handoff_shards(shards) + assert restored == text + + payload_lines = "\n".join(shard.split("\n", 1)[1] for shard in shards) + assert LINE_CONTINUATION_MARKER in payload_lines + continuation_chunks = [ + line for line in payload_lines.split("\n") if line.startswith(LINE_CONTINUATION_MARKER) + ] + assert continuation_chunks + + +def test_long_unbreakable_token_hard_cuts_and_restores() -> None: + text = "a" * 5000 + shards = split_handoff_text(text) + assert len(shards) >= 3 + assert reassemble_handoff_shards(shards) == text + assert all(len(shard) <= 1800 for shard in shards) + + +def test_unicode_long_line_restores_code_points_exactly() -> None: + text = "中文超长行:" + "甲乙丙丁" * 800 + shards = split_handoff_text(text) + assert reassemble_handoff_shards(shards) == text + + +def test_oversized_fence_keeps_each_shard_balanced_and_restores() -> None: + command = "loopx " + " ".join(f"--opt{index}=value{index}" for index in range(120)) + text = "目标校验:g\n停止条件:停下等授权\n```bash\n" + command + "\n```" + shards = split_handoff_text(text, max_chars=400) + assert len(shards) >= 2 + + for shard in shards: + assert shard.count("```") % 2 == 0, shard + assert len(shard.split("\n")) <= 16 + assert len(shard) <= 400 + + payloads = "\n".join(shard.split("\n", 1)[1] for shard in shards) + assert FENCE_OPEN_MARKER in payloads + assert FENCE_RESUME_MARKER in payloads + + restored = reassemble_handoff_shards(shards) + assert restored == text + assert restored.count("```bash") == 1 + assert FENCE_OPEN_MARKER not in restored + assert FENCE_RESUME_MARKER not in restored + assert command in restored + + +def test_oversized_multiline_fence_restores_exactly() -> None: + command = " \\\n ".join(f"--part{index}=value{index}" for index in range(200)) + text = "目标校验:g\n```bash\n" + command + "\n```" + shards = split_handoff_text(text, max_chars=400) + assert len(shards) >= 2 + for shard in shards: + assert shard.count("```") % 2 == 0, shard + restored = reassemble_handoff_shards(shards) + assert restored == text + assert restored.count("```bash") == 1 + + +def test_single_huge_command_line_inside_fence_restores() -> None: + command = "x" * 4000 + text = "目标校验:g\n```bash\n" + command + "\n```" + shards = split_handoff_text(text, max_chars=400) + assert len(shards) >= 2 + for shard in shards: + assert shard.count("```") % 2 == 0, shard + restored = reassemble_handoff_shards(shards) + assert restored == text + assert f"```bash\n{command}\n```" in restored + + +def test_fence_starting_near_line_limit_stays_balanced() -> None: + text = "\n".join( + [f"plain line {index} " + "x" * 20 for index in range(10)] + + ["```bash", "loopx status --goal-id g", "```"] + ) + shards = split_handoff_text(text, max_lines=8, max_chars=1800) + assert len(shards) >= 2 + for shard in shards: + assert len(shard.split("\n")) <= 8 + assert shard.count("```") % 2 == 0 + assert reassemble_handoff_shards(shards) == text + + +@pytest.mark.parametrize( + ("max_lines", "max_chars"), + [ + (8, 300), + (8, 600), + (8, 1800), + (12, 300), + (12, 1800), + (16, 300), + (16, 1800), + ], +) +def test_shard_budget_matrix_round_trips(max_lines: int, max_chars: int) -> None: + texts = [ + "\n".join(f"line{index} " + "y" * 5 for index in range(40)), + "word " * 800, + "z" * 6000, + "目标:g\n```bash\n" + + " ".join(f"--k{index}=v{index}" for index in range(200)) + + "\n```", + "g\n```bash\n" + + " \\\n ".join(f"--p{index}=v{index}" for index in range(300)) + + "\n```", + ("中文 " * 400) + "\n```bash\n" + "a" * 3000 + "\n```\n尾行:停", + ] + for text in texts: + shards = split_handoff_text( + text, max_lines=max_lines, max_chars=max_chars + ) + for shard in shards: + assert len(shard.split("\n")) <= max_lines + assert len(shard) <= max_chars + assert shard.count("```") % 2 == 0 + assert restore_handoff_text(shards) == text + + +# --------------------------------------------------------------------------- +# Legacy single-text compatibility at the receiver +# --------------------------------------------------------------------------- + + +def test_restore_accepts_unfragmented_plain_text() -> None: + text = "目标校验:本段只适用于 goal_id=`g`\n停止条件:停下" + assert restore_handoff_text(text) is text + assert restore_handoff_text([text]) == text + + +def test_restore_rejects_envelope_free_multi_part() -> None: + with pytest.raises(HandoffShardError) as excinfo: + restore_handoff_text(["plain-a", "plain-b"]) + assert excinfo.value.code == "envelope" + + +def test_restore_restores_fragment_list_and_embedded_blob() -> None: + text = _line_overflow_text() + shards = split_handoff_text(text) + assert restore_handoff_text(shards) == text + blob = "\n".join(["【给项目 Agent】", *shards, "回报:done"]) + assert restore_handoff_text(blob) == text + + +# --------------------------------------------------------------------------- +# Extraction from relay framing +# --------------------------------------------------------------------------- + + +def test_extract_shards_from_full_packet_framing() -> None: + text = _line_overflow_text() + shards = split_handoff_text(text) + framing = ["【给项目 Agent】", shards[0]] + for index, shard in enumerate(shards[1:], start=2): + framing.extend([f"【给项目 Agent · 交接分片 {index}/{len(shards)}】", shard]) + framing.append("回报:changed files / validation / next safe action") + blob = "\n".join(framing) + + extracted = extract_handoff_shards(blob) + assert len(extracted) == len(shards) + assert reassemble_handoff_shards(extracted) == text + + +def test_extract_without_envelope_raises() -> None: + with pytest.raises(HandoffShardError) as excinfo: + extract_handoff_shards("普通文本,没有分片\n目标校验:g") + assert excinfo.value.code == "envelope" + + +# --------------------------------------------------------------------------- +# Review Packet integration +# --------------------------------------------------------------------------- + + +def _giant_command_payload(goal_id: str) -> dict: + huge_command = "loopx " + " ".join( + f"--flag-{index}=value-{index}" for index in range(400) + ) + return { + "registry": "./fixtures/registry.json", + "runtime_root": "./fixtures/runtime", + "attention_queue": { + "items": [ + { + "goal_id": goal_id, + "status": "operator_gate_approved", + "waiting_on": "codex", + "severity": "action", + "recommended_action": "run the approved handoff now", + "agent_command": huge_command, + "project_asset": { + "owner": "codex", + "gate": "none", + "next_action": "run the approved handoff now", + "stop_condition": "stop if the command needs write control", + "agent_todos": {"next": "Run the approved dry-run."}, + }, + "source": "latest_run", + } + ] + }, + "run_history": { + "goals": [ + {"id": goal_id, "status": "operator_gate_approved", "latest_runs": []} + ] + }, + } + + +def test_review_packet_fragments_oversized_handoff_losslessly() -> None: + from loopx.review_packet import build_review_packet + + goal_id = "giant-command-handoff" + payload = build_review_packet(_giant_command_payload(goal_id), goal_id=goal_id) + assert payload["ok"] is True + + shard0 = payload["project_agent_handoff"] + fragments = payload["project_agent_handoff_fragments"] + manifest = payload["handoff_fragment_manifest"] + all_shards = [shard0, *fragments] + + assert len(fragments) >= 1 + assert manifest["schema_version"] == "project_agent_handoff_shard_v1" + assert manifest["total"] == len(all_shards) + assert len(manifest["set_id"]) == 16 + assert manifest["original_char_count"] >= len(shard0) + + parsed = [parse_handoff_shard(shard) for shard in all_shards] + assert all(shard.set_id == manifest["set_id"] for shard in parsed) + for shard_text, shard in zip(all_shards, parsed): + assert len(shard_text.split("\n")) <= 16 + assert len(shard_text) <= 1800 + entry = manifest["shards"][shard.index] + assert entry["line_count"] == len(shard_text.split("\n")) + assert entry["char_count"] == len(shard_text) + + first_payload = shard0.split("\n", 1)[1] + assert first_payload.startswith("目标校验:本段只适用于 goal_id=`giant-command-handoff`") + restored = reassemble_handoff_shards(all_shards) + assert restored.startswith("目标校验:本段只适用于 goal_id=`giant-command-handoff`") + assert restored.count("```bash") == 1 + assert "--flag-0=value-0" in restored + assert "--flag-399=value-399" in restored + + extracted = extract_handoff_shards(payload["packet"]) + assert reassemble_handoff_shards(extracted) == restored + assert "交接分片提示:本段为第 1/" in payload["packet"] + + handoff_only = review_packet_handoff_only_payload(payload) + assert handoff_only["project_agent_handoff_fragments"] == fragments + assert handoff_only["handoff_fragment_manifest"] == manifest + assert handoff_only["handoff_text"] == shard0 + + markdown = render_handoff_only_text( + handoff_only["handoff_text"], handoff_only["project_agent_handoff_fragments"] + ) + assert reassemble_handoff_shards(extract_handoff_shards(markdown)) == restored + + assert build_handoff_shard_manifest(restored, all_shards)["set_id"] == manifest["set_id"] + + +def test_review_packet_within_budget_shape_is_unchanged() -> None: + from loopx.review_packet import build_review_packet + + payload = build_review_packet( + { + "attention_queue": {"items": []}, + "run_history": { + "goals": [{"id": "plain-goal", "status": "active", "latest_runs": []}] + }, + }, + goal_id="plain-goal", + ) + handoff = payload["project_agent_handoff"] + assert handoff.startswith("目标校验:本段只适用于 goal_id=`plain-goal`") + assert "project_agent_handoff_fragments" not in payload + assert "handoff_fragment_manifest" not in payload + assert "` envelope line carrying the content-derived set + id, the `i`/`n` sequence, a per-shard payload checksum, the previous-shard + hash chain, and the full-content SHA-256. Receivers concatenate by sequence + only after every payload checksum, the hash chain, set consistency, and the + full-content digest verify; missing shards, out-of-order delivery, + duplicate/conflicting imports, or altered content fail with explicit errors. + The set id and every shard are deterministic from the handoff content, so + regenerating the same handoff yields identical shards and re-importing a + shard is a no-op. Fenced command blocks are never torn across a shard + boundary (an unfinished fence is closed and re-opened with strip-only + transport markers), and over-long single lines use continuation markers, + so reassembly restores the original handoff byte-for-byte. A handoff that + fits the budget carries no envelope and stays byte-identical to the legacy + single-text output; - `handoff_delivery_contract` is optional structured guidance derived from the current `handoff_readiness` plus `project_asset.execution_profile`, not a target-specific hack. When repeated small-scale follow-through reaches the From 50e1d7f1ae73cc2e7206e68e71c64c71ea6f2527 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:47:11 +0800 Subject: [PATCH 3/5] refactor(handoff): separate context assembly and ship strict content restore Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/review-packet-cli-smoke.py | 30 + .../review_packet_cli_fixtures.py | 5 +- loopx/cli_commands/handoff_restore.py | 129 ++++ loopx/cli_commands/status.py | 20 +- loopx/cli_commands/status_registration.py | 3 +- loopx/cli_commands/todo_continuation.py | 22 +- .../handoff/handoff_fragments.py | 281 +++----- .../handoff/project_agent_context.py | 592 ++++++++++++++++ loopx/review_packet.py | 641 ++---------------- tests/test_handoff_fragments.py | 58 +- tests/test_handoff_receiver.py | 265 ++++++++ 11 files changed, 1236 insertions(+), 810 deletions(-) create mode 100644 loopx/cli_commands/handoff_restore.py create mode 100644 loopx/control_plane/handoff/project_agent_context.py create mode 100644 tests/test_handoff_receiver.py diff --git a/examples/control_plane/review-packet-cli-smoke.py b/examples/control_plane/review-packet-cli-smoke.py index 4200aacc0f..82f2a44327 100644 --- a/examples/control_plane/review-packet-cli-smoke.py +++ b/examples/control_plane/review-packet-cli-smoke.py @@ -750,6 +750,35 @@ def assert_dense_handoff_stays_within_budget() -> None: assert_handoff_only_top_level_budget(handoff_only, "dense handoff-only json") +def assert_overflow_cli_roundtrip() -> None: + """The real producer preserves constraints and the receiver cannot execute them.""" + with tempfile.TemporaryDirectory(prefix="loopx-handoff-overflow-") as tmp: + root = Path(tmp) + registry = write_planned_registry(root) + mark_owner_review_todo_done(root) + # An independent synthetic approved command makes the actual CLI overflow. + command = "printf '%s' '" + "preserve-source-evidence " * 100 + "RETURN-VALIDATION'" + append_operator_gate_approval_fixture(root, command=command) + for handoff_only in (False, True): + flags = ["--handoff-only"] if handoff_only else [] + args = ["review-packet", "--goal-id", GOAL_ID, "--scan-root", str(root / "project"), *flags] + payload = json.loads(run_cli(root, registry, "--format", "json", *args).stdout) + complete = payload["project_agent_handoff"] + assert payload["handoff_interface_budget"]["within_budget"] is False + assert "authority/material: topics=2, materials=4" in complete + assert "Run the read-only map dry-run after owner todo resolution." in complete + assert "生产动作、更高权限" in complete + assert command in complete + for input_format in ("json", "markdown"): + source = root / f"received.{input_format}" + source.write_text(run_cli(root, registry, "--format", input_format, *args).stdout) + result = run_cli(root, registry, "--format", "json", "handoff", "restore", + "--input", str(source), "--input-format", input_format) + restored = json.loads(result.stdout) + assert restored == {"ok": True, "handoff_text": complete}, restored + assert "【人只需判断】" not in restored["handoff_text"] + + def main() -> int: help_result = subprocess.run( [sys.executable, "-m", "loopx.cli", "review-packet", "--help"], @@ -762,6 +791,7 @@ def main() -> int: assert "JSON output returns a minimized handoff payload" in compact_help, help_result.stdout assert "JSON output keeps the full payload" not in compact_help, help_result.stdout + assert_overflow_cli_roundtrip() assert_status_data_contract_documents_handoff_budget() assert_attention_queue_drives_approved_handoff_over_stale_history() assert_project_agent_handoff_prioritizes_advancement_todos() diff --git a/examples/control_plane/review_packet_cli_fixtures.py b/examples/control_plane/review_packet_cli_fixtures.py index 7246cbfbb7..b5edb2a7cf 100644 --- a/examples/control_plane/review_packet_cli_fixtures.py +++ b/examples/control_plane/review_packet_cli_fixtures.py @@ -6,7 +6,6 @@ import re import subprocess import sys -import tempfile from collections.abc import Iterator from datetime import datetime, timezone from pathlib import Path @@ -197,7 +196,7 @@ def approved_command_with_local_paths(root: Path) -> str: ) -def append_operator_gate_approval_fixture(root: Path) -> None: +def append_operator_gate_approval_fixture(root: Path, *, command: str | None = None) -> None: run_dir = root / "runtime" / "goals" / GOAL_ID / "runs" run_dir.mkdir(parents=True, exist_ok=True) generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() @@ -216,7 +215,7 @@ def append_operator_gate_approval_fixture(root: Path) -> None: "decision": "approve", "operator_question": f"是否同意 `{GOAL_ID}` 先执行 read-only map opt-in?", "reason_summary": f"同意 {GOAL_ID} 先做 read-only map dry-run,不授权写入或生产动作", - "agent_command": approved_command_with_local_paths(root), + "agent_command": command or approved_command_with_local_paths(root), }, } json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/loopx/cli_commands/handoff_restore.py b/loopx/cli_commands/handoff_restore.py new file mode 100644 index 0000000000..bd97b19b3f --- /dev/null +++ b/loopx/cli_commands/handoff_restore.py @@ -0,0 +1,129 @@ +"""Content-only receive adapter. Never opens a registry or executes commands.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from ..control_plane.handoff.handoff_fragments import ( + ENVELOPE_PREFIX, + HandoffShardError, + build_handoff_shard_manifest, + reassemble_handoff_shards, + restore_handoff_text, +) + + +def restore_handoff_input(text: str, *, input_format: str) -> str: + if input_format == "markdown": + if not text.strip(): + raise HandoffShardError("missing", "empty handoff input") + if ENVELOPE_PREFIX in text: + return restore_handoff_text(text) + if text.startswith("【LoopX Review Packet】"): + raise HandoffShardError( + "input", + "use full packet JSON or handoff-only Markdown for an unfragmented packet", + ) + # Unframed input is content, not integrity-verified transport. + return text + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise HandoffShardError( + "input", "invalid JSON; select --input-format markdown for raw Markdown" + ) from exc + if not isinstance(value, dict) or value.get("ok") is not True: + raise HandoffShardError( + "input", "expected a successful review-packet JSON object" + ) + shards = value.get("project_agent_handoff_fragments") + manifest = value.get("handoff_fragment_manifest") + fields = [ + value[key] for key in ("project_agent_handoff", "handoff_text") if key in value + ] + if not fields or any(not isinstance(field, str) or not field for field in fields): + raise HandoffShardError("input", "missing complete handoff text field") + if shards is not None or manifest is not None: + if not isinstance(shards, list) or not shards: + raise HandoffShardError( + "missing", + "missing handoff fragments; obtain the complete producer output", + ) + if any(not isinstance(shard, str) for shard in shards): + raise HandoffShardError("input", "handoff fragments must be strings") + restored = reassemble_handoff_shards(shards) + if not isinstance(manifest, dict) or manifest != build_handoff_shard_manifest( + restored, shards + ): + raise HandoffShardError( + "manifest", "manifest does not match the verified fragment set" + ) + else: + restored = restore_handoff_text(fields[0]) + if any(field != restored for field in fields): + raise HandoffShardError( + "integrity", "complete handoff fields disagree with the recovered text" + ) + return restored + + +def handle_handoff_restore(args: Any, *, output_format: Any, print_payload: Any) -> int: + try: + # Reject ownership arguments rather than implying restoration adopts work. + ownership_fields = ( + "goal_id", + "todo_id", + "agent_id", + "session_id", + "operation_id", + "expected_revision", + "rationale", + "source_ref", + "artifact", + "target_agent_id", + "task_lease_idempotency_key", + "task_lease_expected_version", + "from_context", + ) + if any( + getattr(args, key, None) is not None and getattr(args, key, None) != [] + for key in ownership_fields + ): + raise HandoffShardError( + "input", + "restore accepts content only; ownership arguments belong to prepare/inspect/adopt", + ) + if args.handoff_format == "digest": + raise HandoffShardError( + "input", "restore supports --format json or markdown" + ) + if not args.input: + raise HandoffShardError( + "input", "restore requires --input FILE (or - for stdin)" + ) + text = ( + sys.stdin.read() + if args.input == "-" + else Path(args.input).read_text(encoding="utf-8") + ) + restored = restore_handoff_input(text, input_format=args.input_format) + payload: dict[str, Any] = {"ok": True, "handoff_text": restored} + except (HandoffShardError, OSError, UnicodeError) as exc: + payload = { + "ok": False, + "error_code": getattr(exc, "code", "input"), + "error": str(exc), + "next_action": "Obtain the unchanged complete handoff and retry; no content was executed or adopted.", + } + fmt = args.handoff_format or output_format(args) + if payload["ok"] and fmt == "markdown": + # Do not add a newline to byte-exact decoded content. + sys.stdout.write(payload["handoff_text"]) + else: + print_payload( + payload, fmt, lambda value: json.dumps(value, ensure_ascii=False, indent=2) + ) + return 0 if payload["ok"] else 1 diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index a0531104fb..89d9d6e72f 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -25,12 +25,13 @@ compact_agent_lane_todo_index_for_status_display, ) from ..diagnose import collect_diagnosis, render_diagnosis_markdown +from ..control_plane.handoff.project_agent_context import build_project_agent_handoff +from ..control_plane.handoff.handoff_fragments import render_handoff_transport from ..handoff_budget import build_handoff_interface_budget from ..presentation.renderers.status_markdown import render_status_markdown from ..quota import build_quota_should_run from ..review_packet import ( build_review_packet, - render_handoff_only_text, render_review_packet_markdown, ) from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status @@ -886,12 +887,15 @@ def handle_review_packet_command( ) if args.agent_id: attach_agent_lane_next_actions(status_payload, agent_id=args.agent_id) - payload = build_review_packet( - status_payload, - goal_id=args.goal_id, - action_kind=args.action_kind, - review_url=args.review_url, - ) + if args.handoff_only: + payload = build_project_agent_handoff( + status_payload, goal_id=args.goal_id, action_kind=args.action_kind, + ) + else: + payload = build_review_packet( + status_payload, goal_id=args.goal_id, action_kind=args.action_kind, + review_url=args.review_url, + ) except Exception as exc: payload = { "ok": False, @@ -905,7 +909,7 @@ def handle_review_packet_command( if not isinstance(fragment_texts, list): fragment_texts = [] print( - render_handoff_only_text( + render_handoff_transport( str(payload.get("handoff_text") or ""), fragment_texts ) ) diff --git a/loopx/cli_commands/status_registration.py b/loopx/cli_commands/status_registration.py index 3eeea204b5..04bdbdff24 100644 --- a/loopx/cli_commands/status_registration.py +++ b/loopx/cli_commands/status_registration.py @@ -204,7 +204,8 @@ def register_status_commands( action="store_true", help=( "Print only the target project-agent handoff in markdown output; " - "JSON output returns a minimized handoff payload." + "JSON output returns a minimized handoff payload with complete text. " + "Overflow Markdown carries all shards; verify with loopx handoff restore." ), ) review_packet_parser.add_argument( diff --git a/loopx/cli_commands/todo_continuation.py b/loopx/cli_commands/todo_continuation.py index 9064c03a89..3146d9417f 100644 --- a/loopx/cli_commands/todo_continuation.py +++ b/loopx/cli_commands/todo_continuation.py @@ -88,15 +88,18 @@ def _render_digest(payload: dict) -> str: def register_todo_continuation(subparsers, add_format): parser = subparsers.add_parser( - "handoff", help="Explicit cross-agent Todo handoff: prepare, inspect, adopt (selected canonical authority)." + "handoff", help="Explicit cross-agent Todo handoff: prepare/inspect/adopt ownership, or restore content without authority changes." ) # Note: we don't use add_format here because we need a custom --format # with a 'digest' choice. We add it manually below. - parser.add_argument("action", choices=["prepare", "inspect", "adopt"]) - parser.add_argument("--goal-id", required=True) - parser.add_argument("--todo-id", required=True) - parser.add_argument("--agent-id", required=True) - parser.add_argument("--session-id", required=True, help="Current host session identifier; provenance, not authorization.") + parser.add_argument("action", choices=["prepare", "inspect", "adopt", "restore"]) + parser.add_argument("--goal-id") + parser.add_argument("--todo-id") + parser.add_argument("--agent-id") + parser.add_argument("--session-id", help="Current host session identifier; provenance, not authorization.") + parser.add_argument("--input", help="restore only: producer output file, or - for stdin.") + parser.add_argument("--input-format", choices=["json", "markdown"], default="json", + help="restore input representation; JSON is recommended for transport fidelity.") parser.add_argument("--operation-id", help="Stable retry identity, required for prepare/adopt.") parser.add_argument("--expected-revision", help="Exact revision from inspect, required for prepare/adopt.") parser.add_argument("--rationale", help="Decision rationale (legacy, prepare only). Prefer --from-context for rich handoff.") @@ -114,7 +117,14 @@ def register_todo_continuation(subparsers, add_format): def handle_todo_continuation(args, *, registry_path, runtime_root_arg, output_format, print_payload): if args.command != "handoff": return None + if args.action == "restore": + from .handoff_restore import handle_handoff_restore + return handle_handoff_restore(args, output_format=output_format, print_payload=print_payload) try: + if args.input or args.input_format != "json": + raise ValueError("--input/--input-format are only valid for restore") + if not all((args.goal_id, args.todo_id, args.agent_id, args.session_id)): + raise ValueError("prepare/inspect/adopt require --goal-id, --todo-id, --agent-id and --session-id") if args.action != "inspect" and (not args.operation_id or not args.expected_revision): raise ValueError("prepare/adopt require --operation-id and --expected-revision; reuse both on retry") if args.action != "prepare" and (args.rationale or args.source_ref): diff --git a/loopx/control_plane/handoff/handoff_fragments.py b/loopx/control_plane/handoff/handoff_fragments.py index 5411168c27..945ff1419f 100644 --- a/loopx/control_plane/handoff/handoff_fragments.py +++ b/loopx/control_plane/handoff/handoff_fragments.py @@ -5,8 +5,7 @@ the budget, this module splits it into ordered, independently verifiable shards instead of dropping sections: -* shard 0 keeps the existing ``project_agent_handoff`` field position and - semantics; continuation shards are delivered alongside it; +* complete text fields remain complete; transport arrays carry every shard; * every shard starts with a single envelope line carrying a stable content set id, its sequence index/total, a per-shard payload checksum, a previous shard hash chain, and the full-content digest; @@ -113,7 +112,9 @@ def _envelope_line( def _assert_no_transport_markers(lines: list[str]) -> None: for line in lines: - if line.startswith(ENVELOPE_PREFIX) or line.startswith(LINE_CONTINUATION_MARKER): + if line.startswith(ENVELOPE_PREFIX) or line.startswith( + LINE_CONTINUATION_MARKER + ): raise HandoffShardError( "reserved_marker", "handoff content contains a reserved loopx-handoff transport marker", @@ -147,7 +148,9 @@ def _parse_units(lines: list[str]) -> list[_FenceUnit | str]: "cannot fragment handoff with an unterminated fenced code block", ) closer_index = lines.index("```", index + 1) - units.append(_FenceUnit(opener=line, content=lines[index + 1 : closer_index])) + units.append( + _FenceUnit(opener=line, content=lines[index + 1 : closer_index]) + ) index = closer_index + 1 else: units.append(line) @@ -264,7 +267,9 @@ def emit_fence(self, fence: _FenceUnit) -> None: used_chars = len(self._payload_text()) + (1 if self.current else 0) framing_chars = len(FENCE_OPEN_MARKER) + 1 + len("```") needs_lines = len(self.current) + 1 + 2 <= self.payload_max_lines - needs_chars = used_chars + len(fence.opener) + framing_chars <= self.payload_max_chars + needs_chars = ( + used_chars + len(fence.opener) + framing_chars <= self.payload_max_chars + ) if self.current and not (needs_lines and needs_chars): self._flush() self.current.append(fence.opener) @@ -277,7 +282,9 @@ def emit_fence(self, fence: _FenceUnit) -> None: def finish(self) -> list[str]: if self.fence_active: - raise HandoffShardError("structure", "unterminated fence while finishing shards") + raise HandoffShardError( + "structure", "unterminated fence while finishing shards" + ) self._flush() return ["\n".join(lines) for lines in self.shards] @@ -311,7 +318,9 @@ def split_handoff_text( default=0, ) if max_opener_len > ENVELOPE_CHAR_RESERVE: - raise HandoffShardError("structure", "fence opener cannot fit shard framing budget") + raise HandoffShardError( + "structure", "fence opener cannot fit shard framing budget" + ) packer = _ShardPacker(max_lines=line_limit, max_chars=char_limit) for unit in units: @@ -321,7 +330,9 @@ def split_handoff_text( packer.emit_line(unit) payloads = packer.finish() if len(payloads) < 2: - raise HandoffShardError("structure", "overflow handoff produced no continuation shard") + raise HandoffShardError( + "structure", "overflow handoff produced no continuation shard" + ) digest_hex = _sha256_hex(text) set_id = digest_hex[:16] @@ -345,7 +356,9 @@ def split_handoff_text( f"generated shard {index}/{total} exceeds the interface budget", ) if len(envelope) >= ENVELOPE_CHAR_RESERVE: - raise HandoffShardError("structure", "shard envelope exceeds reserved header budget") + raise HandoffShardError( + "structure", "shard envelope exceeds reserved header budget" + ) shards.append(shard) prev_hash = chunk_hash @@ -365,7 +378,9 @@ def parse_handoff_shard(text: str) -> HandoffShard: raise HandoffShardError("envelope", "handoff shard must not end with a newline") first_line, separator, payload = text.partition("\n") if not separator or not payload: - raise HandoffShardError("envelope", "handoff shard needs an envelope line and payload") + raise HandoffShardError( + "envelope", "handoff shard needs an envelope line and payload" + ) match = ENVELOPE_RE.match(first_line) if match is None: raise HandoffShardError("envelope", "malformed handoff shard envelope") @@ -379,7 +394,9 @@ def parse_handoff_shard(text: str) -> HandoffShard: digest_hex = match.group("d") set_id = match.group("id") if set_id != digest_hex[:16]: - raise HandoffShardError("envelope", "handoff shard set id does not bind content digest") + raise HandoffShardError( + "envelope", "handoff shard set id does not bind content digest" + ) prev_field = match.group("p") return HandoffShard( set_id=set_id, @@ -392,14 +409,6 @@ def parse_handoff_shard(text: str) -> HandoffShard: ) -def is_handoff_shard_text(text: str) -> bool: - try: - parse_handoff_shard(text) - except HandoffShardError: - return False - return True - - def _decode_fence_groups(physical: list[str]) -> list[str]: """Collapse transport fence wrapping, then join wrapped physical lines.""" @@ -410,18 +419,25 @@ def _decode_fence_groups(physical: list[str]) -> list[str]: line = physical[index] if line.startswith("```"): closer_index = next( - (candidate for candidate in range(index + 1, len(physical)) - if physical[candidate] == "```"), + ( + candidate + for candidate in range(index + 1, len(physical)) + if physical[candidate] == "```" + ), None, ) if closer_index is None: - raise HandoffShardError("structure", "unterminated fence in shard payload") + raise HandoffShardError( + "structure", "unterminated fence in shard payload" + ) content = physical[index + 1 : closer_index] if any( marker in content[1:-1] for marker in (FENCE_OPEN_MARKER, FENCE_RESUME_MARKER) ): - raise HandoffShardError("structure", "fence transport marker at invalid position") + raise HandoffShardError( + "structure", "fence transport marker at invalid position" + ) starts_resume = bool(content) and content[0] == FENCE_RESUME_MARKER ends_open = bool(content) and content[-1] == FENCE_OPEN_MARKER inner = content[1:] if starts_resume else list(content) @@ -429,7 +445,8 @@ def _decode_fence_groups(physical: list[str]) -> list[str]: if starts_resume: if pending is None: raise HandoffShardError( - "structure", "fence resume marker without a preceding fence part" + "structure", + "fence resume marker without a preceding fence part", ) pending.extend(inner) else: @@ -446,7 +463,9 @@ def _decode_fence_groups(physical: list[str]) -> list[str]: index = closer_index + 1 continue if line in (FENCE_OPEN_MARKER, FENCE_RESUME_MARKER): - raise HandoffShardError("structure", "fence transport marker outside fenced block") + raise HandoffShardError( + "structure", "fence transport marker outside fenced block" + ) if pending is not None: raise HandoffShardError( "structure", "non-fence line interleaved with a split fenced block" @@ -461,7 +480,8 @@ def _decode_fence_groups(physical: list[str]) -> list[str]: if line.startswith(LINE_CONTINUATION_MARKER): if not joined or joined[-1].startswith("```"): raise HandoffShardError( - "structure", "line continuation marker without a preceding line part" + "structure", + "line continuation marker without a preceding line part", ) joined[-1] += line[len(LINE_CONTINUATION_MARKER) :] else: @@ -470,38 +490,9 @@ def _decode_fence_groups(physical: list[str]) -> list[str]: def _decode_shards(shards: list[HandoffShard]) -> str: - if not shards: - raise HandoffShardError("missing", "no handoff shards provided") - set_ids = {shard.set_id for shard in shards} - if len(set_ids) > 1: - raise HandoffShardError( - "set_mismatch", - f"handoff shards belong to multiple fragment sets: {sorted(set_ids)}", - ) - totals = {shard.total for shard in shards} - if len(totals) > 1: - raise HandoffShardError("set_mismatch", "handoff shards disagree on total count") - digest_values = {shard.digest_hex for shard in shards} - if len(digest_values) > 1: - raise HandoffShardError("set_mismatch", "handoff shards disagree on content digest") - total = shards[0].total + # The sole caller has validated set, cardinality and arrival order. set_id = shards[0].set_id - - ordered = sorted(shards, key=lambda shard: shard.index) - indices = [shard.index for shard in ordered] - expected = list(range(total)) - if indices != expected: - missing = [index for index in expected if index not in indices] - extra = [index for index in indices if index < 0 or index >= total] - if missing: - raise HandoffShardError( - "missing", - f"handoff fragment set {set_id} missing shard index/indices {missing}", - ) - raise HandoffShardError( - "out_of_order", - f"handoff fragment set {set_id} has unexpected indices {extra}", - ) + ordered = shards previous_hash: str | None = None for shard in ordered: @@ -527,12 +518,10 @@ def _decode_shards(shards: list[HandoffShard]) -> str: def reassemble_handoff_shards( shard_texts: Iterable[str], - *, - strict_order: bool = True, ) -> str: """Parse, verify and concatenate handoff shards back to the original text. - With ``strict_order=True`` (default) shard texts must arrive in sequence + Shard texts must arrive in sequence order (0, 1, ..., n-1); out-of-order arrival raises :class:`HandoffShardError`. Missing shards, duplicate shards, foreign-set shards, checksum and hash-chain failures, and full-content digest @@ -540,36 +529,45 @@ def reassemble_handoff_shards( """ parsed = [parse_handoff_shard(text) for text in shard_texts] - if parsed: - arrival = [shard.index for shard in parsed] - total = parsed[0].total - if any(index < 0 or index >= total for index in arrival): - raise HandoffShardError( - "unexpected_index", - f"handoff import has shard indices outside 0..{total - 1}: {arrival}", - ) - if len(set(arrival)) != len(arrival): - raise HandoffShardError( - "duplicate", - f"duplicate handoff shard index in import: {arrival}", - ) - if len(parsed) > total: - raise HandoffShardError( - "unexpected_index", - f"more handoff shards than declared total {total}: {arrival}", - ) + if not parsed: + raise HandoffShardError("missing", "no handoff shards provided") + if len({(s.set_id, s.total, s.digest_hex) for s in parsed}) != 1: + raise HandoffShardError( + "set_mismatch", "handoff input mixes different fragment sets" + ) + arrival = [shard.index for shard in parsed] + total = parsed[0].total + if any(index < 0 or index >= total for index in arrival): + raise HandoffShardError( + "unexpected_index", + f"handoff import has shard indices outside 0..{total - 1}: {arrival}", + ) + if len(set(arrival)) != len(arrival): + raise HandoffShardError( + "duplicate", + f"duplicate handoff shard index in import: {arrival}", + ) + if len(parsed) > total: + raise HandoffShardError( + "unexpected_index", + f"more handoff shards than declared total {total}: {arrival}", + ) + if len(parsed) < total: present = set(arrival) - missing = [index for index in range(total) if index not in present] - if missing: - raise HandoffShardError( - "missing", - f"handoff fragment set {parsed[0].set_id} missing shard index/indices {missing}", - ) - if strict_order and arrival != sorted(arrival): - raise HandoffShardError( - "out_of_order", - f"handoff shards arrived out of sequence: {arrival}", - ) + # Work scales with received input, never with an untrusted declared total. + first_missing = next( + index for index in range(len(parsed) + 1) if index not in present + ) + raise HandoffShardError( + "missing", + f"handoff fragment set {parsed[0].set_id} missing shard index {first_missing}; " + f"received {len(parsed)} of {total}", + ) + if arrival != sorted(arrival): + raise HandoffShardError( + "out_of_order", + f"handoff shards arrived out of sequence: {arrival}", + ) return _decode_shards(parsed) @@ -583,7 +581,7 @@ def restore_handoff_text(value: str | Iterable[str]) -> str: """ if isinstance(value, str): - if any(ENVELOPE_RE.match(line) for line in value.split("\n")): + if any(line.startswith(ENVELOPE_PREFIX) for line in value.split("\n")): return reassemble_handoff_shards(extract_handoff_shards(value)) return value shard_texts = list(value) @@ -600,86 +598,6 @@ def restore_handoff_text(value: str | Iterable[str]) -> str: ) -class HandoffShardCollector: - """Accumulate imported shards idempotently and reassemble once complete. - - Importing the same shard text twice is a no-op (stable set id + index + - payload checksum). Re-importing the same index with different content - raises ``conflict``; shards from a different fragment set raise - ``set_mismatch``. - """ - - def __init__(self) -> None: - self._shards: dict[int, HandoffShard] = {} - self._arrival: list[int] = [] - self.set_id: str | None = None - self.total: int | None = None - self.digest_hex: str | None = None - - def ingest(self, shard: HandoffShard) -> HandoffShard: - if self.set_id is None: - if shard.index >= shard.total: - raise HandoffShardError( - "envelope", - f"handoff shard index {shard.index} >= total {shard.total}", - ) - self.set_id = shard.set_id - self.total = shard.total - self.digest_hex = shard.digest_hex - elif ( - shard.set_id != self.set_id - or shard.total != self.total - or shard.digest_hex != self.digest_hex - ): - raise HandoffShardError( - "set_mismatch", - f"shard set {shard.set_id} does not match collector set {self.set_id}", - ) - if shard.index < 0 or shard.index >= shard.total: - raise HandoffShardError( - "envelope", - f"handoff shard index {shard.index} outside total {shard.total}", - ) - existing = self._shards.get(shard.index) - if existing is not None: - if existing.chunk_hash != shard.chunk_hash: - raise HandoffShardError( - "conflict", - f"handoff shard {shard.index} re-imported with different content", - ) - return shard - self._shards[shard.index] = shard - self._arrival.append(shard.index) - return shard - - def ingest_text(self, text: str) -> HandoffShard: - return self.ingest(parse_handoff_shard(text)) - - @property - def received_indices(self) -> list[int]: - return sorted(self._shards) - - @property - def arrival_indices(self) -> list[int]: - return list(self._arrival) - - @property - def missing_indices(self) -> list[int]: - if self.total is None: - return [] - return [index for index in range(self.total) if index not in self._shards] - - @property - def complete(self) -> bool: - return self.total is not None and not self.missing_indices - - def reassemble(self) -> str: - if self.total is None: - raise HandoffShardError("missing", "no handoff shards imported") - ordered = [self._shards[index] for index in sorted(self._shards)] - return _decode_shards(ordered) - - def extract_handoff_shards(text: str) -> list[str]: """Extract shard blocks embedded in a larger text (e.g. a full packet). @@ -689,7 +607,14 @@ def extract_handoff_shards(text: str) -> list[str]: """ lines = text.split("\n") - starts = [index for index, line in enumerate(lines) if ENVELOPE_RE.match(line)] + starts = [ + index for index, line in enumerate(lines) if line.startswith(ENVELOPE_PREFIX) + ] + if any(not ENVELOPE_RE.match(lines[index]) for index in starts): + raise HandoffShardError( + "envelope", + "malformed handoff envelope; obtain the unchanged producer output", + ) if not starts: raise HandoffShardError("envelope", "no handoff shard envelope found") extracted: list[str] = [] @@ -713,7 +638,9 @@ def extract_handoff_shards(text: str) -> list[str]: return extracted -def build_handoff_shard_manifest(original_text: str, shard_texts: list[str]) -> dict[str, Any]: +def build_handoff_shard_manifest( + original_text: str, shard_texts: list[str] +) -> dict[str, Any]: """Structured projection of a fragmented handoff for JSON surfaces.""" parsed = [parse_handoff_shard(text) for text in shard_texts] @@ -734,3 +661,13 @@ def build_handoff_shard_manifest(original_text: str, shard_texts: list[str]) -> for shard, shard_text in zip(parsed, shard_texts) ], } + + +def render_handoff_transport(text: str, shards: list[str]) -> str: + """Render complete plain text or an ordered transport set, never both.""" + if not shards: + return text + return "\n\n".join( + f"【交接分片 {index + 1}/{len(shards)};收齐后用 loopx handoff restore 校验;恢复不授予执行权限】\n{shard}" + for index, shard in enumerate(shards) + ) diff --git a/loopx/control_plane/handoff/project_agent_context.py b/loopx/control_plane/handoff/project_agent_context.py new file mode 100644 index 0000000000..5c2d4968cb --- /dev/null +++ b/loopx/control_plane/handoff/project_agent_context.py @@ -0,0 +1,592 @@ +"""Assemble the existing project-agent context independently of human review. + +Read-only projection over status facts; no request identity or ownership writer. +""" + +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass +from typing import Any + +from .review_packet_context import ( + agent_member_from_item, + agent_member_summary, + agent_todo_texts_for_handoff, + project_agent_required_reads, + project_asset_source, + project_asset_source_line, +) +from .delivery_contract import ( + handoff_delivery_contract, + handoff_delivery_contract_summary, +) +from .handoff_fragments import build_handoff_shard_manifest, split_handoff_text +from ...handoff_budget import build_handoff_interface_budget + +LOCAL_ABSOLUTE_PATH_PATTERN = re.compile( + r"(^|[\s`'\"=:(])(?:/[A-Za-z0-9._-]+(?:/[^\s`'\",)]+)+|[A-Za-z]:[\\/][^\s`'\",)]+)" +) + + +def redact_local_absolute_paths(value: str) -> str: + return LOCAL_ABSOLUTE_PATH_PATTERN.sub( + lambda match: f"{match.group(1)}", value + ) + + +def compact_packet_text(value: str, limit: int = 180) -> str: + compact = " ".join(str(value).split()) + if len(compact) <= limit: + return compact + return compact[: limit - 1].rstrip() + "…" + + +def compact_shell_command(command: str) -> str: + parts: list[str] = [] + for line in command.splitlines(): + part = line.strip() + if part.endswith("\\"): + part = part[:-1].rstrip() + if part: + parts.append(part) + return " ".join(parts) + + +def command_block(command: str | None, *, compact: bool = False) -> str: + if not command: + return "(当前没有可执行命令;先读取 status/history。)" + if compact: + command = compact_shell_command(command) + return "\n".join(["```bash", command, "```"]) + + +def compact_last_bash_command_block(text: str) -> str: + lines = text.splitlines() + try: + start = len(lines) - 1 - lines[::-1].index("```bash") + except ValueError: + return text + try: + end = start + 1 + lines[start + 1 :].index("```") + except ValueError: + return text + command = "\n".join(lines[start + 1 : end]) + compact_command = compact_shell_command(command) + return "\n".join([*lines[: start + 1], compact_command, *lines[end:]]) + + +def normalize_project_agent_handoff_text(text: str) -> str: + """Prepare oversized text using the existing bash-block normalization. + + Unlike the former prefix-dropping fit pass, this never removes sections: + if the normalized text still exceeds the interface budget, the caller + fragments it into verifiable continuation shards instead. + """ + + if build_handoff_interface_budget(text)["within_budget"]: + return text + return compact_last_bash_command_block(text) + + +def build_status_command(status_payload: dict[str, Any]) -> str: + return "\n".join( + [ + "loopx \\", + f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", + f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", + " --format json \\", + " status", + ] + ) + + +def build_history_command(status_payload: dict[str, Any], goal_id: str) -> str: + return "\n".join( + [ + "loopx \\", + f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", + f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", + " history \\", + f" --goal-id {shlex.quote(goal_id)} \\", + " --limit 3", + ] + ) + + +def build_read_only_map_command(status_payload: dict[str, Any], goal_id: str) -> str: + return "\n".join( + [ + "loopx \\", + f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", + f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", + " read-only-map \\", + f" --goal-id {shlex.quote(goal_id)} \\", + " --dry-run", + ] + ) + + +def build_quota_should_run_command(status_payload: dict[str, Any], goal_id: str) -> str: + return " ".join( + ( + "loopx", + f"--registry {shlex.quote(str(status_payload.get('registry') or ''))}", + "--format json", + "quota should-run", + f"--goal-id {shlex.quote(goal_id)}", + "--runtime-profile generic_cli", + ) + ) + + +def find_goal(status_payload: dict[str, Any], goal_id: str) -> dict[str, Any] | None: + run_history = status_payload.get("run_history") + if not isinstance(run_history, dict): + return None + for goal in run_history.get("goals") or []: + if isinstance(goal, dict) and goal.get("id") == goal_id: + return goal + return None + + +def find_queue_item( + status_payload: dict[str, Any], goal_id: str +) -> dict[str, Any] | None: + attention_queue = status_payload.get("attention_queue") + if not isinstance(attention_queue, dict): + return None + for item in attention_queue.get("items") or []: + if isinstance(item, dict) and item.get("goal_id") == goal_id: + return item + return None + + +def handoff_followthrough_summary(item: dict[str, Any] | None) -> str | None: + if not isinstance(item, dict): + return None + readiness = ( + item.get("handoff_readiness") + if isinstance(item.get("handoff_readiness"), dict) + else {} + ) + latest_run = ( + readiness.get("post_handoff_latest_run") + if isinstance(readiness.get("post_handoff_latest_run"), dict) + else {} + ) + if not latest_run: + return None + classification = ( + str(latest_run.get("classification") or "unknown").strip() or "unknown" + ) + scale = ( + str(latest_run.get("delivery_batch_scale") or "unknown").strip() or "unknown" + ) + generated_at = str(latest_run.get("generated_at") or "").strip() + streak = readiness.get("post_handoff_small_scale_streak") + streak_text = f", small_streak={streak}" if isinstance(streak, int) else "" + suffix = f", at={generated_at}" if generated_at else "" + return compact_packet_text( + f"post_handoff_run={classification}, scale={scale}{streak_text}{suffix}", + limit=440, + ) + + +def authority_material_summary(goal: dict[str, Any] | None) -> str | None: + if not isinstance(goal, dict): + return None + registry = goal.get("authority_registry") + if not isinstance(registry, dict) or not registry.get("declared"): + return None + material_total = int(registry.get("project_material_count") or 0) + topic_count = int(registry.get("topic_authority_count") or 0) + if material_total <= 0 and topic_count <= 0: + return None + parts = [ + f"topics={topic_count}", + f"materials={material_total}", + f"repositories={int(registry.get('project_material_repository_count') or 0)}", + f"owner_review_required={int(registry.get('project_material_owner_review_required_count') or 0)}", + f"stale={int(registry.get('project_material_stale_count') or 0)}", + f"current_authority={int(registry.get('project_material_current_authority_count') or 0)}", + f"risk={registry.get('conflict_risk') or 'unknown'}", + ] + return "authority/material: " + ", ".join(parts) + + +def latest_run(goal: dict[str, Any] | None) -> dict[str, Any] | None: + runs = goal.get("latest_runs") if isinstance(goal, dict) else None + if isinstance(runs, list) and runs and isinstance(runs[0], dict): + return runs[0] + return None + + +def infer_action_kind(item: dict[str, Any] | None, goal: dict[str, Any] | None) -> str: + run = latest_run(goal) + missing_gates = item.get("missing_gates") if isinstance(item, dict) else None + if not isinstance(missing_gates, list) and isinstance(run, dict): + readiness = run.get("controller_readiness") + missing_gates = ( + readiness.get("missing_gates") if isinstance(readiness, dict) else None + ) + missing_gate_set = {str(gate) for gate in missing_gates or [] if gate} + if isinstance(item, dict) and item.get("severity") == "high": + return "health" + if "human_reward_capture" in missing_gate_set: + return "reward" + waiting_on = str(item.get("waiting_on") if isinstance(item, dict) else "") + if waiting_on in {"controller", "user_or_controller"}: + return "controller" + if waiting_on == "external_evidence": + return "evidence" + if waiting_on == "codex": + quota = ( + item.get("quota") + if isinstance(item, dict) and isinstance(item.get("quota"), dict) + else {} + ) + asset = ( + item.get("project_asset") + if isinstance(item, dict) and isinstance(item.get("project_asset"), dict) + else {} + ) + asset_quota = asset.get("quota") if isinstance(asset.get("quota"), dict) else {} + if ( + quota.get("state") == "focus_wait" + or asset_quota.get("state") == "focus_wait" + ): + return "focus_wait" + return "codex" + return "status" + + +def project_agent_command( + status_payload: dict[str, Any], + goal_id: str, + kind: str, + item: dict[str, Any] | None, + goal: dict[str, Any] | None = None, +) -> str: + if kind == "reward": + return build_history_command(status_payload, goal_id) + if ( + isinstance(item, dict) + and item.get("agent_command") + and ( + kind in {"controller", "codex"} + or operator_gate_approved_handoff(item, goal) + ) + ): + return str(item.get("agent_command")) + if kind == "controller": + return build_read_only_map_command(status_payload, goal_id) + if kind == "codex": + if connected_delivery_handoff(item, goal): + return build_quota_should_run_command(status_payload, goal_id) + return build_history_command(status_payload, goal_id) + if kind == "focus_wait": + return build_history_command(status_payload, goal_id) + return build_status_command(status_payload) + + +def target_goal_guard(goal_id: str) -> str: + return ( + f"目标校验:本段只适用于 goal_id=`{goal_id}`;如果与你当前 active goal " + "或 registry entry 不一致,停止并回报目标不匹配。" + ) + + +def agent_context_rule() -> str: + return ( + "上下文规则:本段只携带最小当前指令;如需核验上下文,只读目标 active " + "state/status/history 和本命令输出,不要从旧聊天或旧 packet 拼当前状态。" + ) + + +def operator_gate_approved_handoff( + item: dict[str, Any] | None, goal: dict[str, Any] | None +) -> bool: + if not isinstance(item, dict) or not item.get("agent_command"): + return False + if str(item.get("status") or "") == "operator_gate_approved": + return True + run = latest_run(goal) + operator_gate = run.get("operator_gate") if isinstance(run, dict) else None + return ( + isinstance(operator_gate, dict) + and operator_gate.get("decision") == "approve" + and bool(operator_gate.get("agent_command")) + ) + + +def connected_delivery_handoff( + item: dict[str, Any] | None, goal: dict[str, Any] | None = None +) -> bool: + if not isinstance(item, dict): + return False + adapter_status = str(item.get("adapter_status") or "").strip() + if adapter_status != "connected-delivery" and isinstance(goal, dict): + adapter_status = str(goal.get("adapter_status") or "").strip() + if adapter_status != "connected-delivery": + return False + if str(item.get("waiting_on") or "") != "codex": + return False + quota = item.get("quota") if isinstance(item.get("quota"), dict) else {} + return str(quota.get("state") or "") == "eligible" + + +def project_agent_section( + kind: str, + command: str, + goal_id: str, + *, + agent_todo_text: str | None = None, + agent_todo_items: list[str] | None = None, + authority_summary: str | None = None, + project_asset_source_text: str | None = None, + agent_member_text: str | None = None, + handoff_followthrough_text: str | None = None, + handoff_delivery_contract_text: str | None = None, + required_reads: list[dict[str, Any]] | None = None, + approved_operator_gate: bool = False, + connected_delivery: bool = False, +) -> str: + goal_guard = target_goal_guard(goal_id) + context_rule = agent_context_rule() + todo_line = f"Agent 待办:{agent_todo_text}" if agent_todo_text else None + extra_todo_lines = [ + f"Agent 待办候选 {index + 2}:{text}" + for index, text in enumerate((agent_todo_items or [])[1:3]) + if text + ] + authority_line = ( + f"材料上下文:{authority_summary};只用这些脱敏计数判断 freshness / owner gap,不要要求内部链接或原文。" + if authority_summary + else None + ) + source_line = ( + f"项目资产来源:{project_asset_source_text}" + if project_asset_source_text + else None + ) + member_line = f"Agent 成员:{agent_member_text}" if agent_member_text else None + followthrough_line = ( + f"交付观测:{handoff_followthrough_text}" + if handoff_followthrough_text + else None + ) + delivery_contract_line = ( + f"交付合同:{handoff_delivery_contract_text}" + if handoff_delivery_contract_text + else None + ) + first_required_read = next( + ( + item + for item in (required_reads or []) + if isinstance(item, dict) and item.get("command") + ), + None, + ) + required_read_line = ( + "必读流水账:replan/接力前运行 " + f"`{compact_shell_command(str(first_required_read.get('command') or ''))}`;" + "只展开本 agent,其他 agent 只看 frontier。" + if first_required_read + else None + ) + context_lines = [ + goal_guard, + context_rule, + source_line, + member_line, + required_read_line, + todo_line, + *extra_todo_lines, + authority_line, + followthrough_line, + delivery_contract_line, + ] + if approved_operator_gate: + lines = [ + *context_lines, + "转发条件:operator gate 已记录为 approve;本段只用于把已批准的 agent_command 交给目标项目 Agent。", + "执行边界:只执行下面命令;这是只读/dry-run 执行,不是写权限、主控接管或生产动作授权。", + "停止条件:命令失败,或需要写入、run history append、生产动作、更高权限时,停下并用中文回报结果。", + "", + command_block(command), + ] + elif connected_delivery and kind == "codex": + lines = [ + *context_lines, + "转发条件:目标 registry 已是 connected-delivery,且 quota/owner/gate 显示 codex-ready;本段用于目标项目 Agent 做真实 delivery。", + "执行边界:先执行下面 quota guard;若 should_run=true,读取 active state/status/goal_boundary/execution_profile 后,选择一个 write_scope 内的 bounded delivery segment,可改文件、验证、写回、spend。", + "停止条件:只能继续 isolated test、surface-only 下游传播,或需要未授权写入范围、生产动作、destructive git、私密材料时,回报 blocker,不 spend。", + "", + command_block(command, compact=True), + ] + elif kind == "reward": + lines = [ + *context_lines, + "转发条件:只有用户已经真实记录 run-bound human_reward 后,才把本段发给项目 Agent。", + "执行边界:不要替用户写 reward;active state 只做摘要,reward 的权威来源是 run-bound human_reward overlay。", + "停止条件:如果 reward 还停留在 dry-run / 草稿 / 口头判断,停下等待用户记录;如果已经记录,只用下面 history 路径读取。", + "", + command_block(command), + ] + elif kind == "controller": + lines = [ + *context_lines, + "转发条件:只有用户已经明确同意 read-only/controller dry-run 后,才把本段发给项目 Agent。", + "执行边界:只执行下面只读或 dry-run 项目路径;不要运行用户本地 Gate 记录草稿。", + "停止条件:需要真实 approval、write-control、run history append、生产动作或命令失败时,停下等明确授权。", + "", + command_block(command), + ] + elif kind == "focus_wait": + lines = [ + *context_lines, + "转发条件:仅当目标项目 Agent 需要当前等待边界时转发;这不是恢复 delivery 的授权。", + "执行边界:只读 status/history,确认当前 owner blocker、证据入口和 stop condition;不要继续实现、adapter work、写入或生产动作。", + "停止条件:没有新的 owner evidence、clean baseline 或外部 eval 时,保持 focus_wait 并用中文回报仍在等待什么。", + "", + command_block(command), + ] + else: + lines = [ + *context_lines, + "转发条件:只有用户已经同意 safe local path 后,才把本段发给项目 Agent。", + "执行边界:读取本项目 status/history 后,只执行下面只读或 dry-run 路径。", + "停止条件:需要真实写 reward、approval、write-control、run history append、生产动作或命令失败时,停下等明确授权。", + "", + command_block(command), + ] + return normalize_project_agent_handoff_text( + "\n".join(line for line in lines if line) + ) + + +@dataclass(frozen=True) +class ProjectAgentContext: + """Current source facts and their existing bounded display projections.""" + + goal_id: str + item: dict[str, Any] | None + goal: dict[str, Any] | None + kind: str + command: str + agent_todo_items: list[str] + asset_source: str + member: dict[str, Any] | None + member_summary: str | None + authority_summary: str | None + followthrough_summary: str | None + delivery_contract: dict[str, Any] | None + required_reads: list[dict[str, Any]] + approved_handoff: bool + delivery_handoff: bool + + @property + def agent_todo_text(self) -> str | None: + return self.agent_todo_items[0] if self.agent_todo_items else None + + @property + def effective_kind(self) -> str: + return "codex" if self.approved_handoff else self.kind + + def payload(self) -> dict[str, Any]: + text = project_agent_section( + self.kind, + self.command, + self.goal_id, + agent_todo_text=self.agent_todo_text, + agent_todo_items=self.agent_todo_items, + authority_summary=self.authority_summary, + project_asset_source_text=project_asset_source_line(self.asset_source), + agent_member_text=self.member_summary, + handoff_followthrough_text=self.followthrough_summary, + handoff_delivery_contract_text=handoff_delivery_contract_summary( + self.delivery_contract + ), + required_reads=self.required_reads, + approved_operator_gate=self.approved_handoff, + connected_delivery=self.delivery_handoff, + ) + result: dict[str, Any] = { + "ok": True, + "goal_id": self.goal_id, + "kind": self.effective_kind, + "waiting_on": self.item.get("waiting_on") if self.item else None, + "status": self.item.get("status") + if self.item + else self.goal.get("status") + if self.goal + else None, + "project_agent_command": self.command, + "project_agent_handoff": text, + "operator_gate_approved_handoff": self.approved_handoff, + "connected_delivery_handoff": self.delivery_handoff, + "agent_todo_text": self.agent_todo_text, + "agent_todo_items": self.agent_todo_items, + "agent_member": self.member, + "agent_member_summary": self.member_summary, + "authority_summary": self.authority_summary, + "handoff_followthrough_summary": self.followthrough_summary, + "handoff_delivery_contract": self.delivery_contract, + "project_agent_required_reads": self.required_reads, + "handoff_interface_budget": build_handoff_interface_budget(text), + "project_asset_source": self.asset_source, + } + shards = split_handoff_text(text) + if len(shards) > 1: + # Complete fields stay complete. The transport array includes ALL shards. + result["project_agent_handoff_fragments"] = shards + result["handoff_fragment_manifest"] = build_handoff_shard_manifest( + text, shards + ) + return result + + +def assemble_project_agent_context( + status_payload: dict[str, Any], + *, + goal_id: str, + action_kind: str | None = None, +) -> ProjectAgentContext: + item = find_queue_item(status_payload, goal_id) + goal = find_goal(status_payload, goal_id) + if item is None and goal is None: + raise ValueError(f"goal not found in status payload: {goal_id}") + kind = action_kind or infer_action_kind(item, goal) + return ProjectAgentContext( + goal_id=goal_id, + item=item, + goal=goal, + kind=kind, + command=redact_local_absolute_paths( + project_agent_command(status_payload, goal_id, kind, item, goal) + ), + agent_todo_items=agent_todo_texts_for_handoff(item), + asset_source=project_asset_source(item), + member=agent_member_from_item(item), + member_summary=agent_member_summary(item), + authority_summary=authority_material_summary(goal), + followthrough_summary=handoff_followthrough_summary(item), + delivery_contract=handoff_delivery_contract(item), + required_reads=project_agent_required_reads(goal_id, item), + approved_handoff=operator_gate_approved_handoff(item, goal), + delivery_handoff=connected_delivery_handoff(item, goal) and kind == "codex", + ) + + +def build_project_agent_handoff( + status_payload: dict[str, Any], *, goal_id: str, action_kind: str | None = None +) -> dict[str, Any]: + try: + return assemble_project_agent_context( + status_payload, goal_id=goal_id, action_kind=action_kind + ).payload() + except ValueError as exc: + return {"ok": False, "goal_id": goal_id, "error": str(exc)} diff --git a/loopx/review_packet.py b/loopx/review_packet.py index f5c088ca20..ed071426b9 100644 --- a/loopx/review_packet.py +++ b/loopx/review_packet.py @@ -1,181 +1,39 @@ from __future__ import annotations -import re import shlex from typing import Any from .control_plane.runtime.decision_freshness import ( decision_freshness_warning as runtime_decision_freshness_warning, ) +from .control_plane.handoff.project_agent_context import ( + assemble_project_agent_context, + command_block, + compact_packet_text, + redact_local_absolute_paths, +) from .control_plane.handoff.review_packet_context import ( - agent_member_from_item, - agent_member_summary, - agent_todo_texts_for_handoff, - project_agent_required_reads, - project_asset_source, project_asset_source_line, todo_text_from_project_asset, ) -from .control_plane.handoff.delivery_contract import ( - handoff_delivery_contract, - handoff_delivery_contract_summary, -) -from .control_plane.handoff.handoff_fragments import ( - build_handoff_shard_manifest, - split_handoff_text, -) -from .handoff_budget import build_handoff_interface_budget - - -LOCAL_ABSOLUTE_PATH_PATTERN = re.compile( - r"(^|[\s`'\"=:(])(?:/[A-Za-z0-9._-]+(?:/[^\s`'\",)]+)+|[A-Za-z]:[\\/][^\s`'\",)]+)" -) - - -def redact_local_absolute_paths(value: str) -> str: - return LOCAL_ABSOLUTE_PATH_PATTERN.sub(lambda match: f"{match.group(1)}", value) - - -def compact_packet_text(value: str, limit: int = 180) -> str: - compact = " ".join(str(value).split()) - if len(compact) <= limit: - return compact - return compact[: limit - 1].rstrip() + "…" - - -def compact_shell_command(command: str) -> str: - parts: list[str] = [] - for line in command.splitlines(): - part = line.strip() - if part.endswith("\\"): - part = part[:-1].rstrip() - if part: - parts.append(part) - return " ".join(parts) - - -def command_block(command: str | None, *, compact: bool = False) -> str: - if not command: - return "(当前没有可执行命令;先读取 status/history。)" - if compact: - command = compact_shell_command(command) - return "\n".join(["```bash", command, "```"]) - - -def compact_last_bash_command_block(text: str) -> str: - lines = text.splitlines() - try: - start = len(lines) - 1 - lines[::-1].index("```bash") - except ValueError: - return text - try: - end = start + 1 + lines[start + 1 :].index("```") - except ValueError: - return text - command = "\n".join(lines[start + 1 : end]) - compact_command = compact_shell_command(command) - return "\n".join([*lines[: start + 1], compact_command, *lines[end:]]) - - -def normalize_project_agent_handoff_text(text: str) -> str: - """Apply the lossless bash-block compaction normalization when oversized. - - Unlike the former prefix-dropping fit pass, this never removes content: - if the normalized text still exceeds the interface budget, the caller - fragments it into verifiable continuation shards instead. - """ - - if build_handoff_interface_budget(text)["within_budget"]: - return text - return compact_last_bash_command_block(text) - - -def prepare_project_agent_handoff_shards(text: str) -> list[str]: - """Return the handoff as one in-budget text or multiple verified shards.""" - - normalized = normalize_project_agent_handoff_text(text) - return split_handoff_text(normalized) - - -def handoff_shard_section_header(index: int, total: int) -> str: - return ( - f"【给项目 Agent · 交接分片 {index + 1}/{total}:整段转发,收齐全部 {total} 片" - "并按序号校验通过后再执行;缺片、乱序或内容改动都会明确报错】" - ) - - -def render_handoff_only_text(project_agent_handoff: str, continuation_shards: list[str]) -> str: - """Render the handoff-only relay text: shard 0 plus continuation shards.""" - - parts = [project_agent_handoff] - total = len(continuation_shards) + 1 - for offset, shard in enumerate(continuation_shards, start=1): - parts.append(handoff_shard_section_header(offset, total) + "\n" + shard) - return "\n\n".join(parts) - - -def build_status_command(status_payload: dict[str, Any]) -> str: - return "\n".join( - [ - "loopx \\", - f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", - f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", - " --format json \\", - " status", - ] - ) - - -def build_history_command(status_payload: dict[str, Any], goal_id: str) -> str: - return "\n".join( - [ - "loopx \\", - f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", - f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", - " history \\", - f" --goal-id {shlex.quote(goal_id)} \\", - " --limit 3", - ] - ) - - -def build_read_only_map_command(status_payload: dict[str, Any], goal_id: str) -> str: - return "\n".join( - [ - "loopx \\", - f" --registry {shlex.quote(str(status_payload.get('registry') or ''))} \\", - f" --runtime-root {shlex.quote(str(status_payload.get('runtime_root') or ''))} \\", - " read-only-map \\", - f" --goal-id {shlex.quote(goal_id)} \\", - " --dry-run", - ] - ) - - -def build_quota_should_run_command(status_payload: dict[str, Any], goal_id: str) -> str: - return " ".join( - ( - "loopx", - f"--registry {shlex.quote(str(status_payload.get('registry') or ''))}", - "--format json", - "quota should-run", - f"--goal-id {shlex.quote(goal_id)}", - "--runtime-profile generic_cli", - ) - ) +from .control_plane.handoff.handoff_fragments import render_handoff_transport def operator_gate_reason_summary(goal_id: str, decision: str) -> str: if decision == "approve": return controller_approval_reason(goal_id) if decision == "reject": - return f"暂不同意 {goal_id} 先做 read-only map dry-run,原因:" + return ( + f"暂不同意 {goal_id} 先做 read-only map dry-run,原因:" + ) if decision == "defer": return f"暂缓 {goal_id} read-only map dry-run,等待:" return "" -def build_operator_gate_command(status_payload: dict[str, Any], goal_id: str, *, decision: str = "approve") -> str: +def build_operator_gate_command( + status_payload: dict[str, Any], goal_id: str, *, decision: str = "approve" +) -> str: return "\n".join( [ "loopx \\", @@ -198,33 +56,17 @@ def controller_approval_reason(goal_id: str) -> str: return f"同意 {goal_id} 先做 read-only map dry-run,不授权写入或生产动作" -def operator_gate_decision_commands(status_payload: dict[str, Any], goal_id: str) -> dict[str, str]: +def operator_gate_decision_commands( + status_payload: dict[str, Any], goal_id: str +) -> dict[str, str]: return { - decision: build_operator_gate_command(status_payload, goal_id, decision=decision) + decision: build_operator_gate_command( + status_payload, goal_id, decision=decision + ) for decision in ("approve", "reject", "defer") } -def find_goal(status_payload: dict[str, Any], goal_id: str) -> dict[str, Any] | None: - run_history = status_payload.get("run_history") - if not isinstance(run_history, dict): - return None - for goal in run_history.get("goals") or []: - if isinstance(goal, dict) and goal.get("id") == goal_id: - return goal - return None - - -def find_queue_item(status_payload: dict[str, Any], goal_id: str) -> dict[str, Any] | None: - attention_queue = status_payload.get("attention_queue") - if not isinstance(attention_queue, dict): - return None - for item in attention_queue.get("items") or []: - if isinstance(item, dict) and item.get("goal_id") == goal_id: - return item - return None - - def decision_freshness_packet_lines(warning: dict[str, Any] | None) -> list[str]: if not isinstance(warning, dict) or not warning: return [] @@ -244,7 +86,9 @@ def decision_freshness_packet_lines(warning: dict[str, Any] | None) -> list[str] f"newer_7d={item.get('newer_event_count_7d')} " f"at={compact_packet_text(str(item.get('decision_at') or ''), limit=80)}" ) - lines.append("处理方式:这不是仓库回滚;只在审批/转交这一瞬间重读当前控制面状态后再复用旧决策。") + lines.append( + "处理方式:这不是仓库回滚;只在审批/转交这一瞬间重读当前控制面状态后再复用旧决策。" + ) return [redact_local_absolute_paths(line) for line in lines] @@ -263,84 +107,6 @@ def stale_latest_run_packet_lines(warning: dict[str, Any] | None) -> list[str]: return [redact_local_absolute_paths(line) for line in lines] -def handoff_followthrough_summary(item: dict[str, Any] | None) -> str | None: - if not isinstance(item, dict): - return None - readiness = item.get("handoff_readiness") if isinstance(item.get("handoff_readiness"), dict) else {} - latest_run = ( - readiness.get("post_handoff_latest_run") - if isinstance(readiness.get("post_handoff_latest_run"), dict) - else {} - ) - if not latest_run: - return None - classification = str(latest_run.get("classification") or "unknown").strip() or "unknown" - scale = str(latest_run.get("delivery_batch_scale") or "unknown").strip() or "unknown" - generated_at = str(latest_run.get("generated_at") or "").strip() - streak = readiness.get("post_handoff_small_scale_streak") - streak_text = f", small_streak={streak}" if isinstance(streak, int) else "" - suffix = f", at={generated_at}" if generated_at else "" - return compact_packet_text( - f"post_handoff_run={classification}, scale={scale}{streak_text}{suffix}", - limit=440, - ) - - -def authority_material_summary(goal: dict[str, Any] | None) -> str | None: - if not isinstance(goal, dict): - return None - registry = goal.get("authority_registry") - if not isinstance(registry, dict) or not registry.get("declared"): - return None - material_total = int(registry.get("project_material_count") or 0) - topic_count = int(registry.get("topic_authority_count") or 0) - if material_total <= 0 and topic_count <= 0: - return None - parts = [ - f"topics={topic_count}", - f"materials={material_total}", - f"repositories={int(registry.get('project_material_repository_count') or 0)}", - f"owner_review_required={int(registry.get('project_material_owner_review_required_count') or 0)}", - f"stale={int(registry.get('project_material_stale_count') or 0)}", - f"current_authority={int(registry.get('project_material_current_authority_count') or 0)}", - f"risk={registry.get('conflict_risk') or 'unknown'}", - ] - return "authority/material: " + ", ".join(parts) - - -def latest_run(goal: dict[str, Any] | None) -> dict[str, Any] | None: - runs = goal.get("latest_runs") if isinstance(goal, dict) else None - if isinstance(runs, list) and runs and isinstance(runs[0], dict): - return runs[0] - return None - - -def infer_action_kind(item: dict[str, Any] | None, goal: dict[str, Any] | None) -> str: - run = latest_run(goal) - missing_gates = item.get("missing_gates") if isinstance(item, dict) else None - if not isinstance(missing_gates, list) and isinstance(run, dict): - readiness = run.get("controller_readiness") - missing_gates = readiness.get("missing_gates") if isinstance(readiness, dict) else None - missing_gate_set = {str(gate) for gate in missing_gates or [] if gate} - if isinstance(item, dict) and item.get("severity") == "high": - return "health" - if "human_reward_capture" in missing_gate_set: - return "reward" - waiting_on = str(item.get("waiting_on") if isinstance(item, dict) else "") - if waiting_on in {"controller", "user_or_controller"}: - return "controller" - if waiting_on == "external_evidence": - return "evidence" - if waiting_on == "codex": - quota = item.get("quota") if isinstance(item, dict) and isinstance(item.get("quota"), dict) else {} - asset = item.get("project_asset") if isinstance(item, dict) and isinstance(item.get("project_asset"), dict) else {} - asset_quota = asset.get("quota") if isinstance(asset.get("quota"), dict) else {} - if quota.get("state") == "focus_wait" or asset_quota.get("state") == "focus_wait": - return "focus_wait" - return "codex" - return "status" - - def human_prompt(kind: str) -> dict[str, str]: if kind == "reward": return { @@ -385,7 +151,9 @@ def human_prompt(kind: str) -> dict[str, str]: } -def suggested_decision(kind: str, item: dict[str, Any] | None, goal_id: str | None = None) -> str: +def suggested_decision( + kind: str, item: dict[str, Any] | None, goal_id: str | None = None +) -> str: if kind == "controller": lead = f"同意 {goal_id} 先做" if goal_id else "同意先做" question = str(item.get("operator_question") if isinstance(item, dict) else "") @@ -405,229 +173,6 @@ def suggested_decision(kind: str, item: dict[str, Any] | None, goal_id: str | No return "继续 / 不继续 / 继续观察,并补一句原因。" -def project_agent_command( - status_payload: dict[str, Any], - goal_id: str, - kind: str, - item: dict[str, Any] | None, - goal: dict[str, Any] | None = None, -) -> str: - if kind == "reward": - return build_history_command(status_payload, goal_id) - if ( - isinstance(item, dict) - and item.get("agent_command") - and (kind in {"controller", "codex"} or operator_gate_approved_handoff(item, goal)) - ): - return str(item.get("agent_command")) - if kind == "controller": - return build_read_only_map_command(status_payload, goal_id) - if kind == "codex": - if connected_delivery_handoff(item, goal): - return build_quota_should_run_command(status_payload, goal_id) - return build_history_command(status_payload, goal_id) - if kind == "focus_wait": - return build_history_command(status_payload, goal_id) - return build_status_command(status_payload) - - -def target_goal_guard(goal_id: str) -> str: - return ( - f"目标校验:本段只适用于 goal_id=`{goal_id}`;如果与你当前 active goal " - "或 registry entry 不一致,停止并回报目标不匹配。" - ) - - -def agent_context_rule() -> str: - return ( - "上下文规则:本段只携带最小当前指令;如需核验上下文,只读目标 active " - "state/status/history 和本命令输出,不要从旧聊天或旧 packet 拼当前状态。" - ) - - -def operator_gate_approved_handoff(item: dict[str, Any] | None, goal: dict[str, Any] | None) -> bool: - if not isinstance(item, dict) or not item.get("agent_command"): - return False - if str(item.get("status") or "") == "operator_gate_approved": - return True - run = latest_run(goal) - operator_gate = run.get("operator_gate") if isinstance(run, dict) else None - return ( - isinstance(operator_gate, dict) - and operator_gate.get("decision") == "approve" - and bool(operator_gate.get("agent_command")) - ) - - -def connected_delivery_handoff(item: dict[str, Any] | None, goal: dict[str, Any] | None = None) -> bool: - if not isinstance(item, dict): - return False - adapter_status = str(item.get("adapter_status") or "").strip() - if adapter_status != "connected-delivery" and isinstance(goal, dict): - adapter_status = str(goal.get("adapter_status") or "").strip() - if adapter_status != "connected-delivery": - return False - if str(item.get("waiting_on") or "") != "codex": - return False - quota = item.get("quota") if isinstance(item.get("quota"), dict) else {} - return str(quota.get("state") or "") == "eligible" - - -def project_agent_section( - kind: str, - command: str, - goal_id: str, - *, - agent_todo_text: str | None = None, - agent_todo_items: list[str] | None = None, - authority_summary: str | None = None, - project_asset_source_text: str | None = None, - agent_member_text: str | None = None, - handoff_followthrough_text: str | None = None, - handoff_delivery_contract_text: str | None = None, - required_reads: list[dict[str, Any]] | None = None, - approved_operator_gate: bool = False, - connected_delivery: bool = False, -) -> str: - goal_guard = target_goal_guard(goal_id) - context_rule = agent_context_rule() - todo_line = f"Agent 待办:{agent_todo_text}" if agent_todo_text else None - extra_todo_lines = [ - f"Agent 待办候选 {index + 2}:{text}" - for index, text in enumerate((agent_todo_items or [])[1:3]) - if text - ] - authority_line = f"材料上下文:{authority_summary};只用这些脱敏计数判断 freshness / owner gap,不要要求内部链接或原文。" if authority_summary else None - source_line = f"项目资产来源:{project_asset_source_text}" if project_asset_source_text else None - member_line = f"Agent 成员:{agent_member_text}" if agent_member_text else None - followthrough_line = f"交付观测:{handoff_followthrough_text}" if handoff_followthrough_text else None - delivery_contract_line = f"交付合同:{handoff_delivery_contract_text}" if handoff_delivery_contract_text else None - first_required_read = next( - ( - item - for item in (required_reads or []) - if isinstance(item, dict) and item.get("command") - ), - None, - ) - required_read_line = ( - "必读流水账:replan/接力前运行 " - f"`{compact_shell_command(str(first_required_read.get('command') or ''))}`;" - "只展开本 agent,其他 agent 只看 frontier。" - if first_required_read - else None - ) - if approved_operator_gate: - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:operator gate 已记录为 approve;本段只用于把已批准的 agent_command 交给目标项目 Agent。", - "执行边界:只执行下面命令;这是只读/dry-run 执行,不是写权限、主控接管或生产动作授权。", - "停止条件:命令失败,或需要写入、run history append、生产动作、更高权限时,停下并用中文回报结果。", - "", - command_block(command), - ] - elif connected_delivery and kind == "codex": - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:目标 registry 已是 connected-delivery,且 quota/owner/gate 显示 codex-ready;本段用于目标项目 Agent 做真实 delivery。", - "执行边界:先执行下面 quota guard;若 should_run=true,读取 active state/status/goal_boundary/execution_profile 后,选择一个 write_scope 内的 bounded delivery segment,可改文件、验证、写回、spend。", - "停止条件:只能继续 isolated test、surface-only 下游传播,或需要未授权写入范围、生产动作、destructive git、私密材料时,回报 blocker,不 spend。", - "", - command_block(command, compact=True), - ] - elif kind == "reward": - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:只有用户已经真实记录 run-bound human_reward 后,才把本段发给项目 Agent。", - "执行边界:不要替用户写 reward;active state 只做摘要,reward 的权威来源是 run-bound human_reward overlay。", - "停止条件:如果 reward 还停留在 dry-run / 草稿 / 口头判断,停下等待用户记录;如果已经记录,只用下面 history 路径读取。", - "", - command_block(command), - ] - elif kind == "controller": - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:只有用户已经明确同意 read-only/controller dry-run 后,才把本段发给项目 Agent。", - "执行边界:只执行下面只读或 dry-run 项目路径;不要运行用户本地 Gate 记录草稿。", - "停止条件:需要真实 approval、write-control、run history append、生产动作或命令失败时,停下等明确授权。", - "", - command_block(command), - ] - elif kind == "focus_wait": - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:仅当目标项目 Agent 需要当前等待边界时转发;这不是恢复 delivery 的授权。", - "执行边界:只读 status/history,确认当前 owner blocker、证据入口和 stop condition;不要继续实现、adapter work、写入或生产动作。", - "停止条件:没有新的 owner evidence、clean baseline 或外部 eval 时,保持 focus_wait 并用中文回报仍在等待什么。", - "", - command_block(command), - ] - else: - lines = [ - goal_guard, - context_rule, - source_line, - member_line, - required_read_line, - todo_line, - *extra_todo_lines, - authority_line, - followthrough_line, - delivery_contract_line, - "转发条件:只有用户已经同意 safe local path 后,才把本段发给项目 Agent。", - "执行边界:读取本项目 status/history 后,只执行下面只读或 dry-run 路径。", - "停止条件:需要真实写 reward、approval、write-control、run history append、生产动作或命令失败时,停下等明确授权。", - "", - command_block(command), - ] - return normalize_project_agent_handoff_text("\n".join(line for line in lines if line)) - - def build_review_packet( status_payload: dict[str, Any], *, @@ -635,30 +180,28 @@ def build_review_packet( action_kind: str | None = None, review_url: str | None = None, ) -> dict[str, Any]: - item = find_queue_item(status_payload, goal_id) - goal = find_goal(status_payload, goal_id) - if item is None and goal is None: - return { - "ok": False, - "goal_id": goal_id, - "error": f"goal not found in status payload: {goal_id}", - } - - kind = action_kind or infer_action_kind(item, goal) + try: + context = assemble_project_agent_context( + status_payload, goal_id=goal_id, action_kind=action_kind + ) + except ValueError as exc: + return {"ok": False, "goal_id": goal_id, "error": str(exc)} + item, kind = context.item, context.kind + handoff = context.payload() prompt = human_prompt(kind) - question = str(item.get("operator_question") or prompt["question"]) if isinstance(item, dict) else prompt["question"] - summary = str(item.get("recommended_action") or "当前状态源没有对应的 action card。") if isinstance(item, dict) else "当前状态源没有对应的 action card。" + question = ( + str(item.get("operator_question") or prompt["question"]) + if isinstance(item, dict) + else prompt["question"] + ) + summary = ( + str(item.get("recommended_action") or "当前状态源没有对应的 action card。") + if isinstance(item, dict) + else "当前状态源没有对应的 action card。" + ) user_todo_text = todo_text_from_project_asset(item, "user_todos") - agent_todo_items = agent_todo_texts_for_handoff(item) - agent_todo_text = agent_todo_items[0] if agent_todo_items else None - asset_source = project_asset_source(item) - asset_source_line = project_asset_source_line(asset_source) - member_summary = agent_member_summary(item) - authority_summary = authority_material_summary(goal) - followthrough_summary = handoff_followthrough_summary(item) - delivery_contract = handoff_delivery_contract(item) - delivery_contract_text = handoff_delivery_contract_summary(delivery_contract) - required_reads = project_agent_required_reads(goal_id, item) + asset_source_line = project_asset_source_line(context.asset_source) + authority_summary = context.authority_summary freshness_warning = runtime_decision_freshness_warning( status_payload, goal_id=goal_id, @@ -667,20 +210,24 @@ def build_review_packet( freshness_warning_lines = decision_freshness_packet_lines(freshness_warning) stale_latest_run_warning = ( item.get("stale_latest_run_warning") - if isinstance(item, dict) and isinstance(item.get("stale_latest_run_warning"), dict) + if isinstance(item, dict) + and isinstance(item.get("stale_latest_run_warning"), dict) else None ) task_graph_projection = ( item.get("task_graph_projection") - if isinstance(item, dict) and isinstance(item.get("task_graph_projection"), dict) + if isinstance(item, dict) + and isinstance(item.get("task_graph_projection"), dict) else None ) stale_latest_run_lines = stale_latest_run_packet_lines(stale_latest_run_warning) - approved_handoff = operator_gate_approved_handoff(item, goal) - command = redact_local_absolute_paths(project_agent_command(status_payload, goal_id, kind, item, goal)) - effective_kind = "codex" if approved_handoff else kind - delivery_handoff = connected_delivery_handoff(item, goal) and kind == "codex" - gate_commands = operator_gate_decision_commands(status_payload, goal_id) if kind == "controller" else {} + approved_handoff = context.approved_handoff + effective_kind = context.effective_kind + gate_commands = ( + operator_gate_decision_commands(status_payload, goal_id) + if kind == "controller" + else {} + ) gate_command = gate_commands.get("approve") if gate_commands else None decision = suggested_decision(kind, item, goal_id) if user_todo_text and kind == "controller": @@ -693,31 +240,10 @@ def build_review_packet( reply = "转发下方【给项目 Agent】即可。" boundary = "这只是执行已批准的只读/dry-run agent_command;如需写入或更高权限,项目 Agent 必须再次停下。" owner_blocker_text = user_todo_text if kind == "focus_wait" else None - prepared_agent_text = project_agent_section( - kind, - command, - goal_id, - agent_todo_text=agent_todo_text, - agent_todo_items=agent_todo_items, - authority_summary=authority_summary, - project_asset_source_text=asset_source_line, - agent_member_text=member_summary, - handoff_followthrough_text=followthrough_summary, - handoff_delivery_contract_text=delivery_contract_text, - required_reads=required_reads, - approved_operator_gate=approved_handoff, - connected_delivery=delivery_handoff, + agent_text = render_handoff_transport( + handoff["project_agent_handoff"], + handoff.get("project_agent_handoff_fragments", []), ) - handoff_shards = split_handoff_text(prepared_agent_text) - agent_text = handoff_shards[0] - continuation_shards = handoff_shards[1:] - fragmented_handoff = bool(continuation_shards) - handoff_fragment_manifest = ( - build_handoff_shard_manifest(prepared_agent_text, handoff_shards) - if fragmented_handoff - else None - ) - handoff_interface_budget = build_handoff_interface_budget(agent_text) type_label = { "reward": "Reward", "controller": "Controller", @@ -733,13 +259,19 @@ def build_review_packet( f"链接:{review_url or 'CLI generated packet; no dashboard URL provided.'}", f"摘要:{summary}", f"来源:{asset_source_line}", - f"材料:{authority_summary}(仅脱敏计数;不含内部链接、路径或正文。)" if authority_summary else None, + f"材料:{authority_summary}(仅脱敏计数;不含内部链接、路径或正文。)" + if authority_summary + else None, *stale_latest_run_lines, *freshness_warning_lines, "", "【人只需判断】", - f"解锁条件:{owner_blocker_text}(有新证据或明确暂缓后再调整 focus)" if owner_blocker_text else None, - f"待办:{user_todo_text}(先处理/暂缓再判 gate)" if user_todo_text and kind == "controller" else None, + f"解锁条件:{owner_blocker_text}(有新证据或明确暂缓后再调整 focus)" + if owner_blocker_text + else None, + f"待办:{user_todo_text}(先处理/暂缓再判 gate)" + if user_todo_text and kind == "controller" + else None, f"问题:{question}", f"建议判断:{decision}", f"回复:{reply}", @@ -762,20 +294,6 @@ def build_review_packet( agent_text, ] ) - if fragmented_handoff: - total_shards = len(handoff_shards) - lines.append( - f"交接分片提示:本段为第 1/{total_shards} 片(信封在首行 HTML 注释中);" - f"请收齐并按序号校验全部 {total_shards} 片后再执行,缺片、乱序或内容改动都会报错。" - ) - for shard_index, shard_text in enumerate(continuation_shards, start=1): - lines.extend( - [ - "", - handoff_shard_section_header(shard_index, total_shards), - shard_text, - ] - ) lines.extend( [ "", @@ -783,40 +301,19 @@ def build_review_packet( ] ) result = { - "ok": True, - "goal_id": goal_id, - "kind": effective_kind, - "waiting_on": item.get("waiting_on") if isinstance(item, dict) else None, - "status": item.get("status") if isinstance(item, dict) else goal.get("status") if isinstance(goal, dict) else None, + **handoff, "review_url": review_url, "question": question, "suggested_decision": decision, - "project_agent_command": command, - "project_agent_handoff": agent_text, - "operator_gate_approved_handoff": approved_handoff, - "connected_delivery_handoff": delivery_handoff, "operator_gate_dry_run_command": gate_command, "operator_gate_decision_commands": gate_commands, "user_todo_text": user_todo_text, "owner_blocker_text": owner_blocker_text, - "agent_todo_text": agent_todo_text, - "agent_todo_items": agent_todo_items, - "agent_member": agent_member_from_item(item), - "agent_member_summary": member_summary, - "authority_summary": authority_summary, - "handoff_followthrough_summary": followthrough_summary, - "handoff_delivery_contract": delivery_contract, - "project_agent_required_reads": required_reads, - "handoff_interface_budget": handoff_interface_budget, "decision_freshness_warning": freshness_warning, "stale_latest_run_warning": stale_latest_run_warning, "task_graph_projection": task_graph_projection, - "project_asset_source": asset_source, "packet": "\n".join(line for line in lines if line), } - if fragmented_handoff: - result["project_agent_handoff_fragments"] = continuation_shards - result["handoff_fragment_manifest"] = handoff_fragment_manifest return result diff --git a/tests/test_handoff_fragments.py b/tests/test_handoff_fragments.py index 929b74484e..c313404014 100644 --- a/tests/test_handoff_fragments.py +++ b/tests/test_handoff_fragments.py @@ -7,17 +7,15 @@ FENCE_OPEN_MARKER, FENCE_RESUME_MARKER, LINE_CONTINUATION_MARKER, - HandoffShardCollector, HandoffShardError, build_handoff_shard_manifest, extract_handoff_shards, - is_handoff_shard_text, parse_handoff_shard, reassemble_handoff_shards, restore_handoff_text, split_handoff_text, ) -from loopx.review_packet import render_handoff_only_text +from loopx.control_plane.handoff.handoff_fragments import render_handoff_transport # --------------------------------------------------------------------------- @@ -30,7 +28,6 @@ def test_within_budget_text_returned_verbatim() -> None: shards = split_handoff_text(text) assert shards == [text] - assert is_handoff_shard_text(text) is False assert split_handoff_text(text) == [text] @@ -139,12 +136,6 @@ def test_shuffled_delivery_raises_out_of_order() -> None: assert excinfo.value.code == "out_of_order" -def test_non_strict_order_restores_after_sequence_validation() -> None: - shards = split_handoff_text(_line_overflow_text()) - text = reassemble_handoff_shards(list(reversed(shards)), strict_order=False) - assert text == _line_overflow_text() - - def test_payload_tampering_raises_integrity_error() -> None: shards = split_handoff_text(_line_overflow_text()) tampered = shards[1].replace("甲乙", "丙丁", 1) @@ -202,15 +193,6 @@ def test_duplicate_shard_in_batch_raises() -> None: assert excinfo.value.code == "duplicate" -def test_collector_incomplete_reassemble_raises_missing() -> None: - shards = split_handoff_text(_line_overflow_text()) - collector = HandoffShardCollector() - collector.ingest_text(shards[0]) - assert collector.complete is False - assert collector.missing_indices == list(range(1, len(shards))) - with pytest.raises(HandoffShardError) as excinfo: - collector.reassemble() - assert excinfo.value.code == "missing" # --------------------------------------------------------------------------- @@ -226,28 +208,8 @@ def test_regeneration_is_byte_stable() -> None: assert parse_handoff_shard(first[0]).set_id == parse_handoff_shard(second[0]).set_id -def test_collector_repeated_import_is_idempotent() -> None: - text = _line_overflow_text() - shards = split_handoff_text(text) - collector = HandoffShardCollector() - for shard in shards: - collector.ingest_text(shard) - for shard in shards: - collector.ingest_text(shard) - assert collector.arrival_indices == list(range(len(shards))) - assert collector.received_indices == list(range(len(shards))) - assert collector.complete is True - assert collector.reassemble() == text -def test_collector_rejects_foreign_set() -> None: - shards = split_handoff_text(_line_overflow_text()) - other = split_handoff_text("另一条交接:" + "内容" * 1500) - collector = HandoffShardCollector() - collector.ingest_text(shards[0]) - with pytest.raises(HandoffShardError) as excinfo: - collector.ingest_text(other[1]) - assert excinfo.value.code == "set_mismatch" # --------------------------------------------------------------------------- @@ -477,10 +439,10 @@ def test_review_packet_fragments_oversized_handoff_losslessly() -> None: payload = build_review_packet(_giant_command_payload(goal_id), goal_id=goal_id) assert payload["ok"] is True - shard0 = payload["project_agent_handoff"] + shard0 = payload["project_agent_handoff_fragments"][0] fragments = payload["project_agent_handoff_fragments"] manifest = payload["handoff_fragment_manifest"] - all_shards = [shard0, *fragments] + all_shards = fragments assert len(fragments) >= 1 assert manifest["schema_version"] == "project_agent_handoff_shard_v1" @@ -507,14 +469,14 @@ def test_review_packet_fragments_oversized_handoff_losslessly() -> None: extracted = extract_handoff_shards(payload["packet"]) assert reassemble_handoff_shards(extracted) == restored - assert "交接分片提示:本段为第 1/" in payload["packet"] + assert "交接分片 1/" in payload["packet"] handoff_only = review_packet_handoff_only_payload(payload) assert handoff_only["project_agent_handoff_fragments"] == fragments assert handoff_only["handoff_fragment_manifest"] == manifest - assert handoff_only["handoff_text"] == shard0 + assert handoff_only["handoff_text"] == restored == payload["project_agent_handoff"] - markdown = render_handoff_only_text( + markdown = render_handoff_transport( handoff_only["handoff_text"], handoff_only["project_agent_handoff_fragments"] ) assert reassemble_handoff_shards(extract_handoff_shards(markdown)) == restored @@ -542,19 +504,19 @@ def test_review_packet_within_budget_shape_is_unchanged() -> None: handoff_only = review_packet_handoff_only_payload(payload) assert "project_agent_handoff_fragments" not in handoff_only - rendered = render_handoff_only_text(handoff_only["handoff_text"], []) + rendered = render_handoff_transport(handoff_only["handoff_text"], []) assert rendered == handoff_only["handoff_text"] == handoff -def test_handoff_budget_reports_shard_zero_within_budget() -> None: +def test_handoff_budget_reports_complete_text_overflow() -> None: from loopx.review_packet import build_review_packet goal_id = "giant-command-handoff-budget" payload = build_review_packet(_giant_command_payload(goal_id), goal_id=goal_id) budget = payload["handoff_interface_budget"] assert budget["mode"] == "project_agent_handoff" - assert budget["within_budget"] is True + assert budget["within_budget"] is False assert budget["within_line_budget"] is True - assert budget["within_char_budget"] is True + assert budget["within_char_budget"] is False assert budget["line_count"] == len(payload["project_agent_handoff"].splitlines()) assert budget["char_count"] == len(payload["project_agent_handoff"]) diff --git a/tests/test_handoff_receiver.py b/tests/test_handoff_receiver.py new file mode 100644 index 0000000000..c08e594ee3 --- /dev/null +++ b/tests/test_handoff_receiver.py @@ -0,0 +1,265 @@ +"""Receiver contracts: whole content, strict transport, no ownership effects.""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys + +import pytest + +from loopx.control_plane.handoff.project_agent_context import ( + build_project_agent_handoff, +) +from loopx.control_plane.handoff.handoff_fragments import split_handoff_text +from loopx.review_packet import build_review_packet + + +def status_fixture(): + return { + "attention_queue": { + "items": [ + { + "goal_id": "handoff-contract", + "waiting_on": "codex", + "status": "operator_gate_approved", + "agent_command": "touch handoff-should-not-execute; printf '%s' '" + + "evidence " * 350 + + "RETURN-VALIDATION'", + "project_asset": { + "agent_todos": { + "items": [ + {"text": "Preserve the source citation."}, + {"text": "Return validation and remaining limits."}, + ] + }, + "execution_profile": { + "minimum_scale": "implementation", + "must_include": ["targeted_validation", "state_writeback"], + }, + }, + "handoff_readiness": {"post_handoff_small_scale_streak": 3}, + } + ] + }, + "run_history": { + "goals": [ + { + "id": "handoff-contract", + "authority_registry": { + "declared": True, + "project_material_count": 7, + "topic_authority_count": 2, + }, + } + ] + }, + } + + +def receive(tmp_path, value, *, input_format="json", extra=()): + text = json.dumps(value, ensure_ascii=False) if input_format == "json" else value + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(tmp_path / "absent-registry.json"), + "--runtime-root", + str(tmp_path / "absent-runtime"), + "--format", + "json", + "handoff", + "restore", + "--input", + "-", + "--input-format", + input_format, + *extra, + ], + cwd=tmp_path, + input=text, + text=True, + capture_output=True, + check=False, + ) + assert not list(tmp_path.iterdir()), ( + "content restore must not create registry/runtime/work state" + ) + return result.returncode, json.loads(result.stdout) + + +def test_common_context_and_human_display_are_independent(monkeypatch): + source = status_fixture() + source["attention_queue"]["items"][0]["status"] = "active" + before = copy.deepcopy(source) + context = build_project_agent_handoff(source, goal_id="handoff-contract") + monkeypatch.setattr( + "loopx.review_packet.human_prompt", + lambda _: { + "question": "CHANGED HUMAN LABEL", + "reply": "DISPLAY ONLY", + "boundary": "DISPLAY BOUNDARY", + }, + ) + full = build_review_packet(source, goal_id="handoff-contract") + assert full["project_agent_handoff"] == context["project_agent_handoff"] + text = context["project_agent_handoff"] + for required in ( + "Preserve the source citation.", + "Return validation and remaining limits.", + "materials=7", + "targeted validation", + "state writeback", + "RETURN-VALIDATION", + ): + assert required in text + assert "CHANGED HUMAN LABEL" in full["packet"] + assert "CHANGED HUMAN LABEL" not in text + assert context["project_agent_handoff_fragments"][0] != text + assert source == before + + +def test_restore_cli_success_does_not_execute_or_adopt(tmp_path): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + code, payload = receive(tmp_path, packet) + assert code == 0 + assert payload == {"ok": True, "handoff_text": packet["project_agent_handoff"]} + assert "RETURN-VALIDATION" in payload["handoff_text"] + + +@pytest.mark.parametrize( + "mutation,expected", + [ + ("first", "missing"), + ("missing", "missing"), + ("mixed", "set_mismatch"), + ("tamper", "integrity"), + ("reverse", "out_of_order"), + ("duplicate", "duplicate"), + ("complete_field", "integrity"), + ("manifest", "manifest"), + ("no_fragments", "missing"), + ], +) +def test_restore_cli_rejects_incomplete_or_inconsistent_input( + tmp_path, mutation, expected +): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + shards = packet["project_agent_handoff_fragments"] + if mutation == "first": + packet["project_agent_handoff_fragments"] = shards[:1] + elif mutation == "missing": + packet["project_agent_handoff_fragments"] = shards[1:] + elif mutation == "mixed": + shards[1] = split_handoff_text("foreign\n" * 30 + "end")[1] + elif mutation == "tamper": + shards[0] += "tampered" + elif mutation == "reverse": + shards.reverse() + elif mutation == "duplicate": + shards.append(shards[-1]) + elif mutation == "complete_field": + packet["project_agent_handoff"] += "tampered" + elif mutation == "manifest": + packet["handoff_fragment_manifest"]["total"] += 1 + elif mutation == "no_fragments": + del packet["project_agent_handoff_fragments"] + code, payload = receive(tmp_path, packet) + assert code == 1 + assert payload["error_code"] == expected, payload + assert "handoff_text" not in payload + + +@pytest.mark.parametrize( + "kind,expected", + [("first", "missing"), ("malformed", "envelope"), ("tamper", "integrity")], +) +def test_restore_cli_raw_markdown_failure(tmp_path, kind, expected): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + text = packet["project_agent_handoff_fragments"][0] + if kind == "malformed": + text = text.replace("v=1", "v=broken") + if kind == "tamper": + text = text.replace("目标校验", "changed") + code, payload = receive(tmp_path, text, input_format="markdown") + assert code == 1 and payload["error_code"] == expected + assert "handoff_text" not in payload + + +def test_restore_cli_does_not_accept_ownership_options(tmp_path): + code, payload = receive(tmp_path, {}, extra=("--agent-id", "receiver")) + assert code == 1 and payload["error_code"] == "input" + + +def test_handoff_only_does_not_construct_review_packet(monkeypatch, capsys, tmp_path): + from argparse import Namespace + from loopx.cli_commands import status + + monkeypatch.setattr(status, "collect_status", lambda **_: status_fixture()) + + def forbidden(*args, **kwargs): + raise AssertionError("handoff-only must not build a human packet") + + monkeypatch.setattr(status, "build_review_packet", forbidden) + args = Namespace( + handoff_only=True, + goal_id="handoff-contract", + action_kind=None, + review_url=None, + agent_id=None, + scan_path=[], + scan_root=".", + limit=5, + available_capabilities=None, + ) + result = status.handle_review_packet_command( + args, + registry_path=tmp_path / "unused", + runtime_root_arg=None, + output_format=lambda *args: "json", + print_payload=lambda value, *_: print(json.dumps(value)), + ) + assert result == 0 + output = json.loads(capsys.readouterr().out) + assert output["handoff_text"] == output["project_agent_handoff"] + assert "question" not in output + + +def test_hostile_declared_count_is_rejected_without_expanding_it(tmp_path): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + shard = packet["project_agent_handoff_fragments"][0] + import re + + shard = re.sub(r" n=\d+ ", " n=999999999 ", shard) + code, payload = receive(tmp_path, shard, input_format="markdown") + assert code == 1 and payload["error_code"] == "missing" + + +@pytest.mark.parametrize("action", ["prepare", "inspect", "adopt"]) +def test_ownership_actions_still_require_identity_before_opening_registry( + tmp_path, action +): + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(tmp_path / "absent"), + "--format", + "json", + "handoff", + action, + ], + text=True, + capture_output=True, + check=False, + ) + payload = json.loads(result.stdout) + assert result.returncode == 1 + assert payload["reason_code"] == "invalid_continuation_request" + assert "require --goal-id" in payload["reason"] + assert not list(tmp_path.iterdir()) From 68b02bbad6e7b274396cf779739ab24f3bbed33a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:47:11 +0800 Subject: [PATCH 4/5] docs(handoff): explain complete fields and receiver workflow Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../capable-manager-semantic-handoff-v0.md | 4 ++ ...pable-manager-semantic-handoff-v0.zh-CN.md | 4 ++ docs/status-data-contract.md | 61 ++++++++++++------- skills/loopx-project/SKILL.md | 40 +++++++++--- 4 files changed, 78 insertions(+), 31 deletions(-) diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md index 9dc46ef1ec..5458d4cef7 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md @@ -356,6 +356,10 @@ The same contract works when a research worker asks another worker to counterche | Current seam | Target | Retirement condition | | --- | --- | --- | +| `review_packet.py` context assembly | `handoff/project_agent_context.py`, shared by full packet and direct handoff-only | Shared source assembly is implemented; duplicate packet-owned derivation removed | +| `review_packet.py` human judgment, gate display and rendering | Retained presentation adapter | Owns no generic handoff state or new authority | +| Handoff length control, fragmentation and reassembly | Channel codec plus `handoff restore` receiver | Real producer/receiver CLI validates complete fields, strict errors and in-budget compatibility; no Lark/cross-host qualification | +| Handoff request identity, assessment, result and recovery | M2/M3 collaboration owner | Separately qualified; codec digests are neither request identity nor ownership receipts | | Manager inherits Chat planning-only restrictions and JSON preview fallback | Dedicated capable-manager role using native host tools and accepted effect receipts; keep explicit plan-only mode for users who select it | M1 proves ordinary authorized actions and restricted-mode parity; remove contradictory manager instructions | | Manager context inbox plus same-Goal Todo-handoff rules | One collaboration work-request contract with referenced semantic context and intent-specific admission | M2 lossless migration and two-consumer conformance; remove duplicate identities and transitions | | `manager_context` owns generic dispatch/decision semantics in Python | Core TypeScript collaboration domain; Python calls the typed boundary and adapts runtime/channel I/O | Switch one writer after differential tests, then delete the old decision implementation | diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md index 88737e972e..e92db0512e 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md @@ -331,6 +331,10 @@ provider 缺失、索引过期/不完整、超时、零命中,保留 typed gap | 当前边界 | 目标 | 何时删除旧路径 | | --- | --- | --- | +| `review_packet.py` 交接上下文组装 | `handoff/project_agent_context.py`,full packet 与直接 handoff-only 共用 | 共享来源组装已实现,删除 packet 内重复推导 | +| `review_packet.py` 人工判断、gate 展示及渲染 | 保留为展示适配器 | 不拥有通用交接状态,不额外授予权限 | +| 交接长度控制、分片与恢复 | 渠道 codec 与 `handoff restore` 接收入口 | 真实生产→接收 CLI 验证完整字段、严格错误及预算内兼容;不宣称飞书或跨主机验收 | +| 交接请求身份、评估、结果及恢复关系 | M2/M3 collaboration owner | 独立验收;codec digest 不是请求身份或所有权回执 | | 管家继承 Chat planning-only 限制和 JSON 预览兜底 | 独立强能力管家角色,使用原生工具和已接受 effect 回执;用户主动选择时保留 plan-only 模式 | M1 验证普通授权操作、受限模式,再删矛盾指令 | | 管家上下文 inbox 与 same-Goal Todo-handoff 规则并存 | 一个工作请求契约,引用语义背景,按具体意图检查准入 | M2 无损迁移、双消费者验证后,删重复身份与转移 | | `manager_context` 在 Python 掌握通用 dispatch/decision 语义 | Core TS collaboration domain;Python 只调用 typed 边界、适配 runtime/通道 I/O | 差分验证后切单 writer,再删旧判断实现 | diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index c2493e61a8..b0b5e333b0 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1660,7 +1660,7 @@ Review Packet source-of-truth rule: receives a small current instruction; - `loopx review-packet --goal-id --handoff-only` is the copy-minimal form for an already selected or approved target-agent relay: it - prints only the `project_agent_handoff` text in markdown output, while JSON + prints only agent context (or its complete shard set) in markdown output, while JSON output returns a minimized handoff payload instead of the full operator packet. To keep the hot path compact, handoff-only JSON does not expose a separate `handoff_followthrough_summary` prose field; that prose remains available in @@ -1676,28 +1676,43 @@ Review Packet source-of-truth rule: block, and carry only the target goal guard, minimal-context rule, source label, optional compact post-handoff delivery scale, optional delivery contract, forwarding/execution boundary, command, and stop condition; -- overflow handling is lossless: when the prepared handoff still exceeds the - 16 line / 1800 character budget after the lossless command-block - normalization, it is split into ordered, independently verifiable shards - instead of dropping prefixed sections. Shard 0 stays in - `project_agent_handoff` with the same position and field semantics; - continuation shards are exposed as `project_agent_handoff_fragments`, - accompanied by a compact `handoff_fragment_manifest` (stable set id, total, - original size, per-shard sizes). Each shard starts with one - `` envelope line carrying the content-derived set - id, the `i`/`n` sequence, a per-shard payload checksum, the previous-shard - hash chain, and the full-content SHA-256. Receivers concatenate by sequence - only after every payload checksum, the hash chain, set consistency, and the - full-content digest verify; missing shards, out-of-order delivery, - duplicate/conflicting imports, or altered content fail with explicit errors. - The set id and every shard are deterministic from the handoff content, so - regenerating the same handoff yields identical shards and re-importing a - shard is a no-op. Fenced command blocks are never torn across a shard - boundary (an unfinished fence is closed and re-opened with strip-only - transport markers), and over-long single lines use continuation markers, - so reassembly restores the original handoff byte-for-byte. A handoff that - fits the budget carries no envelope and stays byte-identical to the legacy - single-text output; +- overflow preserves the **prepared handoff text**, after existing command-block + normalization and bounded status projection. This is not a promise to preserve + raw source documents or the original multiline command spelling. No sections + are deleted to fit the transport budget. Complete `project_agent_handoff` and + `handoff_text` fields always contain the entire prepared text, including on + overflow; consumers ignoring new keys still receive the complete instruction. + `handoff_interface_budget` and handoff-only size fields measure that complete + text and report `within_budget=false` when appropriate. The 16 line / 1800 + character limit is a **per-shard** transport budget, not a total semantic cap; +- on overflow, `project_agent_handoff_fragments` contains **all ordered shards**, + including index 0, with `handoff_fragment_manifest` describing the set and + original/per-shard sizes. Markdown renders that set once. On the in-budget + path no extra keys or envelopes are added, preserving existing output. + Each shard carries a `` envelope with content-derived + set id, sequence, payload checksum, previous-shard hash and full-content digest. + Fences are closed/reopened and long lines continued using reserved transport + markers. Reassembly restores prepared text byte-for-byte; +- receivers use `loopx handoff restore --input handoff.json --format json` for + full or handoff-only producer JSON, or add `--input-format markdown` for raw + sharded Markdown (full packet or handoff-only). `--input -` reads stdin. + Unfragmented Markdown must be handoff-only; unfragmented full packets should + use JSON. JSON is recommended because Markdown renderers may strip comments. + Plain unframed text has no integrity proof. Reserved envelope, continuation + and fence markers cannot be supplied as oversized source content; +- restoration strictly rejects missing, reordered, duplicate, mixed-set or + modified shards, malformed envelopes, inconsistent complete text fields and + mismatched manifests. Failures exit nonzero, expose an `error_code`, and return + no partial `handoff_text`. Collect all parts in order and retry with unchanged + producer output. There is no incremental/idempotent collector: repeated + generation is deterministic, but duplicate parts in one import are errors; +- checksums prove content consistency, not sender authentication, request + identity, receiver acceptance or execution authority. Equal text from two + requests must not be business-deduplicated by set id. Restore never executes + content, changes Todo/claim/lease, starts a session or opens the registry. + Recheck current goal, scope and applicable gates after restoring; use existing + `handoff prepare/inspect/adopt` for ownership where applicable. This CLI path + does not qualify Lark delivery, cross-host recovery or arbitrary renderers; - `handoff_delivery_contract` is optional structured guidance derived from the current `handoff_readiness` plus `project_asset.execution_profile`, not a target-specific hack. When repeated small-scale follow-through reaches the diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index c96d821930..1b23157ec0 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -688,22 +688,46 @@ subcommand: loopx --format json review-packet --goal-id ``` -When the human/controller decision is already approved and the only remaining -step is to relay the target-agent instruction, use the minimal handoff form: +To relay current target-agent context within existing authorization, use the +handoff form. Ordinary handoffs add no new approval; actual operator gates +still apply: ```bash loopx review-packet --goal-id --handoff-only ``` -This command is read-only. It packages the current status into the same Review -Packet shape as the dashboard; it does not append human reward, append an -operator gate, refresh state, grant write-control, or authorize production -actions. `--handoff-only` only strips the human decision wrapper from markdown -output; JSON output returns a minimized handoff payload with `handoff_text` -instead of the full operator packet. If the selected queue item is legacy/raw +This read-only command assembles agent context directly from current status. +The full Review Packet consumes the same context and adds human presentation. +Neither path grants authority or changes work state. JSON `handoff_text` and +`project_agent_handoff` always contain complete prepared text. On overflow, +`project_agent_handoff_fragments` contains all ordered shards including index 0; +Markdown prints the shard set. The complete text may exceed 16 lines / 1800 +characters; each shard fits that budget. In-budget output keeps its old shape. If the selected queue item is legacy/raw fallback rather than project-asset-backed, do not treat raw queue fields as owner, gate, or stop-condition authority. +Collect and restore the entire producer output before using a sharded handoff: + +```bash +loopx --format json review-packet --goal-id --handoff-only > handoff.json +loopx handoff restore --input handoff.json --format json +``` + +For raw sharded Markdown use `--input-format markdown`; `--input -` reads stdin. +JSON avoids relying on a renderer preserving HTML comment envelopes. A complete +unfragmented handoff-only Markdown input is accepted as plain text without an +integrity claim; use JSON for unfragmented full Review Packets. Missing, +reordered, duplicate, mixed or changed parts fail with a nonzero exit and +`error_code`, without partial text. Obtain the original complete output and +retry. There is no collector or business-request deduplication by content hash. + +A successful restore is content recovery only: it does not execute a command, +adopt work, change Todo/claim/lease or start a session. Check current goal, +source freshness, write scope and real gates using existing status/quota and +ownership workflows. `handoff prepare/inspect/adopt` retains its separate +ownership contract; restore is not a replacement for it. Do not infer sender +identity or permission from a checksum. + Read the packet in order: - `人只需判断`: the user or controller decides in the dashboard/operator view or From 89de36754848a3763bbae87a3e7a759f857cdbfa Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:19:05 +0800 Subject: [PATCH 5/5] fix(handoff): reject Markdown fragments missing verifiable envelopes Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/status-data-contract.md | 9 +++- loopx/cli_commands/handoff_restore.py | 8 ++-- .../handoff/handoff_fragments.py | 34 ++++++++++++-- skills/loopx-project/SKILL.md | 9 ++-- tests/test_handoff_receiver.py | 45 ++++++++++++++++++- 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index b0b5e333b0..6aa8e6976d 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1698,8 +1698,13 @@ Review Packet source-of-truth rule: sharded Markdown (full packet or handoff-only). `--input -` reads stdin. Unfragmented Markdown must be handoff-only; unfragmented full packets should use JSON. JSON is recommended because Markdown renderers may strip comments. - Plain unframed text has no integrity proof. Reserved envelope, continuation - and fence markers cannot be supplied as oversized source content; + A Markdown fragment title with a missing envelope, or an envelope moved off + the start of its line, fails restoration instead of becoming unverified plain + text. If a renderer removes both titles and envelopes, the remaining text + cannot be identified as fragmented; obtain the original JSON output. + Plain unframed text has no integrity proof. Reserved fragment titles, + envelopes, continuation and fence markers cannot be supplied as oversized + source content; - restoration strictly rejects missing, reordered, duplicate, mixed-set or modified shards, malformed envelopes, inconsistent complete text fields and mismatched manifests. Failures exit nonzero, expose an `error_code`, and return diff --git a/loopx/cli_commands/handoff_restore.py b/loopx/cli_commands/handoff_restore.py index bd97b19b3f..3c29d9dd72 100644 --- a/loopx/cli_commands/handoff_restore.py +++ b/loopx/cli_commands/handoff_restore.py @@ -20,15 +20,13 @@ def restore_handoff_input(text: str, *, input_format: str) -> str: if input_format == "markdown": if not text.strip(): raise HandoffShardError("missing", "empty handoff input") - if ENVELOPE_PREFIX in text: - return restore_handoff_text(text) - if text.startswith("【LoopX Review Packet】"): + if text.startswith("【LoopX Review Packet】") and ENVELOPE_PREFIX not in text: raise HandoffShardError( "input", "use full packet JSON or handoff-only Markdown for an unfragmented packet", ) - # Unframed input is content, not integrity-verified transport. - return text + # Unframed input remains content; apparent fragments must verify first. + return restore_handoff_text(text) try: value = json.loads(text) except json.JSONDecodeError as exc: diff --git a/loopx/control_plane/handoff/handoff_fragments.py b/loopx/control_plane/handoff/handoff_fragments.py index 945ff1419f..0a9f2a5247 100644 --- a/loopx/control_plane/handoff/handoff_fragments.py +++ b/loopx/control_plane/handoff/handoff_fragments.py @@ -45,6 +45,7 @@ r"p=(?P

-|[0-9a-f]{16}) " r"d=(?P[0-9a-f]{64})-->$" ) +SHARD_TITLE_PREFIXES = ("【交接分片 ", "【给项目 Agent · 交接分片 ") # Transport-only markers. They never occur in prepared handoff content; the # splitter rejects input that already contains them (fail closed). @@ -112,8 +113,10 @@ def _envelope_line( def _assert_no_transport_markers(lines: list[str]) -> None: for line in lines: - if line.startswith(ENVELOPE_PREFIX) or line.startswith( - LINE_CONTINUATION_MARKER + if ( + line.startswith(ENVELOPE_PREFIX) + or line.startswith(LINE_CONTINUATION_MARKER) + or line.lstrip().startswith(SHARD_TITLE_PREFIXES) ): raise HandoffShardError( "reserved_marker", @@ -581,7 +584,32 @@ def restore_handoff_text(value: str | Iterable[str]) -> str: """ if isinstance(value, str): - if any(line.startswith(ENVELOPE_PREFIX) for line in value.split("\n")): + lines = value.split("\n") + envelopes = [ + index + for index, line in enumerate(lines) + if line.startswith(ENVELOPE_PREFIX) + ] + titles = [ + index + for index, line in enumerate(lines) + if line.lstrip().startswith(SHARD_TITLE_PREFIXES) + ] + if any( + ENVELOPE_PREFIX in line and not line.startswith(ENVELOPE_PREFIX) + for line in lines + ): + raise HandoffShardError( + "envelope", "handoff shard envelope is not at the start of a line" + ) + if titles and ( + len(titles) != len(envelopes) + or any(index + 1 not in envelopes for index in titles) + ): + raise HandoffShardError( + "envelope", "handoff fragment title is missing its verifiable envelope" + ) + if envelopes: return reassemble_handoff_shards(extract_handoff_shards(value)) return value shard_texts = list(value) diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 1b23157ec0..e18c7d7fc7 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -714,9 +714,12 @@ loopx handoff restore --input handoff.json --format json ``` For raw sharded Markdown use `--input-format markdown`; `--input -` reads stdin. -JSON avoids relying on a renderer preserving HTML comment envelopes. A complete -unfragmented handoff-only Markdown input is accepted as plain text without an -integrity claim; use JSON for unfragmented full Review Packets. Missing, +JSON avoids relying on a renderer preserving HTML comment envelopes. A fragment +title without its envelope, or an indented envelope, is rejected rather than +returned as plain text. If both title and envelope disappear, only the original +JSON output can establish completeness. Complete unfragmented handoff-only +Markdown is accepted as plain text without an integrity claim; use JSON for +unfragmented full Review Packets. Missing, reordered, duplicate, mixed or changed parts fail with a nonzero exit and `error_code`, without partial text. Obtain the original complete output and retry. There is no collector or business-request deduplication by content hash. diff --git a/tests/test_handoff_receiver.py b/tests/test_handoff_receiver.py index c08e594ee3..9cce1df6d7 100644 --- a/tests/test_handoff_receiver.py +++ b/tests/test_handoff_receiver.py @@ -12,7 +12,11 @@ from loopx.control_plane.handoff.project_agent_context import ( build_project_agent_handoff, ) -from loopx.control_plane.handoff.handoff_fragments import split_handoff_text +from loopx.control_plane.handoff.handoff_fragments import ( + ENVELOPE_PREFIX, + render_handoff_transport, + split_handoff_text, +) from loopx.review_packet import build_review_packet @@ -130,6 +134,45 @@ def test_restore_cli_success_does_not_execute_or_adopt(tmp_path): assert "RETURN-VALIDATION" in payload["handoff_text"] +def test_restore_cli_accepts_complete_markdown_transport(tmp_path): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + markdown = render_handoff_transport( + packet["project_agent_handoff"], packet["project_agent_handoff_fragments"] + ) + code, payload = receive(tmp_path, markdown, input_format="markdown") + assert code == 0 + assert payload == {"ok": True, "handoff_text": packet["project_agent_handoff"]} + + +def test_restore_cli_keeps_unframed_markdown_without_transport_titles(tmp_path): + text = "目标校验:g\n交接分片只是普通讨论文字,不是传输标题" + code, payload = receive(tmp_path, text, input_format="markdown") + assert code == 0 + assert payload == {"ok": True, "handoff_text": text} + + +@pytest.mark.parametrize("mutation", ["stripped", "indented", "one_missing"]) +def test_restore_cli_rejects_markdown_with_unverifiable_envelopes(tmp_path, mutation): + packet = build_review_packet(status_fixture(), goal_id="handoff-contract") + markdown = render_handoff_transport( + packet["project_agent_handoff"], packet["project_agent_handoff_fragments"] + ) + assert len(packet["project_agent_handoff_fragments"]) > 1 + lines = markdown.split("\n") + if mutation == "stripped": + lines = [line for line in lines if not line.startswith(ENVELOPE_PREFIX)] + elif mutation == "indented": + lines = [ + " " + line if line.startswith(ENVELOPE_PREFIX) else line for line in lines + ] + else: + lines.remove(next(line for line in lines if line.startswith(ENVELOPE_PREFIX))) + code, payload = receive(tmp_path, "\n".join(lines), input_format="markdown") + assert code == 1 + assert payload["error_code"] == "envelope" + assert "handoff_text" not in payload + + @pytest.mark.parametrize( "mutation,expected", [