fix(memory): narrow scrub surface to known wrapper boundaries
Reviewer pushback on the original boundary-hardening commits — three overreach points pulled plugin-specific policy into shared core paths: 1. gateway/run.py hardcoded a '## Honcho Context' literal split for vision-LLM output. Plugin-format heading in framework code; could truncate legitimate output naturally containing that header. Drop the literal split; keep generic sanitize_context (the wrapper strip is plugin-agnostic). Plugin-specific cleanup belongs at the provider boundary, not the shared gateway path. 2. run_agent.run_conversation scrubbed user_message and persist_user_message before the conversation loop. User text is sacred — if a user types a literal <memory-context> tag we must not silently delete it. The producer (build_memory_context_block) is the only legitimate emitter; user input should never need the reverse op. 3. _build_assistant_message scrubbed model output before persistence. Same hazard: would silently mutate legitimate documentation/code the model emits containing the literal markers. The streaming scrubber catches real leaks delta-by-delta before content is concatenated; persist-time scrub was redundant belt-and-suspenders. 4. _fire_stream_delta stripped leading newlines from every delta unless a paragraph break flag was set. Mid-stream '\n' is legitimate markdown — lists, code fences, paragraph breaks — and chunk boundaries are arbitrary. Narrow lstrip to the very first delta of the stream only (so stale provider preamble still gets cleaned on turn start, but mid-stream formatting survives). Plus: build_memory_context_block now logs a warning when its defensive sanitize_context strips something — surfaces buggy providers returning pre-wrapped text instead of silently double-fencing. Net architectural change: scrub surface collapses from 8 sites to 3 (StreamingContextScrubber on output deltas, plugin→backend send, build_memory_context_block input-validation). Plugin-specific strings stay out of shared runtime paths. User input and persisted assistant output are no longer mutated. Tests: rescoped TestMemoryContextSanitization (helper-correctness only, no source-inspection of removed call sites), updated vision tests to drop '## Honcho Context' literal-split assertions, updated _build_assistant_message persistence test to assert preservation. Added: cross-turn scrubber reset, build_memory_context_block warn-on- violation, mid-stream newline preservation (plain + code fence).
This commit is contained in:
@@ -148,3 +148,64 @@ class TestSanitizeContextUnchanged:
|
||||
)
|
||||
out = sanitize_context(leaked).strip()
|
||||
assert out == "Visible"
|
||||
|
||||
|
||||
class TestStreamingContextScrubberCrossTurn:
|
||||
"""A scrubber instance is reused across turns (per agent). reset() must
|
||||
clear any held state so a partial-tag tail from turn N doesn't bleed
|
||||
into turn N+1's first delta."""
|
||||
|
||||
def test_reset_clears_held_partial_tag(self):
|
||||
s = StreamingContextScrubber()
|
||||
# Feed a partial open-tag prefix that gets held back as buffer.
|
||||
out_turn_1 = s.feed("answer<memo")
|
||||
assert out_turn_1 == "answer"
|
||||
|
||||
# Reset for next turn — buffer must clear.
|
||||
s.reset()
|
||||
|
||||
# New turn: plain text starting with a "<m" must NOT be treated as
|
||||
# the continuation of the held "<memo".
|
||||
out_turn_2 = s.feed("<marker>fresh content")
|
||||
assert out_turn_2 == "<marker>fresh content"
|
||||
|
||||
def test_reset_clears_in_span_state(self):
|
||||
s = StreamingContextScrubber()
|
||||
s.feed("text<memory-context>secret-tail")
|
||||
# Mid-span state held — without reset, subsequent text would be
|
||||
# discarded until we see </memory-context>.
|
||||
s.reset()
|
||||
out = s.feed("post-reset visible text")
|
||||
assert out == "post-reset visible text"
|
||||
|
||||
|
||||
class TestBuildMemoryContextBlockWarnsOnViolation:
|
||||
"""Providers must return raw context — not pre-wrapped. When they do,
|
||||
we strip and warn so the buggy provider surfaces."""
|
||||
|
||||
def test_provider_emitting_wrapper_warns(self, caplog):
|
||||
import logging
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
|
||||
prewrapped = (
|
||||
"<memory-context>\n"
|
||||
"[System note: ...]\n\n"
|
||||
"real fact\n"
|
||||
"</memory-context>"
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="agent.memory_manager"):
|
||||
out = build_memory_context_block(prewrapped)
|
||||
|
||||
assert any("contract violation" in rec.message for rec in caplog.records)
|
||||
assert out.count("<memory-context>") == 1
|
||||
assert out.count("</memory-context>") == 1
|
||||
|
||||
def test_clean_provider_output_does_not_warn(self, caplog):
|
||||
import logging
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent.memory_manager"):
|
||||
out = build_memory_context_block("plain fact about user")
|
||||
|
||||
assert not any("contract violation" in rec.message for rec in caplog.records)
|
||||
assert "plain fact about user" in out
|
||||
|
||||
Reference in New Issue
Block a user