fix(email): close remaining email-tool registry drift; classify every email tool for plan mode

Deep self-review follow-up on #3681. Three review rounds each found another
hand-maintained copy of the email tool list that had drifted; this commit
hunts down ALL remaining copies and pins them to BUILTIN_EMAIL_TOOLS.

The same 5 tools (search_emails, draft_email, draft_email_reply,
ai_draft_email_reply, download_attachment) were missing from every
advertising surface, so they were dispatchable but never offered:

- FUNCTION_TOOL_SCHEMAS: native function-calling models never saw them
  (the round-1 fix covered dispatch only); schemas added, mirroring the
  email server's inputSchema definitions.
- TOOL_SECTIONS: fenced-block models were never told about them; prompt
  sections added.
- tool_index: absent from the RAG embedding registry (never retrievable),
  the email keyword hints, and the scheduled assistant's always-available
  set — the latter two now derive from BUILTIN_EMAIL_TOOLS.
- agent_loop._DOMAIN_TOOL_MAP["email"], tool_policy._COMMON_TOOL_NAMES,
  the assistant tool-selector UI groups (assistant.js), and the default
  Assistant crew seed (task_scheduler) now derive from / cover the set.

Plan mode now classifies every email tool explicitly:

- list_email_accounts and search_emails join PLAN_MODE_READONLY_TOOLS.
  Without this, list_email_accounts sat in the plan-mode bare denylist
  (schema-derived) while its qualified form passed the MCP read-only
  filter — and the round-2 bare/qualified alias gate would have blocked
  the qualified call too, regressing read-only email discovery in plan
  mode.
- draft_email, draft_email_reply, ai_draft_email_reply, and
  download_attachment join the fail-closed mutator backstop (drafts
  create documents; download_attachment writes to disk).

Tests: tests/test_email_registry_sync.py pins every registry (including
the email server source and assistant.js) to BUILTIN_EMAIL_TOOLS and
asserts the plan-mode partition, so the next email tool can't drift; a
parse/strip mirror grid covers 192 fence shapes (tag x header x body)
asserting executed <=> stripped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
botinate
2026-06-11 22:19:51 +02:00
parent b3d43ad225
commit db29046e0b
9 changed files with 289 additions and 19 deletions
+7 -2
View File
@@ -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 <name>'; save those with manage_memory. For update/delete, call action=list first to get the uid.",
"manage_calendar": """\
+7 -2
View File
@@ -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",
+11 -4
View File
@@ -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 <name>'; 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"}):
+5 -9
View File
@@ -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(
+91
View File
@@ -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"]
}
}
},
]
+15 -1
View File
@@ -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
+3 -1
View File
@@ -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'],
+122
View File
@@ -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"
)
+28
View File
@@ -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}"