Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 77 additions & 17 deletions loopx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,55 @@ def redact_response_markdown(
)


def _review_openings(text: str, code_ticks: int = 0) -> tuple[list[int], int, int | None]:
"""Locate control markers outside backtick examples and escaped text.

Skip decoded JSON bodies too: a legacy message can itself quote markers
or backticks without creating another envelope or changing code context.
"""
openings = []
tokens = re.compile(r"`+|" + re.escape(CHAT_REVIEW_OPEN_TAG))
cursor = 0
while token := tokens.search(text, cursor):
cursor = token.end()
value = token.group()
if not code_ticks:
preceding = token.start() - 1
while preceding >= 0 and text[preceding] == "\\":
preceding -= 1
if (token.start() - 1 - preceding) % 2:
continue
if value.startswith("`"):
if not code_ticks:
code_ticks = len(value)
elif len(value) == code_ticks:
code_ticks = 0
else:
body_start = len(text) - len(text[cursor:].lstrip())
try:
_, body_end = json.JSONDecoder().raw_decode(text, body_start)
except json.JSONDecodeError:
body_end = None
if code_ticks:
if text[body_start:body_start + 1] == "{":
# Backticks in metadata cannot close a visible example.
# Only release a JSON example after its complete envelope
# and the code delimiter have both arrived.
if body_end is None:
return openings, code_ticks, token.start()
close = len(text) - len(text[body_end:].lstrip())
if not text.startswith(CHAT_REVIEW_CLOSE_TAG, close):
return openings, code_ticks, token.start()
cursor = close + len(CHAT_REVIEW_CLOSE_TAG)
if not any(len(ticks.group()) == code_ticks for ticks in re.finditer(r"`+", text[cursor:])):
return openings, code_ticks, token.start()
else:
openings.append(token.start())
if body_end is not None:
cursor = body_end
return openings, code_ticks, None


class VisibleResponseStreamFilter:
"""Stream safe operator text while withholding the structured review envelope."""

Expand All @@ -420,6 +469,7 @@ def __init__(self, *, protected_paths: Iterable[Path | str] = ()) -> None:
self.marker_pending = ""
self.visible_pending = ""
self.envelope_started = False
self._code_ticks = 0

@staticmethod
def _protected_root_atoms(root: str) -> tuple[tuple[tuple[str, bool], ...], ...]:
Expand Down Expand Up @@ -528,28 +578,36 @@ def feed(self, chunk: str) -> str:
if self.envelope_started:
return ""
self.marker_pending += str(chunk or "")
marker_at = self.marker_pending.find(CHAT_REVIEW_OPEN_TAG)
if marker_at >= 0:
visible = self.marker_pending[:marker_at]
self.marker_pending = ""
self.envelope_started = True
return self._accept_visible(visible, final=True)
suffix_length = 0
limit = min(len(self.marker_pending), len(CHAT_REVIEW_OPEN_TAG) - 1)
for length in range(1, limit + 1):
if self.marker_pending.endswith(CHAT_REVIEW_OPEN_TAG[:length]):
suffix_length = length
if suffix_length:
visible = self.marker_pending[:-suffix_length]
self.marker_pending = self.marker_pending[-suffix_length:]
else:
visible = self.marker_pending
cut = len(self.marker_pending) - suffix_length
# A delimiter run or escape can straddle chunks, just like the marker.
# Keep it whole so one split cannot change which text is code.
while cut and self.marker_pending[cut - 1] == "\\":
cut -= 1
ticks = re.search(r"\\*`+$", self.marker_pending[:cut])
if ticks:
cut = ticks.start()
visible = self.marker_pending[:cut]
openings, self._code_ticks, uncertain = _review_openings(visible, self._code_ticks)
if openings:
self.marker_pending = ""
self.envelope_started = True
return self._accept_visible(visible[:openings[0]], final=True)
if uncertain is not None:
cut = uncertain
visible = visible[:cut]
self.marker_pending = self.marker_pending[cut:]
return self._accept_visible(visible, final=False)

def finish(self) -> str:
if not self.envelope_started:
self.visible_pending += self.marker_pending
openings, _, uncertain = _review_openings(self.marker_pending, self._code_ticks)
cut = openings[0] if openings else uncertain
self.visible_pending += self.marker_pending[:cut]
self.marker_pending = ""
return self._accept_visible("", final=True)

