From e46013f2348fc62d75e58d35a78a5f36e762d1c2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 5 Jul 2026 17:52:53 -0700 Subject: [PATCH] [eric] browser-agent: cap result summary+action-log (CLI hard-rejects >25K-token MCP results) --- .../apps/agents/browser_agent_mcp_server.py | 20 +++++- .../tests/test_browser_agent_mcp_format.py | 67 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_browser_agent_mcp_format.py diff --git a/backend/apps/agents/browser_agent_mcp_server.py b/backend/apps/agents/browser_agent_mcp_server.py index 6e3b71a5..d2c6df06 100644 --- a/backend/apps/agents/browser_agent_mcp_server.py +++ b/backend/apps/agents/browser_agent_mcp_server.py @@ -190,6 +190,18 @@ def call_backend(tasks: list[dict]) -> dict: MAX_IMAGE_B64_BYTES = 400_000 +MAX_SUMMARY_CHARS = 16_000 +MAX_ACTION_LOG_ENTRIES = 40 + + +def p_cap_summary(text: str) -> str: + """Head+tail split: the CLI hard-rejects tool results past ~25K tokens, and a vanished report is worse than a trimmed one.""" + if len(text) <= MAX_SUMMARY_CHARS: + return text + head = text[: MAX_SUMMARY_CHARS - 4_000] + tail = text[-3_500:] + omitted = len(text) - len(head) - len(tail) + return f"{head}\n\n[... {omitted} chars of the report omitted ...]\n\n{tail}" def p_sniff_image_mime(b64: str) -> str: @@ -233,12 +245,16 @@ def format_result(result: dict) -> dict: action_log = result.get("action_log", []) lines = [f"**Browser Agent Result** (browser: {browser_id}, session: {session_id})", ""] - lines.append(f"**Summary:** {summary}") + lines.append(f"**Summary:** {p_cap_summary(summary)}") if action_log: lines.append("") lines.append("**Actions taken:**") - for i, entry in enumerate(action_log, 1): + entries = action_log[-MAX_ACTION_LOG_ENTRIES:] + omitted = len(action_log) - len(entries) + if omitted > 0: + lines.append(f" (... {omitted} earlier actions omitted ...)") + for i, entry in enumerate(entries, omitted + 1): tool = entry.get("tool", "?") inp = entry.get("input", {}) ms = entry.get("elapsed_ms", 0) diff --git a/backend/tests/test_browser_agent_mcp_format.py b/backend/tests/test_browser_agent_mcp_format.py new file mode 100644 index 00000000..6800c577 --- /dev/null +++ b/backend/tests/test_browser_agent_mcp_format.py @@ -0,0 +1,67 @@ +"""Browser-agent MCP result payload caps. + +The bug: format_result forwarded the sub-agent's summary and full action log +uncapped. The bundled Claude CLI rejects any MCP tool result past ~25K tokens +(the model never sees the report at all), and repeated near-cap results were +the refill mass behind the CLI's "Autocompact is thrashing" turn-killer seen +on 1.5.4 installs. + +The seal: summary is head+tail capped at MAX_SUMMARY_CHARS and the action log +keeps only the last MAX_ACTION_LOG_ENTRIES entries, so one delegation result +can never approach the CLI rejection threshold on the text side. +""" + +from backend.apps.agents.browser_agent_mcp_server import ( + MAX_ACTION_LOG_ENTRIES, + MAX_SUMMARY_CHARS, + format_result, +) + + +def result_text(result: dict) -> str: + blocks = [b for b in result["content"] if b.get("type") == "text"] + return "\n".join(b["text"] for b in blocks) + + +def test_small_summary_passes_through_unchanged() -> None: + text = result_text(format_result({"summary": "all done"})) + assert "**Summary:** all done" in text + assert "omitted" not in text + + +def test_giant_summary_keeps_head_and_tail() -> None: + summary = "HEADSTART " + ("x" * 60_000) + " TAILEND" + text = result_text(format_result({"summary": summary})) + assert len(text) < MAX_SUMMARY_CHARS + 300 + assert "HEADSTART" in text + assert text.endswith("TAILEND") + assert "omitted" in text + + +def test_action_log_keeps_last_entries_with_original_numbering() -> None: + log = [{"tool": f"Act{i}", "input": {}, "elapsed_ms": i} for i in range(100)] + text = result_text(format_result({"summary": "ok", "action_log": log})) + assert "(... 60 earlier actions omitted ...)" in text + assert "Act59" not in text + assert "61. Act60(" in text + assert "100. Act99(" in text + + +def test_short_action_log_has_no_omission_line() -> None: + log = [{"tool": "Click", "input": {"x": 1}, "elapsed_ms": 5}] + text = result_text(format_result({"summary": "ok", "action_log": log})) + assert "omitted" not in text + assert "1. Click(" in text + + +def test_pathological_result_stays_far_under_cli_rejection_cap() -> None: + log = [{"tool": "T", "input": {"v": "y" * 500}, "elapsed_ms": 1} for i in range(500)] + out = format_result({"summary": "z" * 200_000, "action_log": log}) + total = len(result_text(out)) + assert total < MAX_SUMMARY_CHARS + MAX_ACTION_LOG_ENTRIES * 160 + 500 + + +def test_error_result_untouched() -> None: + out = format_result({"error": "boom"}) + assert out["isError"] is True + assert "boom" in result_text(out)