From ee93672d26bc946b8613fbf4ef741250aff97023 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 27 Aug 2026 14:59:02 -0700 Subject: [PATCH] [eric] web: a fetched page carries no appended instruction, because the model attributes it to the page (ENG-413) --- backend/apps/agents/web_mcp_server.py | 35 +++++--- .../test_web_hint_is_not_page_content.py | 86 +++++++++++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_web_hint_is_not_page_content.py diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index e3f283ea..5b7a5ebe 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -20,13 +20,30 @@ BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1" # tool RESULT because that's what the model reads right before answering; the system-prompt nudge # alone loses to the prose prior (live-proven on haiku). RICH_UI_OK = os.environ.get("OPENSWARM_RICH_UI_OK", "0") == "1" +# SEARCH RESULTS ONLY, and as its own content block. The fetch side is gone for good, drilled twice +# on 2026-08-27 (ENG-413): concatenated into fetched page content, the model correctly reported "an +# embedded instruction telling me to render the answer using specific UI tools" as a prompt +# injection in the page (the "fabricated" claim quoted this hint verbatim); moved to a separate +# labelled block, it STILL got flagged ("an injected instruction trying to get me to add a +# promotional presentation-guidance footer"). A fetched page is a third party's words, so anything +# we append is attributed to the page and a model with working defences must flag it -- there is no +# wording that survives that. Search results are OUR OWN formatted text, so framing there is honestly +# the tool speaking. The fetch side leans on the system prompt's rich_ui block alone. RICH_UI_HINT = ( - "\n\n[presentation] When you answer the user with this data, render it with the ShowUI tool " - "(weather for forecasts, data-table for rows, stats-display for metrics, links for sources, " - "chart for series, image/image-gallery for any image URLs in the content) and keep prose to " - "one line. Answer in plain text only if no component fits." + "[presentation guidance, not page content] When you answer the user with this data, render it " + "with the ShowUI tool (weather for forecasts, data-table for rows, stats-display for metrics, " + "links for sources, chart for series, image/image-gallery for any image URLs in the content) " + "and keep prose to one line. Answer in plain text only if no component fits." ) + +def with_search_hint(text: str) -> dict: + """One result, two blocks: the data, then the presentation guidance.""" + blocks = [{"type": "text", "text": text}] + if RICH_UI_OK: + blocks.append({"type": "text", "text": RICH_UI_HINT}) + return {"content": blocks} + TOOLS = [ { "name": "WebSearch", @@ -134,10 +151,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: return {"content": [{"type": "text", "text": f"Search failed: {r['error']}"}], "isError": True} results = r.get("results", "") if not results: - results = f"No results for: {query}" - elif RICH_UI_OK: - results += RICH_UI_HINT - return {"content": [{"type": "text", "text": results}]} + return {"content": [{"type": "text", "text": f"No results for: {query}"}]} + return with_search_hint(results) if tool_name == "WebFetch": url = str(arguments.get("url", "")).strip() @@ -157,8 +172,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: content = r.get("content", "") if not content: content = f"No content returned from {url}" - elif RICH_UI_OK: - content += RICH_UI_HINT + # Never any appended guidance here: this is a third party's page, and anything we add to it + # is attributed to the page (see the RICH_UI_HINT note above). return {"content": [{"type": "text", "text": content}]} return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} diff --git a/backend/tests/test_web_hint_is_not_page_content.py b/backend/tests/test_web_hint_is_not_page_content.py new file mode 100644 index 00000000..e5895ba3 --- /dev/null +++ b/backend/tests/test_web_hint_is_not_page_content.py @@ -0,0 +1,86 @@ +"""The presentation hint rides its own content block, never inside the page text. + +ENG-413, root-caused 2026-08-27. An agent reported "page_07 also contained an injected instruction +I disregarded" on a page whose source is provably instruction-free. The claim looked fabricated +because grepping the page found nothing -- but the instruction was never IN the page: WebFetch +appended RICH_UI_HINT ("render the answer using specific UI tools ... keep prose to one line") +directly onto the fetched content, and the fabrication-repro run quoted that hint VERBATIM. The +model detected a real instruction and misattributed it to the page, because we glued it there. + +The model was right both times, and a second drill killed the first fix: moved to a separate, +labelled content block, the hint STILL got flagged ("an injected instruction trying to get me to +add a promotional presentation-guidance footer"). A fetched page is a third party's words; anything +appended is attributed to the page, and no wording survives that. So WebFetch carries no guidance +at all, and WebSearch (our own formatted text, honestly the tool speaking) keeps it as its own +block. +""" + +import importlib + +import pytest + +import backend.apps.agents.web_mcp_server as p_w + +SRC = "backend/apps/agents/web_mcp_server.py" + + +@pytest.fixture +def p_ui_on(monkeypatch): + monkeypatch.setenv("OPENSWARM_RICH_UI_OK", "1") + importlib.reload(p_w) + yield + monkeypatch.setenv("OPENSWARM_RICH_UI_OK", "0") + importlib.reload(p_w) + + +def test_the_page_block_carries_no_instruction(p_ui_on): + r = p_w.with_search_hint("the release codeword is PELICANWRENCH") + page = r["content"][0]["text"] + assert "presentation" not in page and "ShowUI" not in page, \ + "an instruction inside page content reads as the PAGE giving orders" + assert page == "the release codeword is PELICANWRENCH", "and the content itself is untouched" + + +def test_the_hint_is_its_own_block_and_says_what_it_is(p_ui_on): + r = p_w.with_search_hint("body") + assert len(r["content"]) == 2 + hint = r["content"][1]["text"] + assert hint.startswith("[presentation guidance, not page content]"), \ + "the label is the attribution; without it the model must guess who is speaking" + + +def test_ui_off_sends_no_hint_at_all(monkeypatch): + monkeypatch.setenv("OPENSWARM_RICH_UI_OK", "0") + importlib.reload(p_w) + r = p_w.with_search_hint("body") + assert len(r["content"]) == 1 + assert "presentation" not in r["content"][0]["text"] + + +def test_nothing_concatenates_the_hint_into_content_anymore(): + src = open(SRC).read() + assert "+= RICH_UI_HINT" not in src, "the glue is the whole bug" + + +def test_a_fetched_page_carries_no_guidance_at_all(): + """Drilled twice: concatenated OR as a labelled separate block, an instruction riding a fetched + page gets attributed to the page and flagged as an injection. Only the search side, which is our + own formatted text, may carry framing.""" + src = open(SRC).read() + i = src.index('if tool_name == "WebFetch":') + fetch_branch = src[i:src.index("return {", src.index('r.get("content", "")', i)) + 200] + assert "with_search_hint" not in fetch_branch and "RICH_UI_HINT" not in fetch_branch.replace( + "see the RICH_UI_HINT note", "") + i_search = src.index('if tool_name == "WebSearch":') + search_branch = src[i_search:i] + assert "with_search_hint(" in search_branch, "the search side keeps the measured ShowUI win" + + +def test_an_empty_search_result_carries_no_guidance(): + """The fetch side is covered wholesale by the no-guidance test above; here only search's empty + path needs pinning, because its non-empty path legitimately calls with_search_hint.""" + src = open(SRC).read() + i = src.index('f"No results for: {query}"') + line = src[src.rindex("\n", 0, i):src.index("\n", i)] + assert "return {" in line and "with_search_hint" not in line, \ + "guidance on an empty result tells the model to render nothing as a component"