diff --git a/src/agent_loop.py b/src/agent_loop.py index 26938c429..2da92e5d5 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -19,7 +19,7 @@ from src.llm_core import stream_llm, stream_llm_with_fallback, _is_ollama_native from src.model_context import estimate_tokens from src.settings import get_setting from src.prompt_security import untrusted_context_message -from src.tool_security import blocked_tools_for_owner, plan_mode_disabled_tools +from src.tool_security import BUILTIN_EMAIL_TOOLS, blocked_tools_for_owner, plan_mode_disabled_tools from src.tool_policy import GUIDE_ONLY_DIRECTIVE, ToolPolicy from src.tool_utils import _truncate, get_mcp_manager from src.agent_tools import ( @@ -267,7 +267,7 @@ _DOMAIN_RULES = { _DOMAIN_TOOL_MAP = { "web": {"web_search", "web_fetch", "trigger_research", "manage_research"}, "documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"}, - "email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"}, + "email": set(BUILTIN_EMAIL_TOOLS) | {"resolve_contact", "manage_contact"}, "cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"}, "notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"}, "ui": {"ui_control"}, @@ -442,6 +442,11 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e "delete_email": "- ```delete_email``` — Delete one email by UID. Args (JSON): {\"uid\":\"...\", \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.", "archive_email": "- ```archive_email``` — Archive one email by UID. Args (JSON): {\"uid\":\"...\", \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.", "mark_email_read": "- ```mark_email_read``` — Mark one email read/unread. Args (JSON): {\"uid\":\"...\", \"read\":true, \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.", + "search_emails": "- ```search_emails``` — Search emails by free-text query across INBOX/Sent/Archive (matches sender, subject, body). Args (JSON): {\"query\": \"...\", \"max_results\": 20, \"account\": \"Gmail\"}. Use when the user names a person/topic that isn't in the recent inbox slice; returns UIDs for read_email/reply.", + "draft_email": "- ```draft_email``` — Create an email compose DRAFT document for the user to review (does NOT send). Args (JSON): {\"to\": \"...\", \"subject\": \"...\", \"body\": \"...\", \"account\": \"Gmail\"}. Prefer this over send_email when the user asks you to write/compose an email without saying send.", + "draft_email_reply": "- ```draft_email_reply``` — Create a threaded reply DRAFT document for an email UID (does NOT send). Args (JSON): {\"uid\": \"...\", \"body\": \"...\", \"account\": \"Gmail\"}. Prefer this over reply_to_email when the user wants the reply written, not sent.", + "ai_draft_email_reply": "- ```ai_draft_email_reply``` — Generate an AI-written reply draft for an email UID using the user's writing-style settings (does NOT send). Args (JSON): {\"uid\": \"...\", \"account\": \"Gmail\"}. Use when asked to draft a reply without dictating the exact body.", + "download_attachment": "- ```download_attachment``` — Download an email attachment to disk and return the local path (read it with read_file). Args (JSON): {\"uid\": \"...\", \"index\": 0, \"account\": \"Gmail\"}. `index` comes from read_email's attachments list.", "resolve_contact": "- ```resolve_contact``` — Look up a contact's email by name. Searches CardDAV address book + sent email history. Args (JSON): {\"name\": \"...\"}. Use BEFORE send_email when the user gives only a name.", "manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"uid\": \"...\"}. Use only for explicit address-book/contact requests with contact details. Do NOT use for user identity facts like 'my name is '; save those with manage_memory. For update/delete, call action=list first to get the uid.", "manage_calendar": """\ diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 4b71ff8f6..98d118149 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -9,6 +9,8 @@ import uuid from datetime import datetime, timedelta, timezone from typing import Any, Awaitable, Callable, Dict, Tuple +from src.tool_security import BUILTIN_EMAIL_TOOLS + logger = logging.getLogger(__name__) @@ -2293,8 +2295,11 @@ class TaskScheduler: greeting=None, enabled_tools=json.dumps([ "manage_calendar", "manage_notes", "manage_tasks", "manage_memory", - "list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "archive_email", - "mark_email_read", "delete_email", "resolve_contact", + # Full built-in email tool set (single source of truth in + # tool_security) so a tool added to the email server is + # granted to new default assistants without touching this. + *sorted(BUILTIN_EMAIL_TOOLS), + "resolve_contact", "search_chats", "web_search", "web_fetch", "read_file", "create_document", "update_document", "edit_document", "generate_image", "trigger_research", diff --git a/src/tool_index.py b/src/tool_index.py index 32c7bcf41..775bd570a 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -19,6 +19,7 @@ from src.embedding_lanes import ( dedupe_results, migrate_legacy_collection, ) +from src.tool_security import BUILTIN_EMAIL_TOOLS try: import numpy as np @@ -46,9 +47,10 @@ ALWAYS_AVAILABLE = frozenset({ # Tools that the Personal Assistant always has access to during scheduled # check-ins and proactive tasks, in addition to RAG-selected tools. -ASSISTANT_ALWAYS_AVAILABLE = frozenset({ - "list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", - "bulk_email", "archive_email", "delete_email", "mark_email_read", +# Email tools come from BUILTIN_EMAIL_TOOLS (unioned below) so the assistant's +# reach can't drift from what the email server exposes. A CrewMember's +# enabled_tools selection still filters this at runtime. +ASSISTANT_ALWAYS_AVAILABLE = BUILTIN_EMAIL_TOOLS | frozenset({ "manage_calendar", "manage_notes", "manage_tasks", "manage_memory", "web_search", "read_file", "create_document", "update_document", @@ -114,6 +116,11 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "delete_email": "Delete an email — moves to Trash by default, or expunges permanently with permanent=true.", "mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.", "bulk_email": "Perform one action on many emails at once. Use for delete all those, archive these, mark all read, move spam to junk. Takes explicit UIDs from list_emails or all_unread=true. Always pass account for Gmail/work/custom mailbox results.", + "search_emails": "Search emails by free-text query matching sender, subject, or body, across INBOX, Sent, and Archive by default. Use when the user names a person or topic that isn't in the recent inbox slice — 'invoice from EY', 'last email about the property'. Returns matching emails with UIDs for read_email or reply.", + "draft_email": "Write a new email as a compose draft document the user reviews and sends from Odysseus — does NOT send. Default way to write an email for the user: 'write an email to X', 'compose a message about Y'.", + "draft_email_reply": "Write a reply to an existing email as a threaded draft document the user reviews and sends — does NOT send. Use for 'draft a reply', 'write a response to this email' with a known UID.", + "ai_draft_email_reply": "Generate an AI-written reply draft for an email using the user's writing-style settings, opened as a compose document for review — does NOT send. Use when asked to reply to an email without dictating the body.", + "download_attachment": "Download an email attachment to local disk and return the file path for reading with read_file. Use to review a PDF, spreadsheet, or other file attached to an email.", "resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.", "manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is '; those are memory.", "manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.", @@ -345,7 +352,7 @@ class ToolIndex: # whole email toolset and crowding out the relevant tools — the model then # believed it had only email tools and refused web/other tasks (#1707). frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}): - {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"}, + set(BUILTIN_EMAIL_TOOLS) | {"resolve_contact", "ui_control"}, frozenset({"calendar", "event", "meeting", "schedule", "appointment"}): {"manage_calendar"}, frozenset({"note", "todo", "reminder", "remind", "checklist", "remember to"}): diff --git a/src/tool_policy.py b/src/tool_policy.py index b70b5c3be..45f06343a 100644 --- a/src/tool_policy.py +++ b/src/tool_policy.py @@ -7,6 +7,8 @@ from dataclasses import dataclass, field from types import MappingProxyType from typing import Iterable, Mapping, Optional, Set, Tuple +from src.tool_security import BUILTIN_EMAIL_TOOLS + GUIDE_ONLY_DIRECTIVE = ( "## GUIDE-ONLY MODE - TOOL POLICY\n" @@ -17,20 +19,19 @@ GUIDE_ONLY_DIRECTIVE = ( ) +# Email tool names come from BUILTIN_EMAIL_TOOLS (unioned below) so this +# best-effort known-names set can't drift from what the email server exposes. _COMMON_TOOL_NAMES = { "api_call", "app_api", - "archive_email", "ask_teacher", "ask_user", "bash", - "bulk_email", "builtin_browser", "cancel_download", "chat_with_model", "create_document", "create_session", - "delete_email", "download_model", "edit_document", "edit_file", @@ -41,7 +42,6 @@ _COMMON_TOOL_NAMES = { "list_cached_models", "list_cookbook_servers", "list_downloads", - "list_emails", "list_models", "list_serve_presets", "list_served_models", @@ -61,16 +61,12 @@ _COMMON_TOOL_NAMES = { "manage_tasks", "manage_tokens", "manage_webhooks", - "mark_email_read", "pipeline", "python", - "read_email", "read_file", - "reply_to_email", "resolve_contact", "search_chats", "search_hf_models", - "send_email", "send_to_session", "serve_model", "serve_preset", @@ -86,7 +82,7 @@ _COMMON_TOOL_NAMES = { "web_fetch", "web_search", "write_file", -} +} | BUILTIN_EMAIL_TOOLS _GUIDE_ONLY_PATTERNS: Tuple[Tuple[re.Pattern[str], str], ...] = tuple( diff --git a/src/tool_schemas.py b/src/tool_schemas.py index e755cdc62..0d7623517 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1187,6 +1187,97 @@ FUNCTION_TOOL_SCHEMAS = [ } } }, + { + "type": "function", + "function": { + "name": "search_emails", + "description": "Search emails by free-text query (sender, subject, or body) across INBOX + Sent + Archive by default. Use whenever the user names a person or topic that isn't in the most recent inbox slice. Returns matching emails with UIDs for read_email/reply_to_email.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Free-text query. Matches FROM, SUBJECT, and body TEXT."}, + "folders": {"type": "array", "items": {"type": "string"}, "description": "Folders to search (default: INBOX, Sent, Archive)"}, + "max_results": {"type": "integer", "description": "Max results per folder (default: 20)"}, + "account": {"type": "string", "description": "Account name/email/id from list_email_accounts"}, + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "draft_email", + "description": "Create a new email compose DRAFT document for user review — this does NOT send. Prefer this over send_email when the user asks you to write/compose an email without explicitly saying send.", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address(es), comma-separated"}, + "subject": {"type": "string", "description": "Email subject line"}, + "body": {"type": "string", "description": "Draft body"}, + "cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"}, + "bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"}, + "title": {"type": "string", "description": "Optional document title"}, + "account": {"type": "string", "description": "Account name/email/id from list_email_accounts"}, + }, + "required": ["to", "subject", "body"] + } + } + }, + { + "type": "function", + "function": { + "name": "draft_email_reply", + "description": "Create a threaded reply DRAFT document for an existing email UID — this does NOT send. Prefer this over reply_to_email when the user wants a reply written, not sent. Threads with In-Reply-To/References and prefills recipient/subject.", + "parameters": { + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "body": {"type": "string", "description": "Draft reply body text"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)"}, + "title": {"type": "string", "description": "Optional document title"}, + "account": {"type": "string", "description": "Account name/email/id from list_email_accounts"}, + }, + "required": ["uid", "body"] + } + } + }, + { + "type": "function", + "function": { + "name": "ai_draft_email_reply", + "description": "Generate an AI-written reply draft for an email UID using the user's configured writing style, then open it as a compose document for review — this does NOT send. Use when asked to write/draft a reply without dictating the exact body.", + "parameters": { + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)"}, + "title": {"type": "string", "description": "Optional document title"}, + "account": {"type": "string", "description": "Account name/email/id from list_email_accounts"}, + }, + "required": ["uid"] + } + } + }, + { + "type": "function", + "function": { + "name": "download_attachment", + "description": "Download an email attachment to local disk and return the file path (read it with read_file afterwards). Use to review a document/spreadsheet/file attached to an email.", + "parameters": { + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Email UID from list_emails"}, + "index": {"type": "integer", "description": "Attachment index from read_email's attachments list"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, + "account": {"type": "string", "description": "Account name/email/id from list_email_accounts"}, + }, + "required": ["uid", "index"] + } + } + }, ] diff --git a/src/tool_security.py b/src/tool_security.py index 632b8e2e8..5b9f075f8 100644 --- a/src/tool_security.py +++ b/src/tool_security.py @@ -95,8 +95,16 @@ PLAN_MODE_READONLY_TOOLS = { "search_chats", "list_models", "list_sessions", + # Read-only email tools. Keep this subset in sync with the bare/qualified + # alias gate in execute_tool_block: plan mode blocks the qualified + # mcp__email__* names of everything that ISN'T clearly read-only (via + # mcp_tool_is_readonly), and a bare denylist entry blocks the qualified + # spelling too — so a read-only email tool missing here would be blocked + # in plan mode under BOTH spellings despite being safe to call. + "list_email_accounts", "list_emails", "read_email", + "search_emails", "list_served_models", "list_downloads", "list_cached_models", @@ -130,7 +138,13 @@ _PLAN_MODE_KNOWN_MUTATORS = { "manage_webhooks", "manage_tokens", "manage_settings", "manage_contact", "manage_calendar", "api_call", "app_api", "ui_control", "send_email", "reply_to_email", "bulk_email", "delete_email", - "archive_email", "mark_email_read", "download_model", "serve_model", + "archive_email", "mark_email_read", + # Draft tools create documents and download_attachment writes to disk — + # mutating, so they stay blocked in plan mode even if the schema list + # fails to load. + "draft_email", "draft_email_reply", "ai_draft_email_reply", + "download_attachment", + "download_model", "serve_model", "stop_served_model", "cancel_download", "adopt_served_model", "serve_preset", "generate_image", "edit_image", "trigger_research", "manage_research", # Shell is never read-only-safe; block it explicitly so it stays out of plan diff --git a/static/js/assistant.js b/static/js/assistant.js index dca4bd55f..bb24cc305 100644 --- a/static/js/assistant.js +++ b/static/js/assistant.js @@ -120,7 +120,9 @@ function _esc(s) { // Tool groups for the tool selector UI const TOOL_GROUPS = { - 'Email': ['list_emails', 'read_email', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'], + // Keep in sync with BUILTIN_EMAIL_TOOLS (src/tool_security.py) — every tool + // the email MCP server exposes, so the selector can grant/revoke all of them. + 'Email': ['list_email_accounts', 'list_emails', 'search_emails', 'read_email', 'send_email', 'reply_to_email', 'draft_email', 'draft_email_reply', 'ai_draft_email_reply', 'archive_email', 'delete_email', 'mark_email_read', 'bulk_email', 'download_attachment'], 'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'], 'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'], 'Code': ['bash', 'python', 'write_file'], diff --git a/tests/test_email_registry_sync.py b/tests/test_email_registry_sync.py new file mode 100644 index 000000000..ca3a4e2cb --- /dev/null +++ b/tests/test_email_registry_sync.py @@ -0,0 +1,122 @@ +"""PR #3681 — every email-tool registry derives from / covers BUILTIN_EMAIL_TOOLS. + +Three review rounds on #3681 each found another hand-maintained copy of the +email tool list that had drifted (NON_ADMIN_BLOCKED_TOOLS, the `disable_tool +email` alias, tool_schemas' private copy). This test pins ALL of them to the +single source of truth, including the email MCP server itself, so adding or +removing an email tool fails loudly everywhere it matters instead of silently +shrinking one surface. +""" +import re +from pathlib import Path + +import src.agent_tools # noqa: F401 — resolve the circular-import cluster first +from src.tool_security import ( + BUILTIN_EMAIL_TOOLS, + NON_ADMIN_BLOCKED_TOOLS, + PLAN_MODE_READONLY_TOOLS, + _PLAN_MODE_KNOWN_MUTATORS, +) + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Read-only email tools — the only ones plan mode may execute. +_READONLY_EMAIL = {"list_email_accounts", "list_emails", "read_email", "search_emails"} + + +def test_email_server_tools_match_builtin_set(): + """BUILTIN_EMAIL_TOOLS must equal exactly what the email server exposes.""" + source = (_REPO_ROOT / "mcp_servers" / "email_server.py").read_text() + served = set(re.findall(r'Tool\(\s*name="(\w+)"', source)) + assert served == set(BUILTIN_EMAIL_TOOLS), ( + f"email_server tools != BUILTIN_EMAIL_TOOLS; " + f"server-only: {sorted(served - BUILTIN_EMAIL_TOOLS)}, " + f"set-only: {sorted(BUILTIN_EMAIL_TOOLS - served)}" + ) + + +def test_fence_tags_cover_email_tools(): + from src.agent_tools import TOOL_TAGS + + assert BUILTIN_EMAIL_TOOLS <= set(TOOL_TAGS) + + +def test_function_schemas_cover_email_tools(): + """Every email tool must be advertised to native function-calling models.""" + from src.tool_schemas import FUNCTION_TOOL_SCHEMAS + + names = {(t.get("function") or {}).get("name") for t in FUNCTION_TOOL_SCHEMAS} + missing = set(BUILTIN_EMAIL_TOOLS) - names + assert not missing, f"email tools without native schemas: {sorted(missing)}" + + +def test_prompt_sections_cover_email_tools(): + """Every email tool must be describable to fenced-block models.""" + from src.agent_loop import TOOL_SECTIONS + + missing = set(BUILTIN_EMAIL_TOOLS) - set(TOOL_SECTIONS) + assert not missing, f"email tools without prompt sections: {sorted(missing)}" + + +def test_domain_rules_map_covers_email_tools(): + from src.agent_loop import _DOMAIN_TOOL_MAP + + assert BUILTIN_EMAIL_TOOLS <= _DOMAIN_TOOL_MAP["email"] + + +def test_rag_descriptions_and_keyword_hints_cover_email_tools(): + """Every email tool must be retrievable (embedding index + keyword hints).""" + from src.tool_index import ( + ASSISTANT_ALWAYS_AVAILABLE, + BUILTIN_TOOL_DESCRIPTIONS, + ) + + missing = set(BUILTIN_EMAIL_TOOLS) - set(BUILTIN_TOOL_DESCRIPTIONS) + assert not missing, f"email tools not in the RAG description index: {sorted(missing)}" + assert BUILTIN_EMAIL_TOOLS <= ASSISTANT_ALWAYS_AVAILABLE + + +def test_known_tool_names_cover_email_tools(): + from src.tool_policy import _COMMON_TOOL_NAMES + + assert BUILTIN_EMAIL_TOOLS <= _COMMON_TOOL_NAMES + + +def test_non_admin_blocklist_covers_email_tools(): + assert BUILTIN_EMAIL_TOOLS <= NON_ADMIN_BLOCKED_TOOLS + + +def test_plan_mode_partitions_email_tools(): + """Plan mode must classify every email tool: the read-only four are + allowed (both spellings, given the bare/qualified alias gate), everything + else is in the fail-closed mutator backstop. An unclassified tool would be + allowed bare and blocked qualified — the round-2 aliasing bug again.""" + readonly = set(BUILTIN_EMAIL_TOOLS) & PLAN_MODE_READONLY_TOOLS + assert readonly == _READONLY_EMAIL, ( + f"plan-mode read-only email subset drifted: {sorted(readonly)}" + ) + mutators = set(BUILTIN_EMAIL_TOOLS) - _READONLY_EMAIL + missing = mutators - _PLAN_MODE_KNOWN_MUTATORS + assert not missing, f"mutating email tools not in plan-mode backstop: {sorted(missing)}" + + +def test_frontend_tool_selector_covers_email_tools(): + """The assistant tool-selector UI must be able to grant/revoke every email + tool — a name missing here can never be toggled from the UI.""" + source = (_REPO_ROOT / "static" / "js" / "assistant.js").read_text() + m = re.search(r"'Email':\s*\[([^\]]*)\]", source) + assert m, "Email group not found in assistant.js TOOL_GROUPS" + listed = set(re.findall(r"'(\w+)'", m.group(1))) + assert listed == set(BUILTIN_EMAIL_TOOLS), ( + f"assistant.js Email group != BUILTIN_EMAIL_TOOLS; " + f"js-only: {sorted(listed - BUILTIN_EMAIL_TOOLS)}, " + f"missing: {sorted(BUILTIN_EMAIL_TOOLS - listed)}" + ) + + +def test_default_assistant_seed_covers_email_tools(): + """The default Assistant crew seed grants the full email set.""" + source = (_REPO_ROOT / "src" / "task_scheduler.py").read_text() + assert "*sorted(BUILTIN_EMAIL_TOOLS)" in source, ( + "default assistant enabled_tools no longer derives from BUILTIN_EMAIL_TOOLS" + ) diff --git a/tests/test_fenced_inline_args.py b/tests/test_fenced_inline_args.py index 3f0ec7a5f..f0fc30324 100644 --- a/tests/test_fenced_inline_args.py +++ b/tests/test_fenced_inline_args.py @@ -136,3 +136,31 @@ def test_markdown_info_string_fence_is_left_intact_in_display(): # so not stripped from the displayed text. text = 'Example:\n```python title="example.py"\nprint("hi")\n```' assert strip_tool_blocks(text) == text + + +def test_parse_strip_mirror_across_fence_shape_grid(): + # Invariant for ANY single fence: either it executes AND is stripped, or + # it doesn't execute AND stays fully visible. The one allowed exception is + # an empty tool-shaped fence (no header, no body): never executed, but + # stripped as noise — pre-PR behavior, kept deliberately. + from src.agent_tools import TOOL_TAGS + + tags = ["bash", "python", "list_emails", "bulk_email", "manage_memory", + "python3", "bash-session", "notatool"] + headers = ["", " ", ' title="x"', ' {title="x"}', ' {"a": 1}', " [1, 2]", + " {bad json", ' {"a": 1} extra'] + bodies = ["", "content line\n", '{"k": "v"}\n'] + + for tag in tags: + for header in headers: + for body in bodies: + text = f"before\n```{tag}{header}\n{body}```\nafter" + blocks = parse_tool_blocks(text) + stripped = strip_tool_blocks(text) + case = (tag, header, body) + if blocks: + assert stripped == "before\n\nafter", case + elif stripped != text: + assert ( + tag in TOOL_TAGS and not header.strip() and not body.strip() + ), f"non-executed fence was stripped: {case}"