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 && (