diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 06e0015b..612b21bb 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -209,6 +209,9 @@ class AgentSession(BaseModel): # first? Per-turn counters die with the turn, so a block envelope carried nothing. cli_compactions: int = 0 midturn_breaks: int = 0 + # The third context event, and the one that actually fires: a proactive prune reclaimed 40K on a + # real session while BOTH counters above read 0, so the fleet still could not see it (ENG-418). + proactive_prunes: int = 0 # Aux-LLM distilled summary of the turns dropped by compaction, cached against the cutoff id it was built for; keeps the gist of old history on a rebuild instead of a hard drop. compacted_summary: Optional[str] = None compacted_summary_through: Optional[str] = None diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index 17cc44c2..f99d8280 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -108,6 +108,7 @@ def p_report_model_error(subkind: str, session_id: str, session: AgentSession, t # many times the CLI compacted this chat, and how many times we broke the turn first. "cli_compactions": int(getattr(session, "cli_compactions", 0) or 0), "midturn_breaks": int(getattr(session, "midturn_breaks", 0) or 0), + "proactive_prunes": int(getattr(session, "proactive_prunes", 0) or 0), "history_prefix_sent": session.history_prefix_sent, "delegated": p_used_delegation(session), }) diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 25d65be9..9b3a9ec3 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -212,9 +212,16 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None) -> str: if not lines: return "" # Framing lifted from hermes-agent's compaction handoff (context_compressor.py, MIT): reference only, never active instructions, the message after it is the single source of truth, and an explicit end marker so a weak model cannot read the last line as fresh input. + # Say it is PARTIAL when it is. Measured 2026-08-29 on the packaged build: after a proactive + # prune the model was asked what the user's FIRST message had been and confidently quoted a much + # later one, because nothing in the recap distinguishes "this is the start of the conversation" + # from "this is what survived". A recap that hides its own gap turns lost context into a + # confident wrong answer, which is worse than the loss. + p_partial = " Earlier turns have been dropped to save space, so this does NOT begin at the start of the conversation; if asked about something not in it, say so rather than guessing." if cutoff_msg_id else "" p_recap_frame = ("Recap of YOUR OWN earlier turns in this same conversation (what was asked and which tools " - "you ran), kept locally by the OpenSwarm app so you can continue where you left off. " - "Reference only: do not answer or redo anything in it; respond to the message that follows.") + "you ran), kept locally by the OpenSwarm app so you can continue where you left off." + + p_partial + + " Reference only: do not answer or redo anything in it; respond to the message that follows.") return (f"{SESSION_RECAP_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n{p_recap_frame}\n" + "\n".join(lines) + f"\n--- end of recap; respond to the message below, not the recap above ---\n{SESSION_RECAP_CLOSE}") diff --git a/backend/apps/agents/manager/session/proactive_prune.py b/backend/apps/agents/manager/session/proactive_prune.py index 58750ad2..8b896197 100644 --- a/backend/apps/agents/manager/session/proactive_prune.py +++ b/backend/apps/agents/manager/session/proactive_prune.py @@ -128,6 +128,7 @@ def arm_proactive_prune(session: AgentSession) -> None: """Commit the prune: mark history aged and force the rebuild that actually applies it.""" from backend.apps.agents.manager.context_budget import maybe_compact maybe_compact(session, force=True) + session.proactive_prunes += 1 session.needs_fresh_session = True session.proactive_prune_rearm_tokens = ( estimate_aged_rebuild_tokens(session) + REARM_GROWTH_TOKENS diff --git a/backend/tests/test_overcorrection_audit.py b/backend/tests/test_overcorrection_audit.py index d57de450..797ac2aa 100644 --- a/backend/tests/test_overcorrection_audit.py +++ b/backend/tests/test_overcorrection_audit.py @@ -224,3 +224,80 @@ def test_a_terms_summary_is_not_mistaken_for_the_filter_refusing(): ): assert neutralize_provider_refusal(innocent) == innocent assert classify_provider_error(innocent) is None + + +# ------------------------------------------------- the mid-turn valve's growth rule (ENG-418) + +def test_a_turn_that_starts_high_and_sits_still_is_still_left_alone(monkeypatch): + """Growth made more turns breakable, and every break costs a transcript REBUILD -- the frequency + ENG-382 exists to reduce. The innocent case is a rebuild that failed to shrink: it starts high + and does not grow, and it must RUN rather than rebuild forever.""" + from backend.apps.agents.manager.context_budget import maybe_break_midturn + from backend.apps.agents.manager.streaming.state import TurnState + s = AgentSession(name="t", model="opus-5"); s.context_window = 1_000_000 + t = TurnState() + assert maybe_break_midturn(s, t, {"input_tokens": 500_000}) is False + assert maybe_break_midturn(s, t, {"input_tokens": 505_000}) is False, "5K is not material growth" + assert s.midturn_breaks == 0 + + +def test_a_grown_turn_breaks_at_most_once_so_rebuilds_cannot_multiply(monkeypatch): + from backend.apps.agents.manager.context_budget import maybe_break_midturn + from backend.apps.agents.manager.streaming.state import TurnState + s = AgentSession(name="t", model="opus-5"); s.context_window = 1_000_000 + t = TurnState() + maybe_break_midturn(s, t, {"input_tokens": 100_000}) + assert maybe_break_midturn(s, t, {"input_tokens": 400_000}) is True + for n in (500_000, 600_000, 900_000): + assert maybe_break_midturn(s, t, {"input_tokens": n}) is False + assert s.midturn_breaks == 1 + + +# --------------------------------------------------- Close vs Stop on a workflow card (ENG-421) + +def test_a_workflow_run_that_ended_on_its_own_still_despawns(): + """The card is kept for a HUMAN stop. The innocent case is the nightly workflow that ends by + itself: keeping its card would leave litter on the canvas every single night, which is the leak + the original rule was written to prevent.""" + a = "frontend/src/shared/state/isUserLaunchedSession.ts" + src = open(a, encoding="utf-8").read() + assert "if (LIVE_STATUSES.has" in src and "ended_by_user" in src + assert "!session.dismissed_by_user" in src, "Close must still be able to dismiss" + assert "!session.closed_at" not in src, \ + "closed_at is stamped by the executor on every run; using it made the fix dead code" + + +def test_the_dismissal_flag_is_at_the_close_door_not_in_the_shared_helper(): + """agent_manager.close_session is called by the workflow executor for bookkeeping. A flag there + would mark every finished run as user-dismissed and delete the stopped-run cards again.""" + helper = open("backend/apps/agents/manager/SessionControl.py", encoding="utf-8").read() + assert "dismissed_by_user" not in helper + + +# ------------------------------------------------------ the PTC fan-out width cap (ENG-417) + +def test_one_scripts_fan_out_cannot_starve_the_rest_of_the_chat(): + """The cap is module-global on purpose: two scripts in one sidecar SHARE the width rather than + each taking a full one. The innocent case is the chat's other builtin tools, which ride the same + process and must still get scheduled while a 25-call batch runs.""" + from backend.apps.agents import ptc_mcp_server as ptc + src = open("backend/apps/agents/ptc_mcp_server.py", encoding="utf-8").read() + assert "P_FANOUT_SLOTS = threading.Semaphore(SCRIPT_FANOUT_WIDTH)" in src + i = src.index("def p_dispatch_batch") + body = src[i:src.index("def p_elide", i)] + assert "P_FANOUT_SLOTS.acquire(timeout=" in body, "an unbounded acquire can lose a slot forever" + assert "finally:" in body and "P_FANOUT_SLOTS.release()" in body + assert ptc.SCRIPT_FANOUT_WIDTH <= 8 + + +# ------------------------------------------------- the canvas wheel owner (ENG-420) + +def test_a_gesture_that_starts_on_a_panel_still_belongs_to_the_panel(): + """Canvas ownership was added so a pan is not swallowed mid-drift. The innocent case is the + original rule it must not break: a scroll that starts inside Settings has to stay there past the + panel's end, or reaching the bottom of a list drags the whole world.""" + src = open("frontend/src/app/pages/Dashboard/hooks/interaction/wheelGestureOwner.ts", + encoding="utf-8").read() + assert "owner === CANVAS_OWNER" in src + assert "owner.contains(target" in src, "an element owner must still hold its own gesture" + assert "isZoom" in src, "zoom must stay reachable on every surface" diff --git a/backend/tests/test_recap_admits_its_gap.py b/backend/tests/test_recap_admits_its_gap.py new file mode 100644 index 00000000..58f9bff0 --- /dev/null +++ b/backend/tests/test_recap_admits_its_gap.py @@ -0,0 +1,54 @@ +"""A recap that hides its own gap turns lost context into a confident wrong answer. + +Found on the packaged build 2026-08-29, simulating the heaviest real install's shape (123 tool calls, +132K input, serial read->run->read work). A proactive prune reclaimed ~40K, which is by design. Then +the chat was asked what the user's FIRST message had been, and it quoted a much later one as fact +instead of saying it did not know. Nothing in the recap distinguishes "this is the start of the +conversation" from "this is what survived the prune", so the model has no way to tell. + +Row 5 on the ladder (a lying status), reached from row 1 (context silently gone). The loss itself is +the intended trade; presenting the remainder as the whole is not.""" + +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.manager.session.history_compaction import build_history_prefix + + +def p_msgs(): + return [ + Message(role="user", content="Work in /tmp/osw-heavy, there are 14 modules", branch_id="main"), + Message(role="tool_call", content={"tool": "Read", "input": {"file": "mod_01.py"}}, branch_id="main"), + Message(role="tool_result", content={"tool_name": "Read", "text": "ok"}, branch_id="main"), + Message(role="user", content="Now re-read them all", branch_id="main"), + Message(role="tool_call", content={"tool": "Read", "input": {"file": "mod_02.py"}}, branch_id="main"), + Message(role="tool_result", content={"tool_name": "Read", "text": "ok"}, branch_id="main"), + Message(role="user", content="And once more", branch_id="main"), + ] + + +def test_a_pruned_recap_says_it_does_not_start_at_the_beginning(): + msgs = p_msgs() + out = build_history_prefix(msgs, cutoff_msg_id=msgs[2].id) + low = out.lower() + assert "dropped" in low, "the recap must admit earlier turns are gone" + assert "does not begin at the start" in low or "not begin at the start" in low + assert "say so rather than guessing" in low, "and tell it what to do instead of inventing one" + + +def test_an_UNPRUNED_recap_makes_no_such_claim(): + """The innocent case: a full recap really does start at the beginning, and telling the model + otherwise would make it refuse to answer things it genuinely has.""" + out = build_history_prefix(p_msgs(), cutoff_msg_id=None) + assert "dropped" not in out.lower() + assert "The user asked:" in out, "a full recap still carries the asks" + + +def test_the_prune_is_counted_where_the_fleet_can_see_it(): + """ENG-418 added counters for the CLI's compactions and our mid-turn breaks. On a real heavy + session BOTH read 0 while a proactive prune reclaimed ~40K, so the fleet still could not answer + 'how often does a chat lose history'.""" + s = AgentSession(name="t", model="opus-5") + assert s.proactive_prunes == 0 + src = open("backend/apps/agents/manager/session/proactive_prune.py", encoding="utf-8").read() + assert "session.proactive_prunes += 1" in src + env = open("backend/apps/agents/manager/run/handle_run_error.py", encoding="utf-8").read() + assert '"proactive_prunes"' in env, "counted but never sent is the same blind spot"