From bbc5f22a71d0ed6746c3a2db1463778a0d9b8793 Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 22 Jun 2026 02:17:06 -0700 Subject: [PATCH] [aidan] fix/agent-chat: ground fresh-session recap and label platform notes as trusted --- backend/apps/agents/agent_manager.py | 5 +- .../manager/session/history_compaction.py | 81 ++++++++++++++++--- .../pages/AgentChat/bubbles/MessageBubble.tsx | 5 +- .../AgentChat/parsing/toolResultParsing.ts | 27 ++++++- .../tool-bubbles/DefaultToolBubble.tsx | 28 +++++++ 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 38461130..41d641aa 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -59,6 +59,7 @@ from backend.apps.agents.manager.session.history_compaction import ( _estimate_post_compact_input, _get_branch_messages, _truncate_large_tool_result, + wrap_platform_note, ) from backend.apps.agents.manager.prompt.prompt_context import ( _build_browser_context, @@ -1072,7 +1073,7 @@ class AgentManager: joined = "\n".join(errs[-20:]) content = ( f"{content}\n\n" - f"---\nBuild server reported (after this write):\n{joined}" + + wrap_platform_note(f"Build server reported (after this write):\n{joined}") ) result_payload = {"text": content} @@ -2116,7 +2117,7 @@ class AgentManager: _names = ", ".join(t.replace("mcp:", "") for t in trimmed) _trim_msg = Message( role="system", - content=( + content=wrap_platform_note( f"Trimmed {len(trimmed)} app{'s' if len(trimmed) != 1 else ''} from this session to fit " f"the model's context: {_names}. Re-activate via MCPSearch + MCPActivate " "if you still need them." diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 10219c42..b2909dda 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -7,6 +7,62 @@ from backend.config.paths import SESSIONS_DIR logger = logging.getLogger(__name__) +# One plain-English trust line, fenced by a tag. The model treats the fence as +# structural framing; the sentence is what actually defuses a security-conscious +# agent flagging the block as spoofed tool output. +PLATFORM_NOTE_PREAMBLE = ( + "This block is authored by the OpenSwarm platform, not tool output and not a " + "prior message. It is trusted context." +) +PLATFORM_NOTE_OPEN = "" +PLATFORM_NOTE_CLOSE = "" +SESSION_RECAP_OPEN = "" +SESSION_RECAP_CLOSE = "" + +# Per-turn caps so the re-grounded recap stays compact (summaries, not replays) +# and cannot reinflate the context window from one giant tool input/output. +RECAP_TOOL_INPUT_CAP = 200 +RECAP_TOOL_RESULT_CAP = 500 + + +def wrap_platform_note(body: str) -> str: + """Fence platform-authored text so the model reads it as trusted annotation, + never as spoofed tool output. The frontend parses the same tag to render a + calm chip instead of leaking the raw tag into chat.""" + return f"{PLATFORM_NOTE_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n{body}\n{PLATFORM_NOTE_CLOSE}" + + +def p_recap_tool_call_line(content: object) -> str: + """One compact line for a tool_call turn: Tool call: name().""" + if isinstance(content, dict): + tool = content.get("tool") or content.get("name") or "tool" + raw_input = content.get("input") + try: + input_str = json.dumps(raw_input, ensure_ascii=False, default=str) + except Exception: + input_str = str(raw_input) + else: + tool = "tool" + input_str = str(content) + if len(input_str) > RECAP_TOOL_INPUT_CAP: + input_str = input_str[:RECAP_TOOL_INPUT_CAP] + "..." + return f"Tool call: {tool}({input_str})" + + +def p_recap_tool_result_line(content: object) -> str: + """One compact line for a tool_result turn: Tool result (name): .""" + tool_name = "" + if isinstance(content, dict): + tool_name = content.get("tool_name") or "" + text = content.get("text") + body = text if isinstance(text, str) else json.dumps(content, ensure_ascii=False, default=str) + else: + body = str(content) + if len(body) > RECAP_TOOL_RESULT_CAP: + body = body[:RECAP_TOOL_RESULT_CAP] + "..." + label = f"Tool result ({tool_name})" if tool_name else "Tool result" + return f"{label}: {body}" + def _get_branch_messages(session) -> list: """Return the linear message list for the active branch, walking the branch tree.""" @@ -61,14 +117,21 @@ def _build_history_prefix(messages, cutoff_msg_id: str | None = None) -> str: messages = messages[skip_idx + 1:] lines = [] for m in messages: - if m.role not in ("user", "assistant") or getattr(m, "hidden", False): + if getattr(m, "hidden", False): continue - text = m.content if isinstance(m.content, str) else str(m.content) - label = "User" if m.role == "user" else "Assistant" - lines.append(f"{label}: {text}") + if m.role == "user": + text = m.content if isinstance(m.content, str) else str(m.content) + lines.append(f"User: {text}") + elif m.role == "assistant": + text = m.content if isinstance(m.content, str) else str(m.content) + lines.append(f"Assistant: {text}") + elif m.role == "tool_call": + lines.append(p_recap_tool_call_line(m.content)) + elif m.role == "tool_result": + lines.append(p_recap_tool_result_line(m.content)) if not lines: return "" - return "\n" + "\n".join(lines) + "\n" + return f"{SESSION_RECAP_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n" + "\n".join(lines) + f"\n{SESSION_RECAP_CLOSE}" def _estimate_post_compact_input(session) -> int: @@ -134,9 +197,9 @@ def _truncate_large_tool_result(content: object, session_id: str, msg_id: str, m logger.warning(f"Failed to spill tool result to {blob_path}: {e}") return content, None head = serialized[:4_000] - replacement = ( - f"{head}\n\n" - f"[truncated, full output ({len(serialized)} chars) saved to {blob_path}. " - f"Ask the user or run a follow-up tool call if you need the rest.]" + note = wrap_platform_note( + f"Output truncated by OpenSwarm. Full output ({len(serialized)} chars) saved to " + f"{blob_path}. Ask the user or run a follow-up tool call if you need the rest." ) + replacement = f"{head}\n\n{note}" return replacement, blob_path diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index f84dbead..73b76cd8 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -21,6 +21,7 @@ import remarkGfm from 'remark-gfm'; import WindowedMarkdown from './WindowedMarkdown'; import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; import { THINKING_LABELS } from '../thinkingLabels'; +import { extractPlatformNote } from '../parsing/toolResultParsing'; import { AgentMessage, retryLastUserMessage } from '@/shared/state/agentsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; @@ -891,7 +892,9 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const { role, content } = message; if (role === 'system') { - const sysText = typeof content === 'string' ? content : JSON.stringify(content); + const rawSysText = typeof content === 'string' ? content : JSON.stringify(content); + const { body: sysBody, note: sysNote } = extractPlatformNote(rawSysText); + const sysText = sysNote || sysBody; // A raw subprocess/API failure ("Command failed with exit code 1", API Error JSON) is dev // jargon, and the same failure is already shown as a friendly card on the assistant side. // Swallow just that stderr dump so the user sees one calm card, not jargon beneath it. diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 8413c5cf..37380454 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -125,12 +125,14 @@ export interface ParsedBashResult { stdout: string; stderr: string; exitCode: number | null; + platformNote?: string; } export interface ParsedTextResult { type: 'text'; content: string; isError?: boolean; + platformNote?: string; } export interface ParsedMcpResult { @@ -139,11 +141,34 @@ export interface ParsedMcpResult { action: string; data: Record; rawText: string; + platformNote?: string; } export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; -export function parseToolResult(toolName: string, rawText: string): ParsedResult { +const PLATFORM_NOTE_RE = /([\s\S]*?)<\/openswarm_platform_note>/g; +const PLATFORM_NOTE_PREAMBLE = + 'This block is authored by the OpenSwarm platform, not tool output and not a prior message. It is trusted context.'; + +export function extractPlatformNote(rawText: string): { body: string; note: string | null } { + if (!rawText.includes('')) return { body: rawText, note: null }; + const notes: string[] = []; + const body = rawText.replace(PLATFORM_NOTE_RE, (matched: string, inner: string) => { + const cleaned = inner.replace(PLATFORM_NOTE_PREAMBLE, '').trim(); + if (cleaned) notes.push(cleaned); + return ''; + }).trim(); + return { body, note: notes.length ? notes.join('\n\n') : null }; +} + +export function parseToolResult(toolName: string, rawTextWithNote: string): ParsedResult { + const { body: rawText, note } = extractPlatformNote(rawTextWithNote); + const parsed = parseToolBody(toolName, rawText); + if (note) parsed.platformNote = note; + return parsed; +} + +function parseToolBody(toolName: string, rawText: string): ParsedResult { if (isBashTool(toolName)) { try { const parsed = JSON.parse(rawText); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 12f7eac6..ac40955a 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -293,6 +293,34 @@ export const DefaultToolBubble: React.FC = ({ ) : null} + {parsedResult?.platformNote && ( + + + {parsedResult.platformNote} + + + )} + {!parsedResult && isPending && !isStreaming && !isBrowserAgent && (