From a171210224cd75bf8f3e0fc80b73c2ace74403bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 5 Apr 2026 22:58:14 -0400 Subject: [PATCH] feat(tools): confirmed guard for deletes, update_person/place, get/update_profile, calculate - delete_note / delete_task: add confirmed parameter + requires_confirmation guard (find the note first, then ask, consistent with create_note/task pattern) - get_note: description now mentions notes AND tasks - update_person / update_place: new tools to update existing entity notes in-place - get_profile / update_profile: surface and edit the user's stored profile (expertise, tone, response style, job title, interests) - calculate: eval math expressions via Python math module; solves precision issues on multi-step arithmetic and supports sqrt/log/trig/etc. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/tools.py | 279 +++++++++++++++++++++++++- 1 file changed, 275 insertions(+), 4 deletions(-) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index dd58ba2..6b48abb 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -258,8 +258,8 @@ _CORE_TOOLS = [ "function": { "name": "get_note", "description": ( - "Retrieve the full content of a specific note. Use this when the user asks to read, " - "view, or check what a particular note says. Returns the complete note body." + "Retrieve the full content of a specific note or task. Use this when the user asks to read, " + "view, or check what a particular note or task says. Returns the complete body." ), "parameters": { "type": "object", @@ -316,7 +316,7 @@ _CORE_TOOLS = [ "name": "delete_note", "description": ( "Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. " - "This action requires user confirmation and cannot be undone." + "Always confirm with the user first — this cannot be undone." ), "parameters": { "type": "object", @@ -325,6 +325,10 @@ _CORE_TOOLS = [ "type": "string", "description": "Title or keyword to find the note to delete", }, + "confirmed": { + "type": "boolean", + "description": "Must be true — only set after the user has explicitly confirmed they want this note deleted.", + }, }, "required": ["query"], }, @@ -336,7 +340,7 @@ _CORE_TOOLS = [ "name": "delete_task", "description": ( "Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. " - "This action requires user confirmation and cannot be undone." + "Always confirm with the user first — this cannot be undone." ), "parameters": { "type": "object", @@ -345,6 +349,10 @@ _CORE_TOOLS = [ "type": "string", "description": "Title or keyword to find the task to delete", }, + "confirmed": { + "type": "boolean", + "description": "Must be true — only set after the user has explicitly confirmed they want this task deleted.", + }, }, "required": ["query"], }, @@ -937,6 +945,28 @@ _RAG_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "calculate", + "description": ( + "Evaluate a mathematical expression and return the exact result. Use this for any " + "arithmetic, percentages, unit conversions, or multi-step calculations where precision " + "matters. Supports standard math operators (+, -, *, /, **, %) and all Python math " + "module functions (sqrt, log, sin, cos, floor, ceil, etc.)." + ), + "parameters": { + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')", + }, + }, + "required": ["expression"], + }, + }, + }, ] _ENTITY_TOOLS = [ @@ -964,6 +994,29 @@ _ENTITY_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "update_person", + "description": ( + "Update details about a saved person. Use this when the user corrects or adds new " + "information about someone already in the knowledge base." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Name or keyword to find the person"}, + "relationship": {"type": "string", "description": "Updated relationship to the user"}, + "phone": {"type": "string", "description": "Updated phone number"}, + "email": {"type": "string", "description": "Updated email address"}, + "birthday": {"type": "string", "description": "Updated birthday in YYYY-MM-DD format"}, + "address": {"type": "string", "description": "Updated home or mailing address"}, + "notes": {"type": "string", "description": "Updated free-form notes about this person"}, + }, + "required": ["query"], + }, + }, + }, { "type": "function", "function": { @@ -987,6 +1040,28 @@ _ENTITY_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "update_place", + "description": ( + "Update details about a saved place. Use this when the user corrects or adds new " + "information about a location already in the knowledge base." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Name or keyword to find the place"}, + "address": {"type": "string", "description": "Updated street address"}, + "phone": {"type": "string", "description": "Updated phone number"}, + "hours": {"type": "string", "description": "Updated opening hours"}, + "url": {"type": "string", "description": "Updated website URL"}, + "notes": {"type": "string", "description": "Updated free-form notes about this place"}, + }, + "required": ["query"], + }, + }, + }, { "type": "function", "function": { @@ -1051,12 +1126,71 @@ _ENTITY_TOOLS = [ ] +_PROFILE_TOOLS = [ + { + "type": "function", + "function": { + "name": "get_profile", + "description": ( + "Retrieve the user's stored profile: name, job title, industry, expertise level, " + "preferred response style, tone, and interests. Use this when you need to personalise " + "a response and the user's profile hasn't already been injected into context." + ), + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_profile", + "description": ( + "Update the user's stored profile when they share personal information: their name, " + "job, industry, expertise, preferred response style, tone, or interests. " + "Only set fields the user has explicitly mentioned." + ), + "parameters": { + "type": "object", + "properties": { + "display_name": {"type": "string", "description": "User's preferred display name"}, + "job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"}, + "industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"}, + "expertise_level": { + "type": "string", + "enum": ["novice", "intermediate", "expert"], + "description": "User's general expertise level", + }, + "response_style": { + "type": "string", + "enum": ["concise", "balanced", "detailed"], + "description": "Preferred response length/depth", + }, + "tone": { + "type": "string", + "enum": ["casual", "professional", "technical"], + "description": "Preferred communication tone", + }, + "interests": { + "type": "array", + "items": {"type": "string"}, + "description": "List of topics or hobbies the user is interested in", + }, + }, + }, + }, + }, +] + + async def get_tools_for_user(user_id: int) -> list[dict]: """Build the tool list for a user based on their configured integrations.""" tools = list(_CORE_TOOLS) tools.extend(_URL_TOOLS) tools.extend(_RAG_TOOLS) tools.extend(_ENTITY_TOOLS) + tools.extend(_PROFILE_TOOLS) if await is_caldav_configured(user_id): tools.extend(_CALDAV_TOOLS) if Config.searxng_enabled(): @@ -1698,6 +1832,12 @@ async def execute_tool( note = notes[0] if note.status is not None: return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."} + if not arguments.get("confirmed"): + return { + "success": False, + "requires_confirmation": True, + "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", + } deleted = await delete_note(user_id, note.id) if not deleted: return {"success": False, "error": "Failed to delete note."} @@ -1717,6 +1857,12 @@ async def execute_tool( note = notes[0] if note.status is None: return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."} + if not arguments.get("confirmed"): + return { + "success": False, + "requires_confirmation": True, + "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", + } deleted = await delete_note(user_id, note.id) if not deleted: return {"success": False, "error": "Failed to delete task."} @@ -2172,6 +2318,72 @@ async def execute_tool( _schedule_embedding(note.id, user_id, name, note.body) return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} + elif tool_name == "update_person": + query = str(arguments.get("query", "")).strip() + if not query: + return {"success": False, "error": "query is required"} + existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "person"), None) + if target is None: + return {"success": False, "error": f"No person found matching '{query}'. Use create_person to add them."} + meta = dict(target.entity_meta or {}) + for field in ("relationship", "phone", "email", "birthday", "address"): + val = arguments.get(field) + if val is not None: + meta[field] = str(val) + body_lines = [] + if meta.get("relationship"): + body_lines.append(f"**Relationship:** {meta['relationship']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("email"): + body_lines.append(f"**Email:** {meta['email']}") + if meta.get("birthday"): + body_lines.append(f"**Birthday:** {meta['birthday']}") + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + extra_notes = arguments.get("notes") + if extra_notes is not None: + body_lines.append(f"\n{extra_notes}") + new_body = "\n".join(body_lines) + updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) + if updated is None: + return {"success": False, "error": "Failed to update person."} + _schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}} + + elif tool_name == "update_place": + query = str(arguments.get("query", "")).strip() + if not query: + return {"success": False, "error": "query is required"} + existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "place"), None) + if target is None: + return {"success": False, "error": f"No place found matching '{query}'. Use create_place to add it."} + meta = dict(target.entity_meta or {}) + for field in ("address", "phone", "hours", "url"): + val = arguments.get(field) + if val is not None: + meta[field] = str(val) + body_lines = [] + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("hours"): + body_lines.append(f"**Hours:** {meta['hours']}") + if meta.get("url"): + body_lines.append(f"**Website:** {meta['url']}") + extra_notes = arguments.get("notes") + if extra_notes is not None: + body_lines.append(f"\n{extra_notes}") + new_body = "\n".join(body_lines) + updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) + if updated is None: + return {"success": False, "error": "Failed to update place."} + _schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}} + elif tool_name == "create_list": name = str(arguments.get("name", "")).strip() if not name: @@ -2235,6 +2447,65 @@ async def execute_tool( _schedule_embedding(target.id, user_id, target.title, cleaned) return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} + elif tool_name == "get_profile": + from fabledassistant.services.user_profile import get_profile as _get_profile + profile = await _get_profile(user_id) + return { + "success": True, + "type": "profile", + "data": { + "display_name": profile.display_name or "", + "job_title": profile.job_title or "", + "industry": profile.industry or "", + "expertise_level": profile.expertise_level or "intermediate", + "response_style": profile.response_style or "balanced", + "tone": profile.tone or "casual", + "interests": profile.interests or [], + }, + } + + elif tool_name == "update_profile": + from fabledassistant.services.user_profile import update_profile as _update_profile, VALID_EXPERTISE, VALID_STYLES, VALID_TONES + data: dict = {} + for field in ("display_name", "job_title", "industry"): + val = arguments.get(field) + if val is not None: + data[field] = str(val) + expertise = arguments.get("expertise_level") + if expertise in VALID_EXPERTISE: + data["expertise_level"] = expertise + style = arguments.get("response_style") + if style in VALID_STYLES: + data["response_style"] = style + tone = arguments.get("tone") + if tone in VALID_TONES: + data["tone"] = tone + interests = arguments.get("interests") + if isinstance(interests, list): + data["interests"] = [str(i) for i in interests if str(i).strip()] + if not data: + return {"success": False, "error": "No valid fields provided to update."} + profile = await _update_profile(user_id, data) + return { + "success": True, + "type": "profile_updated", + "data": {"fields_updated": list(data.keys())}, + } + + elif tool_name == "calculate": + import math as _math + expr = str(arguments.get("expression", "")).strip() + if not expr: + return {"success": False, "error": "expression is required"} + allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")} + allowed_names["abs"] = abs + allowed_names["round"] = round + try: + result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307 + except Exception as calc_err: + return {"success": False, "error": f"Could not evaluate expression: {calc_err}"} + return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}} + else: return {"success": False, "error": f"Unknown tool: {tool_name}"}