Expand Down Expand Up @@ -812,8 +870,12 @@ def parse_agent_response(
team_plan_context: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
protected = tuple(protected_paths)
start = raw_text.rfind(CHAT_REVIEW_OPEN_TAG)
openings, _, uncertain = _review_openings(raw_text)
if uncertain is not None:
openings.append(uncertain)
start = openings[-1] if openings else -1
end = raw_text.rfind(CHAT_REVIEW_CLOSE_TAG)
visible = raw_text[:openings[0]].strip() if openings else raw_text.strip()
if start >= 0 and end > start:
body = raw_text[start + len(CHAT_REVIEW_OPEN_TAG) : end].strip()
try:
Expand All @@ -825,7 +887,6 @@ def parse_agent_response(
# Resolve only inside a complete envelope, then use the same
# redaction and typed metadata normalization as legacy responses.
if payload.get("message") == "":
visible = raw_text[:start].split(CHAT_REVIEW_OPEN_TAG, 1)[0].strip()
payload = {**payload, "message": visible}
try:
return normalize_agent_response(
Expand All @@ -839,7 +900,7 @@ def parse_agent_response(
# Preserve prose, but admit none of the unvalidated actions.
answer = payload.get("message")
if not isinstance(answer, str) or not answer.strip():
answer = raw_text[:start].split(CHAT_REVIEW_OPEN_TAG, 1)[0].strip()
answer = visible
if not answer:
raise
summary = "答复正文已保留,但操作或委派元数据未通过校验;相关操作和委派尚未提交。"
Expand All @@ -862,14 +923,13 @@ def parse_agent_response(
"protected_action": None,
"gate": None,
}
raw_text = raw_text[:start]
raw_text = visible
elif start >= 0:
# The review envelope is an authority boundary, not display text. A
# provider can finish after emitting a complete JSON object but before
# emitting the closing tag. In that case retain only a human-readable
# answer and fail closed for proposals, gates, and protected actions.
# Never leak the raw protocol fragment into downstream transports.
visible = raw_text[:start].strip()
body = raw_text[start + len(CHAT_REVIEW_OPEN_TAG) :].strip()
salvaged_message = ""
try:
Expand Down
55 changes: 55 additions & 0 deletions tests/test_chat_response_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,61 @@ def test_compact_answer_never_reveals_an_earlier_hidden_envelope() -> None:
assert parse_agent_response(raw)["message"] == "Answer."


@pytest.mark.parametrize("example", [
"Use `<loopx-review-json>` as the opening marker.",
'Use ``<loopx-review-json>{"message":"example"}</loopx-review-json>`` literally.',
'```json\n<loopx-review-json>{"message":"example"}</loopx-review-json>\n```',
r"Escaped \` text and \<loopx-review-json> are literal.",
r"A path ending with a slash, `C:\`, is code.",
])
def test_quoted_review_examples_survive_compact_answers_and_stream_splits(example):
visible = example + "\nThe complete explanation continues here."
raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": ""}) + CHAT_REVIEW_CLOSE_TAG

assert parse_agent_response(raw)["message"] == visible
for split in range(len(raw) + 1):
stream = VisibleResponseStreamFilter()
assert (stream.feed(raw[:split]) + stream.feed(raw[split:]) + stream.finish()).strip() == visible


def test_review_example_without_an_envelope_is_text_and_admits_no_actions():
example = '```json\n<loopx-review-json>{"message":"example","proposals":[{"kind":"todo","text":"must not run"}]}</loopx-review-json>\n```'
response = parse_agent_response(example)
assert response["message"] == example
assert response["proposals"] == [] and response["gate"] is None
assert response["protected_action"] is None


@pytest.mark.parametrize("metadata", [
{"context_handoff": [{"brief": "invalid"}]},
{"proposals": [{"kind": "todo", "text": "must not run"}]},
])
def test_quoted_markers_do_not_restore_an_earlier_hidden_envelope(metadata):
visible = "The `<loopx-review-json>` example is followed by the full answer."
hidden = CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "hidden"}) + CHAT_REVIEW_CLOSE_TAG
raw = visible + "\n" + hidden + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", **metadata})
response = parse_agent_response(raw)
assert response["message"] == visible
assert response["proposals"] == [] and response["gate"] is None
assert response["protected_action"] is None and not response.get("context_handoff")


def test_legacy_message_can_contain_literal_review_markers():
message = "Literal <loopx-review-json> and </loopx-review-json> inside the answer."
raw = CHAT_REVIEW_OPEN_TAG + json.dumps({"message": message}) + CHAT_REVIEW_CLOSE_TAG
assert parse_agent_response(raw)["message"] == message


@pytest.mark.parametrize("summary", ["hidden", "hidden ` detail"])
def test_unclosed_code_does_not_expose_a_possible_review_envelope(summary):
visible = "An unfinished `example."
raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", "GlobalSummary": summary}) + CHAT_REVIEW_CLOSE_TAG
assert parse_agent_response(raw)["message"] == visible
for split in range(len(raw) + 1):
stream = VisibleResponseStreamFilter()
assert (stream.feed(raw[:split]) + stream.feed(raw[split:]) + stream.finish()).strip() == visible


@pytest.mark.parametrize("suffix", ["", CHAT_REVIEW_CLOSE_TAG])
def test_invalid_compact_envelope_cannot_restore_metadata(suffix) -> None:
raw = 'Readable answer\n<loopx-review-json>{"message":"","proposals":[{"kind":"todo","text":"must not run"}],' + suffix
Expand Down
Loading