From 392a813bb34616bea443810e13e4910e36a7664d Mon Sep 17 00:00:00 2001 From: abccodes Date: Sat, 13 Jun 2026 00:18:11 -0700 Subject: [PATCH] ui/ux: hide noisy memory meter, surface accurate post-compact tokens Drop the inline 'Memory N% full' pill from AgentChat and the mode label from AgentCard so the chat header stays quiet until tools are connected. Backend now emits agent:context_update after compact and clear so the session token counters reflect the trimmed history instead of stale pre-compact totals, with a new _estimate_post_compact_input helper and matching invariants in test_v2_invariants. --- backend/apps/agents/agent_manager.py | 37 ++++++++++++ backend/apps/agents/agents.py | 14 ++++- .../manager/session/history_compaction.py | 33 ++++++++++ backend/tests/test_v2_invariants.py | 60 +++++++++++++++++++ electron/package-lock.json | 8 ++- frontend/package-lock.json | 19 +++++- .../src/app/pages/AgentChat/AgentChat.tsx | 35 ++--------- .../app/pages/Dashboard/cards/AgentCard.tsx | 3 - 8 files changed, 172 insertions(+), 37 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 72bba296..7c342291 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -53,6 +53,7 @@ from backend.apps.agents.manager.prompt.tool_catalog import ( from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label from backend.apps.agents.manager.session.history_compaction import ( _build_history_prefix, + _estimate_post_compact_input, _get_branch_messages, _truncate_large_tool_result, ) @@ -377,6 +378,35 @@ class AgentManager: session.compacted_through_msg_id = last_id return True + async def _emit_context_update( + self, + session_id: str, + session: AgentSession, + *, + input_tokens: int | None = None, + output_tokens: int | None = None, + cache_read_tokens: int = 0, + cache_read_pct: float = 0.0, + ) -> None: + if input_tokens is None: + input_tokens = int(session.tokens.get("input", 0) or 0) + if output_tokens is None: + output_tokens = int(session.tokens.get("output", 0) or 0) + session.tokens["input"] = input_tokens + session.tokens["output"] = output_tokens + ctx_window = max(1, getattr(session, "context_window", 0) or 200_000) + await ws_manager.send_to_session(session_id, "agent:context_update", { + "session_id": session_id, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_tokens": cache_read_tokens, + "cache_read_pct": cache_read_pct, + "ctx_used_pct": round(input_tokens / ctx_window, 4) if input_tokens else 0.0, + "context_window": ctx_window, + "framework_overhead_tokens": session.framework_overhead_tokens, + "active_mcps": list(session.active_mcps), + }) + def _build_prompt_content(self, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, api_type: str = "anthropic", model: str = ""): return _build_prompt_content(prompt, images, context_paths, forced_tools, attached_skills, api_type, model) @@ -1813,11 +1843,18 @@ class AgentManager: # zero latency on the user's turn. try: if self._maybe_compact(session): + new_input = _estimate_post_compact_input(session) await ws_manager.send_to_session(session_id, "agent:context_status", { "session_id": session_id, "reason": "compacted", "compacted_through_msg_id": session.compacted_through_msg_id, }) + await self._emit_context_update( + session_id, + session, + input_tokens=new_input, + output_tokens=session.tokens.get("output", 0), + ) except Exception: logger.exception("compaction failed; proceeding without it") diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index f32e05f5..60fbacf2 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -2,6 +2,7 @@ from backend.config.Apps import SubApp from backend.apps.agents.agent_manager import agent_manager from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.agents.core.models import AgentConfig, ApprovalResponse +from backend.apps.agents.manager.session.history_compaction import _estimate_post_compact_input from contextlib import asynccontextmanager from fastapi import WebSocket, WebSocketDisconnect, HTTPException from fastapi.responses import JSONResponse @@ -304,6 +305,12 @@ async def compact_session(session_id: str): "reason": "compacted", "compacted_through_msg_id": session.compacted_through_msg_id, }) + await agent_manager._emit_context_update( + session_id, + session, + input_tokens=_estimate_post_compact_input(session), + output_tokens=session.tokens.get("output", 0), + ) except Exception: pass return {"ok": True, "compacted": fired} @@ -329,6 +336,12 @@ async def clear_session(session_id: str): "status": session.status, "session": session.model_dump(mode="json"), }) + await agent_manager._emit_context_update( + session_id, + session, + input_tokens=0, + output_tokens=0, + ) except Exception: pass return {"ok": True} @@ -817,4 +830,3 @@ async def subscriptions_disconnect(body: dict): return {"ok": False, "error": "Connection not found"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index f644e2e6..10219c42 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -71,6 +71,39 @@ def _build_history_prefix(messages, cutoff_msg_id: str | None = None) -> str: return "\n" + "\n".join(lines) + "\n" +def _estimate_post_compact_input(session) -> int: + """Return a conservative token estimate after compaction trims history.""" + try: + messages = _get_branch_messages(session) + cutoff_msg_id = getattr(session, "compacted_through_msg_id", None) + if cutoff_msg_id: + skip_idx = next( + (i for i, m in enumerate(messages) if m.id == cutoff_msg_id), + -1, + ) + if skip_idx >= 0: + messages = messages[skip_idx + 1:] + surviving_chars = 0 + for message in messages: + if getattr(message, "hidden", False): + continue + content = getattr(message, "content", "") + if isinstance(content, str): + serialized = content + else: + try: + serialized = json.dumps(content, ensure_ascii=False) + except Exception: + serialized = str(content) + surviving_chars += len(serialized) + framework_overhead = int(getattr(session, "framework_overhead_tokens", 0) or 0) + summary_overhead = 200 if cutoff_msg_id else 0 + return max(0, framework_overhead + summary_overhead + (surviving_chars // 4)) + except Exception: + logger.debug("post-compact token estimate failed", exc_info=True) + return max(0, int(getattr(session, "framework_overhead_tokens", 0) or 0)) + + def _truncate_large_tool_result(content: object, session_id: str, msg_id: str, max_bytes: int = 50_000) -> tuple[object, str | None]: """Spill a large tool_result body to disk, return a truncated inline replacement plus the on-disk path (or None if untouched). diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 0cfd2198..5ca346c8 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -784,6 +784,66 @@ def test_compact_threshold_default(): assert s.context_window == 200_000 +def test_post_compact_estimate_excludes_compacted_messages(): + from backend.apps.agents.core.models import AgentSession, Message + from backend.apps.agents.manager.session.history_compaction import ( + _estimate_post_compact_input, + ) + + messages = [ + Message(id=f"m{i}", role="user", content=("old" * 1000 if i < 6 else "keep")) + for i in range(8) + ] + s = AgentSession(id="x", name="t", model="sonnet", mode="agent") + s.messages = messages + s.compacted_through_msg_id = "m5" + s.framework_overhead_tokens = 100 + + assert _estimate_post_compact_input(s) == 100 + 200 + (len("keepkeep") // 4) + + +@pytest.mark.asyncio +async def test_context_update_emitter_refreshes_session_tokens(monkeypatch): + import backend.apps.agents.agent_manager as agent_manager_module + from backend.apps.agents.agent_manager import AgentManager + from backend.apps.agents.core.models import AgentSession + + sent = [] + + async def fake_send_to_session(session_id, event, payload): + sent.append((session_id, event, payload)) + + monkeypatch.setattr( + agent_manager_module.ws_manager, + "send_to_session", + fake_send_to_session, + ) + s = AgentSession(id="x", name="t", model="sonnet", mode="agent") + s.context_window = 1_000 + s.tokens = {"input": 900, "output": 7} + s.framework_overhead_tokens = 42 + s.active_mcps = ["github"] + + await AgentManager()._emit_context_update("x", s, input_tokens=250) + + assert s.tokens == {"input": 250, "output": 7} + assert sent == [( + "x", + "agent:context_update", + { + "session_id": "x", + "input_tokens": 250, + "output_tokens": 7, + "cache_read_tokens": 0, + "cache_read_pct": 0.0, + "ctx_used_pct": 0.25, + "context_window": 1_000, + "framework_overhead_tokens": 42, + "active_mcps": ["github"], + }, + )] + + # =========================================================================== # Group L, Sentence-case display (the parseMcpToolName fix) # =========================================================================== diff --git a/electron/package-lock.json b/electron/package-lock.json index 9a47efdc..96e0bad9 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.1.70", + "version": "1.2.77", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.1.70", + "version": "1.2.77", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", @@ -567,6 +567,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1434,6 +1435,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -1582,6 +1584,7 @@ "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -2885,6 +2888,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..8e97f035 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1964,6 +1965,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2007,6 +2009,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2242,6 +2245,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -3374,6 +3378,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3770,6 +3775,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3809,6 +3815,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4137,6 +4144,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8184,6 +8192,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8240,6 +8249,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8458,6 +8468,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8470,6 +8481,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8516,6 +8528,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8654,7 +8667,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8966,6 +8980,7 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10069,6 +10084,7 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10117,6 +10133,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 5fc18742..dc83dcc2 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1034,42 +1034,17 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); })()} {(() => { - const liveWindow = session.context_window || contextEstimate.limit || 200_000; - const liveInput = session.tokens?.input ?? 0; - const pct = liveInput > 0 - ? Math.min(1, liveInput / Math.max(1, liveWindow)) - : (contextEstimate.used / Math.max(1, liveWindow)); const mcpCount = session.active_mcps?.length ?? 0; - // Quiet until it matters: hide the memory meter until the chat is filling up, - // and hide the tool count unless tools are actually connected. - const showMemory = pct >= 0.60; - const showTools = mcpCount > 0; - if (!showMemory && !showTools) return null; - const pctTxt = `${Math.round(pct * 100)}%`; - const memColor = pct >= 0.85 ? '#ef4444' : '#f59e0b'; - const tip = [ - showMemory ? `Memory ${pctTxt} full. As the chat fills up, the oldest messages start dropping out.` : null, - showTools ? `${mcpCount} tool${mcpCount === 1 ? '' : 's'} connected.` : null, - ].filter(Boolean).join('\n'); + if (mcpCount === 0) return null; return ( - {showMemory && ( - - Memory {pctTxt} full - - )} - {showMemory && showTools && ( - ยท - )} - {showTools && ( - - {mcpCount} tool{mcpCount === 1 ? '' : 's'} - - )} + + {mcpCount} tool{mcpCount === 1 ? '' : 's'} + ); })()} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index a9a383db..4e518672 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -809,9 +809,6 @@ const AgentCard: React.FC = ({ {friendlyModelLabel} - - {session.mode} -