mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 17:44:53 +02:00
[aidan] fix/agent-chat: stop agents reading the recap as spoofed tool output (#104)
* [aidan] fix/agent-chat: ground fresh-session recap and label platform notes as trusted * [aidan] fix/mcp-activate: fork on activate instead of fresh-session restart * [eric] agents: add strip_forged_sentinels, defang spoofed platform-note/recap tags in untrusted text * [eric] agents: sanitize untrusted tool output in post_tool_hook before trusted notes append --------- Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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 = "<openswarm_platform_note>"
|
||||
PLATFORM_NOTE_CLOSE = "</openswarm_platform_note>"
|
||||
SESSION_RECAP_OPEN = "<openswarm_session_recap>"
|
||||
SESSION_RECAP_CLOSE = "</openswarm_session_recap>"
|
||||
|
||||
# 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"</?openswarm_(?:platform_note|session_recap)\b[^>]*>")
|
||||
|
||||
|
||||
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(<truncated input>)."""
|
||||
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): <truncated text>."""
|
||||
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 "<prior_conversation>\n" + "\n".join(lines) + "\n</prior_conversation>"
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<Props> = 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.
|
||||
|
||||
@@ -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<string, any>;
|
||||
rawText: string;
|
||||
platformNote?: string;
|
||||
}
|
||||
|
||||
export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult;
|
||||
|
||||
export function parseToolResult(toolName: string, rawText: string): ParsedResult {
|
||||
const PLATFORM_NOTE_RE = /<openswarm_platform_note>([\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('<openswarm_platform_note>')) 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);
|
||||
|
||||
@@ -293,6 +293,34 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{parsedResult?.platformNote && (
|
||||
<Box
|
||||
sx={{
|
||||
mx: 1.5,
|
||||
mb: 1,
|
||||
mt: 0.5,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: c.font.mono,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{parsedResult.platformNote}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
|
||||
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}>
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user