mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[eric] web: a fetched page carries no appended instruction, because the model attributes it to the page (ENG-413)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user