mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
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:
+7
-2
@@ -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.model_context import estimate_tokens
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
from src.prompt_security import untrusted_context_message
|
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_policy import GUIDE_ONLY_DIRECTIVE, ToolPolicy
|
||||||
from src.tool_utils import _truncate, get_mcp_manager
|
from src.tool_utils import _truncate, get_mcp_manager
|
||||||
from src.agent_tools import (
|
from src.agent_tools import (
|
||||||
@@ -267,7 +267,7 @@ _DOMAIN_RULES = {
|
|||||||
_DOMAIN_TOOL_MAP = {
|
_DOMAIN_TOOL_MAP = {
|
||||||
"web": {"web_search", "web_fetch", "trigger_research", "manage_research"},
|
"web": {"web_search", "web_fetch", "trigger_research", "manage_research"},
|
||||||
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
|
"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"},
|
"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"},
|
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
|
||||||
"ui": {"ui_control"},
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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_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": """\
|
"manage_calendar": """\
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import uuid
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -2293,8 +2295,11 @@ class TaskScheduler:
|
|||||||
greeting=None,
|
greeting=None,
|
||||||
enabled_tools=json.dumps([
|
enabled_tools=json.dumps([
|
||||||
"manage_calendar", "manage_notes", "manage_tasks", "manage_memory",
|
"manage_calendar", "manage_notes", "manage_tasks", "manage_memory",
|
||||||
"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "archive_email",
|
# Full built-in email tool set (single source of truth in
|
||||||
"mark_email_read", "delete_email", "resolve_contact",
|
# 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",
|
"search_chats", "web_search", "web_fetch", "read_file",
|
||||||
"create_document", "update_document", "edit_document",
|
"create_document", "update_document", "edit_document",
|
||||||
"generate_image", "trigger_research",
|
"generate_image", "trigger_research",
|
||||||
|
|||||||
+11
-4
@@ -19,6 +19,7 @@ from src.embedding_lanes import (
|
|||||||
dedupe_results,
|
dedupe_results,
|
||||||
migrate_legacy_collection,
|
migrate_legacy_collection,
|
||||||
)
|
)
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -46,9 +47,10 @@ ALWAYS_AVAILABLE = frozenset({
|
|||||||
|
|
||||||
# Tools that the Personal Assistant always has access to during scheduled
|
# Tools that the Personal Assistant always has access to during scheduled
|
||||||
# check-ins and proactive tasks, in addition to RAG-selected tools.
|
# check-ins and proactive tasks, in addition to RAG-selected tools.
|
||||||
ASSISTANT_ALWAYS_AVAILABLE = frozenset({
|
# Email tools come from BUILTIN_EMAIL_TOOLS (unioned below) so the assistant's
|
||||||
"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email",
|
# reach can't drift from what the email server exposes. A CrewMember's
|
||||||
"bulk_email", "archive_email", "delete_email", "mark_email_read",
|
# enabled_tools selection still filters this at runtime.
|
||||||
|
ASSISTANT_ALWAYS_AVAILABLE = BUILTIN_EMAIL_TOOLS | frozenset({
|
||||||
"manage_calendar", "manage_notes", "manage_tasks",
|
"manage_calendar", "manage_notes", "manage_tasks",
|
||||||
"manage_memory", "web_search", "read_file",
|
"manage_memory", "web_search", "read_file",
|
||||||
"create_document", "update_document",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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_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.",
|
"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
|
# whole email toolset and crowding out the relevant tools — the model then
|
||||||
# believed it had only email tools and refused web/other tasks (#1707).
|
# 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"}):
|
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"}):
|
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
|
||||||
{"manage_calendar"},
|
{"manage_calendar"},
|
||||||
frozenset({"note", "todo", "reminder", "remind", "checklist", "remember to"}):
|
frozenset({"note", "todo", "reminder", "remind", "checklist", "remember to"}):
|
||||||
|
|||||||
+5
-9
@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Iterable, Mapping, Optional, Set, Tuple
|
from typing import Iterable, Mapping, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
GUIDE_ONLY_DIRECTIVE = (
|
GUIDE_ONLY_DIRECTIVE = (
|
||||||
"## GUIDE-ONLY MODE - TOOL POLICY\n"
|
"## 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 = {
|
_COMMON_TOOL_NAMES = {
|
||||||
"api_call",
|
"api_call",
|
||||||
"app_api",
|
"app_api",
|
||||||
"archive_email",
|
|
||||||
"ask_teacher",
|
"ask_teacher",
|
||||||
"ask_user",
|
"ask_user",
|
||||||
"bash",
|
"bash",
|
||||||
"bulk_email",
|
|
||||||
"builtin_browser",
|
"builtin_browser",
|
||||||
"cancel_download",
|
"cancel_download",
|
||||||
"chat_with_model",
|
"chat_with_model",
|
||||||
"create_document",
|
"create_document",
|
||||||
"create_session",
|
"create_session",
|
||||||
"delete_email",
|
|
||||||
"download_model",
|
"download_model",
|
||||||
"edit_document",
|
"edit_document",
|
||||||
"edit_file",
|
"edit_file",
|
||||||
@@ -41,7 +42,6 @@ _COMMON_TOOL_NAMES = {
|
|||||||
"list_cached_models",
|
"list_cached_models",
|
||||||
"list_cookbook_servers",
|
"list_cookbook_servers",
|
||||||
"list_downloads",
|
"list_downloads",
|
||||||
"list_emails",
|
|
||||||
"list_models",
|
"list_models",
|
||||||
"list_serve_presets",
|
"list_serve_presets",
|
||||||
"list_served_models",
|
"list_served_models",
|
||||||
@@ -61,16 +61,12 @@ _COMMON_TOOL_NAMES = {
|
|||||||
"manage_tasks",
|
"manage_tasks",
|
||||||
"manage_tokens",
|
"manage_tokens",
|
||||||
"manage_webhooks",
|
"manage_webhooks",
|
||||||
"mark_email_read",
|
|
||||||
"pipeline",
|
"pipeline",
|
||||||
"python",
|
"python",
|
||||||
"read_email",
|
|
||||||
"read_file",
|
"read_file",
|
||||||
"reply_to_email",
|
|
||||||
"resolve_contact",
|
"resolve_contact",
|
||||||
"search_chats",
|
"search_chats",
|
||||||
"search_hf_models",
|
"search_hf_models",
|
||||||
"send_email",
|
|
||||||
"send_to_session",
|
"send_to_session",
|
||||||
"serve_model",
|
"serve_model",
|
||||||
"serve_preset",
|
"serve_preset",
|
||||||
@@ -86,7 +82,7 @@ _COMMON_TOOL_NAMES = {
|
|||||||
"web_fetch",
|
"web_fetch",
|
||||||
"web_search",
|
"web_search",
|
||||||
"write_file",
|
"write_file",
|
||||||
}
|
} | BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
_GUIDE_ONLY_PATTERNS: Tuple[Tuple[re.Pattern[str], str], ...] = tuple(
|
_GUIDE_ONLY_PATTERNS: Tuple[Tuple[re.Pattern[str], str], ...] = tuple(
|
||||||
|
|||||||
@@ -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
@@ -95,8 +95,16 @@ PLAN_MODE_READONLY_TOOLS = {
|
|||||||
"search_chats",
|
"search_chats",
|
||||||
"list_models",
|
"list_models",
|
||||||
"list_sessions",
|
"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",
|
"list_emails",
|
||||||
"read_email",
|
"read_email",
|
||||||
|
"search_emails",
|
||||||
"list_served_models",
|
"list_served_models",
|
||||||
"list_downloads",
|
"list_downloads",
|
||||||
"list_cached_models",
|
"list_cached_models",
|
||||||
@@ -130,7 +138,13 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
|||||||
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
||||||
"manage_calendar", "api_call", "app_api", "ui_control",
|
"manage_calendar", "api_call", "app_api", "ui_control",
|
||||||
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
"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",
|
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ function _esc(s) {
|
|||||||
|
|
||||||
// Tool groups for the tool selector UI
|
// Tool groups for the tool selector UI
|
||||||
const TOOL_GROUPS = {
|
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'],
|
'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'],
|
||||||
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
||||||
'Code': ['bash', 'python', 'write_file'],
|
'Code': ['bash', 'python', 'write_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"
|
||||||
|
)
|
||||||
@@ -136,3 +136,31 @@ def test_markdown_info_string_fence_is_left_intact_in_display():
|
|||||||
# so not stripped from the displayed text.
|
# so not stripped from the displayed text.
|
||||||
text = 'Example:\n```python title="example.py"\nprint("hi")\n```'
|
text = 'Example:\n```python title="example.py"\nprint("hi")\n```'
|
||||||
assert strip_tool_blocks(text) == text
|
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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user