diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index c72deed5..30fabb29 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -8,7 +8,7 @@ from typeguard import typechecked from backend.apps.agents.core.models import AgentSession, Message from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input +from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input, wrap_platform_note logger = logging.getLogger(__name__) @@ -70,7 +70,7 @@ async def pre_send_context_guard(manager, session: AgentSession, session_id: str p_names = ", ".join(t.replace("mcp:", "") for t in trimmed) p_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: {p_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 a6c34563..46246642 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -9,6 +9,73 @@ 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}" + + +_SENTINEL_TAG_RE = re.compile(r"]*>") + + +def strip_forged_sentinels(text: str) -> str: + """Neuter any platform-note/recap tags hiding in UNTRUSTED text (tool results, + user input) so attacker-supplied content can't pose as trusted platform context.""" + if "openswarm_platform_note" not in text and "openswarm_session_recap" not in text: + return text + return _SENTINEL_TAG_RE.sub(lambda m: m.group(0).replace("<", "<").replace(">", ">"), text) + + +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}({strip_forged_sentinels(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}: {strip_forged_sentinels(body)}" + @typechecked def get_branch_messages(session) -> List: @@ -65,14 +132,21 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = 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: {strip_forged_sentinels(text)}") + elif m.role == "assistant": + text = m.content if isinstance(m.content, str) else str(m.content) + lines.append(f"Assistant: {strip_forged_sentinels(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}" @typechecked @@ -139,10 +213,10 @@ def truncate_large_tool_result(content: object, session_id: str, msg_id: str, ma except Exception as e: 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.]" + head = strip_forged_sentinels(serialized[:4_000]) + 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/backend/apps/agents/manager/streaming/post_tool_hook.py b/backend/apps/agents/manager/streaming/post_tool_hook.py index 9590b88f..d41fc863 100644 --- a/backend/apps/agents/manager/streaming/post_tool_hook.py +++ b/backend/apps/agents/manager/streaming/post_tool_hook.py @@ -17,7 +17,11 @@ from typeguard import typechecked from backend.apps.agents.core.models import AgentSession, Message from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.agents.manager.session.apply_context_window import apply_context_window -from backend.apps.agents.manager.session.history_compaction import truncate_large_tool_result +from backend.apps.agents.manager.session.history_compaction import ( + truncate_large_tool_result, + wrap_platform_note, + strip_forged_sentinels, +) from backend.apps.agents.manager.streaming.HookContext import HookContext from backend.apps.agents.manager.view_builder_state import view_builder_dirty_sessions @@ -74,6 +78,9 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex except Exception: content = str(raw_response) + # Untrusted tool output could forge our trusted-note tag; neuter it before we append real ones below. + content = strip_forged_sentinels(content) + hook_tool_name_for_errors = input_data.get("tool_name", "") wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit") tool_in = input_data.get("tool_input") or {} @@ -112,7 +119,7 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex 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} diff --git a/backend/main.py b/backend/main.py index 5a698017..9317a677 100644 --- a/backend/main.py +++ b/backend/main.py @@ -674,16 +674,6 @@ async def mcp_meta(action: str, request: Request): session.active_mcps.append(server_name) session.needs_fork = True - # When the session has prior turns, fork_session alone won't - # make the bundled CLI re-read mcp_servers, the transport - # snapshot at launch time is what serves tool schemas. Force a - # full fresh-session restart so the next turn rebuilds with the - # newly-activated server in its mcp_servers dict from scratch. - # First-turn activations don't need this (the SDK session hasn't - # locked in yet). One-time ~200-400ms cold start on the auto- - # continuation turn that fires right after this anyway. - if session.sdk_session_id: - session.needs_fresh_session = True try: from backend.apps.agents.core.ws_manager import ws_manager as p_ws await p_ws.send_to_session(parent_session_id, "agent:status", { 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 6e14aff3..ea692c3b 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -136,12 +136,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 { @@ -150,11 +152,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 && (