From bbafa81feba66fd1b7161b910363c99c1907b586 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 09:31:01 +0200 Subject: [PATCH 01/17] feat: Implement dynamic impulse radius classification of tools to automatically judge need for user confirmation Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Marcel Klehr --- ex_app/lib/agent.py | 15 +- ex_app/lib/all_tools/assignments.py | 10 +- ex_app/lib/all_tools/audio2text.py | 4 +- ex_app/lib/all_tools/bookmarks.py | 51 +++++- ex_app/lib/all_tools/calendar.py | 61 ++++++- .../lib/all_tools/calendar_advanced_search.py | 4 +- ex_app/lib/all_tools/circles.py | 29 ++-- ex_app/lib/all_tools/collectives.py | 40 +++-- ex_app/lib/all_tools/contacts.py | 8 +- ex_app/lib/all_tools/context_chat.py | 6 +- ex_app/lib/all_tools/cookbook.py | 14 +- ex_app/lib/all_tools/deck.py | 77 +++++++-- ex_app/lib/all_tools/doc_gen.py | 4 +- ex_app/lib/all_tools/files.py | 37 +++-- ex_app/lib/all_tools/forms.py | 30 +++- ex_app/lib/all_tools/here.py | 4 +- ex_app/lib/all_tools/image_gen.py | 4 +- ex_app/lib/all_tools/lib/audience.py | 98 +++++++++++ ex_app/lib/all_tools/lib/decorator.py | 8 - ex_app/lib/all_tools/lib/impulse.py | 154 ++++++++++++++++++ ex_app/lib/all_tools/mail.py | 10 +- ex_app/lib/all_tools/memory.py | 13 +- ex_app/lib/all_tools/nextcloud_links.py | 4 +- ex_app/lib/all_tools/openproject.py | 8 +- ex_app/lib/all_tools/openstreetmap.py | 8 +- ex_app/lib/all_tools/search.py | 4 +- ex_app/lib/all_tools/shares.py | 22 ++- ex_app/lib/all_tools/skills.py | 7 +- ex_app/lib/all_tools/tables.py | 76 +++++++-- ex_app/lib/all_tools/talk.py | 48 ++++-- ex_app/lib/all_tools/weather.py | 4 +- ex_app/lib/all_tools/web.py | 4 +- ex_app/lib/graph.py | 65 +++++--- ex_app/lib/main.py | 25 +++ ex_app/lib/mcp_server.py | 7 +- ex_app/lib/tools.py | 33 ++-- 36 files changed, 774 insertions(+), 222 deletions(-) create mode 100644 ex_app/lib/all_tools/lib/audience.py create mode 100644 ex_app/lib/all_tools/lib/impulse.py diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index a1a3711d..9d388403 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -16,7 +16,7 @@ from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url from ex_app.lib.all_tools.skills import list_skills_metadata -from ex_app.lib.graph import AgentState, get_graph +from ex_app.lib.graph import CONFIRM_TOOLS_NODE, AgentState, get_graph from ex_app.lib.jsonplus import JsonPlusSerializer from ex_app.lib.memorysaver import MemorySaver from ex_app.lib.nc_model import ( @@ -27,7 +27,7 @@ model, ) from ex_app.lib.signature import add_signature, verify_signature -from ex_app.lib.tools import get_tools +from ex_app.lib.tools import get_impulse_threshold, get_tools # Dummy thread id as we return the whole state thread = {"configurable": {"thread_id": "thread-1"}} @@ -112,9 +112,8 @@ async def react( model.bind_nextcloud(nc) model.multimodal = multimodal - safe_tools, dangerous_tools = await get_tools(nc) - - tools = dangerous_tools + safe_tools + tools = await get_tools(nc) + impulse_threshold = await get_impulse_threshold(nc) bound_model = model.bind_tools( tools, @@ -203,12 +202,12 @@ async def call_model( # if this fails, we fail the whole task checkpointer = load_conversation_old(task['input']['conversation_token']) - graph = await get_graph(call_model, safe_tools, dangerous_tools, checkpointer) + graph = await get_graph(call_model, tools, checkpointer, impulse_threshold) state_snapshot = graph.get_state(thread) ## if the next step is a tool call - if state_snapshot.next == ('dangerous_tools', ): + if state_snapshot.next == (CONFIRM_TOOLS_NODE, ): if task['input']['confirmation'] == 0: new_input = { "messages": [ @@ -289,7 +288,7 @@ async def report_stream_state(force: bool = False): state_snapshot = graph.get_state(thread) actions = '' - if state_snapshot.next == ('dangerous_tools', ): + if state_snapshot.next == (CONFIRM_TOOLS_NODE, ): actions = json.dumps(last_message.tool_calls) result = { diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index c7e36ffd..8165a817 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -6,13 +6,13 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str|None = None, starts_at: None|str = None): """ Create a Scheduled Task for the assistant that will be carried out autonomously. @@ -39,7 +39,7 @@ async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, t return True @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_scheduled_tasks(): """ List all assistant Scheduled Tasks by the current user. @@ -49,7 +49,7 @@ async def list_scheduled_tasks(): return await nc.ocs('GET', f'/ocs/v2.php/apps/assistant/assignments') @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rule: None|str = None, timezone: str|None = None, starts_at: None|str = None): """ Update a assistant Scheduled Task @@ -69,7 +69,7 @@ async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rul }) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def delete_scheduled_task(id: int): """ Delete a recurring Assistant Scheduled Task diff --git a/ex_app/lib/all_tools/audio2text.py b/ex_app/lib/all_tools/audio2text.py index 6a9b0da0..42fec187 100644 --- a/ex_app/lib/all_tools/audio2text.py +++ b/ex_app/lib/all_tools/audio2text.py @@ -5,13 +5,13 @@ from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url from ex_app.lib.all_tools.lib.task_processing import run_task -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def transcribe_file(file_url: str) -> str: """ Transcribe a media file stored inside Nextcloud diff --git a/ex_app/lib/all_tools/bookmarks.py b/ex_app/lib/all_tools/bookmarks.py index 73ab43d2..b286c8aa 100644 --- a/ex_app/lib/all_tools/bookmarks.py +++ b/ex_app/lib/all_tools/bookmarks.py @@ -5,12 +5,45 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.audience import share_type_radius +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + + BOOKMARKS_API = f"{nc.app_cfg.endpoint}/index.php/apps/bookmarks" + BOOKMARKS_HEADERS = {"Content-Type": "application/json", "OCS-APIREQUEST": "true"} + + async def bookmarks_get(path): + response = await nc._session._create_adapter(False).request('GET', f"{BOOKMARKS_API}{path}", headers=BOOKMARKS_HEADERS) + payload = response.json() + if payload.get('status') != 'success': + raise ValueError(f'Bookmarks API said {payload.get("status")!r} for {path}') + return payload + + async def folder_radius(folder_id=None, parent_folder_id=None): + """Who a bookmark folder is shared with. The root folder cannot be shared.""" + folder_id = folder_id if folder_id is not None else parent_folder_id + if folder_id is None or int(folder_id) < 0: + return ImpulseRadius.SELF + radius = ImpulseRadius.SELF + for share in (await bookmarks_get(f'/folder/{int(folder_id)}/shares')).get('data') or []: + # Bookmarks stores the Nextcloud share type: user, group or team. + radius = max(radius, share_type_radius(share.get('type'))) + return radius + + async def bookmark_radius(bookmark_id, folders=None): + """A bookmark reaches whoever its folders are shared with, before and after a move.""" + item = (await bookmarks_get(f'/bookmark/{int(bookmark_id)}')).get('item') or {} + current = item.get('folders') + if current is None: + raise ValueError(f'Could not read the folders of bookmark {bookmark_id!r}') + radius = ImpulseRadius.SELF + for folder in list(current) + list(folders or []): + radius = max(radius, await folder_radius(folder)) + return radius @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_bookmarks(page: int = 0, limit: int = 100, folder_id: Optional[int] = None, tags: Optional[list[str]] = None): """ List bookmarks with optional filtering @@ -36,7 +69,7 @@ async def list_bookmarks(page: int = 0, limit: int = 100, folder_id: Optional[in return json.dumps(response.json()) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def search_bookmarks(search_term: str): """ Search for bookmarks by keyword @@ -50,7 +83,7 @@ async def search_bookmarks(search_term: str): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(folder_radius) async def create_bookmark(url: str, title: Optional[str] = None, description: Optional[str] = None, tags: Optional[list[str]] = None, folder_id: Optional[int] = None): """ Create a new bookmark @@ -81,7 +114,7 @@ async def create_bookmark(url: str, title: Optional[str] = None, description: Op return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(bookmark_radius) async def update_bookmark(bookmark_id: int, url: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, tags: Optional[list[str]] = None, folders: Optional[list[int]] = None): """ Update an existing bookmark @@ -112,7 +145,7 @@ async def update_bookmark(bookmark_id: int, url: Optional[str] = None, title: Op return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(bookmark_radius) async def delete_bookmark(bookmark_id: int): """ Delete a bookmark @@ -126,7 +159,7 @@ async def delete_bookmark(bookmark_id: int): return json.dumps(response.json()) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_bookmark_folders(): """ List all bookmark folders @@ -139,7 +172,7 @@ async def list_bookmark_folders(): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(folder_radius) async def create_bookmark_folder(title: str, parent_folder_id: Optional[int] = None): """ Create a new bookmark folder @@ -160,7 +193,7 @@ async def create_bookmark_folder(title: str, parent_folder_id: Optional[int] = N return json.dumps(response.json()) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_bookmark_tags(): """ List all bookmark tags with usage counts diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 4170131f..922f8466 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -13,7 +13,8 @@ import xml.etree.ElementTree as ET import vobject -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.audience import principal_radius +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour @@ -21,13 +22,55 @@ async def get_tools(nc: AsyncNextcloudApp): ncSync = NextcloudApp() ncSync.set_user(await nc.user) + CALENDAR_PROPFIND = ( + '' + '' + '' + '' + ) + + async def calendar_radius(calendar_name): + """Who can reach this calendar: is it shared out, or owned by somebody else? + + Calendars are not private by default -- one can be shared with a user, a group + or a team, and one shared with the current user is owned by somebody who sees + everything put into it. Both are read off the calendar's DAV properties. + """ + user_id = await nc.user + response = await nc._session._create_adapter(True).request( + 'PROPFIND', + f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{user_id}/", + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + data=CALENDAR_PROPFIND, + ) + for entry in ET.fromstring(response.text).findall('{DAV:}response'): + displayname = entry.find('.//{DAV:}displayname') + if displayname is None or displayname.text != calendar_name: + continue + radius = ImpulseRadius.SELF + owner = entry.find('.//{DAV:}owner/{DAV:}href') + if owner is not None and (owner.text or '').rstrip('/').rsplit('/', 1)[-1] != user_id: + # Somebody else owns it, so they see whatever we put in it. + radius = max(radius, ImpulseRadius.INDIVIDUALS) + # holds one per sharee, each with the principal it + # was shared to; an sibling is the owner, not a sharee. + for sharee in entry.findall('.//{http://owncloud.org/ns}invite/{http://owncloud.org/ns}user/{DAV:}href'): + radius = max(radius, principal_radius(sharee.text)) + return radius + raise ValueError(f'No calendar named {calendar_name!r}') + + async def event_radius(calendar_name, attendees=None): + """An event reaches its attendees, plus whoever else can see the calendar.""" + attendee_radius = ImpulseRadius.INDIVIDUALS if attendees else ImpulseRadius.SELF + return max(attendee_radius, await calendar_radius(calendar_name)) + def list_calendars_sync(): principal = ncSync.cal.principal() calendars = principal.calendars() return ", ".join([cal.name for cal in calendars]) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_calendars(): """ List all existing calendars by name @@ -100,7 +143,7 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ calendar.add_event(str(c)) @tool - @dangerous_tool + @impulse(event_radius) async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]): """ Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event. @@ -184,7 +227,7 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: return available_slots @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]): """ Finds a free time slot where all participants have time @@ -237,7 +280,7 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op return True @tool - @dangerous_tool + @impulse(calendar_radius) async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str]): """ Crete a new task in a calendar. @@ -291,7 +334,7 @@ def list_tasks_sync(calendar_name: Optional[str] = None): return tasks @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_tasks(calendar_name: Optional[str] = None, filter_status: Optional[str] = None): """ List tasks from calendars. Can filter by calendar name and status. @@ -334,7 +377,7 @@ def complete_task_sync(calendar_name: str, task_uid: str): return False @tool - @dangerous_tool + @impulse(calendar_radius) async def complete_task(calendar_name: str, task_uid: str): """ Mark a task as completed @@ -389,7 +432,7 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N return False @tool - @dangerous_tool + @impulse(calendar_radius) async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None, priority: Optional[int] = None): """ Update an existing task @@ -428,7 +471,7 @@ def delete_task_sync(calendar_name: str, task_uid: str): return False @tool - @dangerous_tool + @impulse(calendar_radius) async def delete_task(calendar_name: str, task_uid: str): """ Delete a task diff --git a/ex_app/lib/all_tools/calendar_advanced_search.py b/ex_app/lib/all_tools/calendar_advanced_search.py index 0d3f1e70..b995f3cf 100644 --- a/ex_app/lib/all_tools/calendar_advanced_search.py +++ b/ex_app/lib/all_tools/calendar_advanced_search.py @@ -28,7 +28,7 @@ principal_calendar_home_propfind_body, validate_search, ) -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse MAX_CONCURRENT_CALENDAR_QUERIES = 4 MAX_PROCESSED_OCCURRENCES_PER_SEARCH = 250_000 @@ -46,7 +46,7 @@ def __init__(self, status_code: int, request_stage: str): async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def search_calendar_events( range_start: str, range_end: str, diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index abb38d12..5fb0fb3b 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse # Nextcloud Circles member type constants TYPE_USER = 1 @@ -29,8 +29,17 @@ def _validate_member_id(member_id: str) -> str: async def get_tools(nc: AsyncNextcloudApp): + + def new_member_radius(member_type=TYPE_USER): + """Adding a group or another team to a team pulls in everyone in it, not just one person.""" + if member_type in (TYPE_GROUP, TYPE_CIRCLE): + return ImpulseRadius.GROUP + if member_type == TYPE_MAIL: + return ImpulseRadius.EXTERNAL + return ImpulseRadius.INDIVIDUALS + @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_circles(): """ List all circles (teams) the user is a member of @@ -39,7 +48,7 @@ async def list_circles(): return json.dumps(await nc.ocs('GET', '/ocs/v2.php/apps/circles/circles')) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_circle_details(circle_id: str): """ Get detailed information about a specific circle (team) @@ -50,7 +59,7 @@ async def get_circle_details(circle_id: str): return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}')) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_circle_members(circle_id: str): """ List all members of a specific circle (team) @@ -62,7 +71,7 @@ async def list_circle_members(circle_id: str): return json.dumps(circle_members) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def create_circle(name: str, description: Optional[str] = None, is_personal: bool = False): """ Create a new circle (team) @@ -81,7 +90,7 @@ async def create_circle(name: str, description: Optional[str] = None, is_persona return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/circles/circles', json=payload)) @tool - @dangerous_tool + @impulse(new_member_radius) async def add_member_to_circle(circle_id: str, member_id: str, member_type: int = TYPE_USER): """ Add a member to a circle (team) @@ -100,7 +109,7 @@ async def add_member_to_circle(circle_id: str, member_id: str, member_type: int return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/multi', json=payload)) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def remove_member_from_circle(circle_id: str, member_id: str): """ Remove a member from a circle (team) @@ -113,7 +122,7 @@ async def remove_member_from_circle(circle_id: str, member_id: str): return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/{member_id}')) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def update_circle(circle_id: str, name: Optional[str] = None, description: Optional[str] = None): """ Update circle (team) information @@ -130,7 +139,7 @@ async def update_circle(circle_id: str, name: Optional[str] = None, description: return @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def delete_circle(circle_id: str): """ Delete a circle (team) @@ -141,7 +150,7 @@ async def delete_circle(circle_id: str): await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}') @tool - @dangerous_tool + @impulse(ImpulseRadius.GROUP) async def share_with_circle(path: str, circle_id: str, permissions: int = 19): """ Share a file or folder with a circle (team) diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py index ab9cf9d5..5b9a972e 100644 --- a/ex_app/lib/all_tools/collectives.py +++ b/ex_app/lib/all_tools/collectives.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse # Unlike the other write tools, which append their AI note to a value they create, # update_page_content replaces a whole page the agent usually read back first - so the @@ -44,6 +44,18 @@ def _strip_ai_disclaimer(markdown: str) -> str: async def get_tools(nc: AsyncNextcloudApp): + async def collective_radius(collective_id): + """A collective belongs to a team; if it also carries a public link, it reaches further.""" + payload = await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives') + # The endpoint wraps the list in a 'collectives' key. + collectives = payload.get('collectives') if isinstance(payload, dict) else payload + collective = next((c for c in collectives or [] if str(c.get('id')) == str(collective_id)), None) + if collective is None: + raise ValueError(f'No collective with id {collective_id!r}') + if collective.get('shareToken'): + return ImpulseRadius.EXTERNAL + return ImpulseRadius.GROUP + async def _page_webdav_url(user_id: str, page: dict) -> str: # A page's markdown file lives at: # /remote.php/dav/files/{user}/{collectivePath}/{filePath}/{fileName} @@ -56,7 +68,7 @@ async def _page_webdav_url(user_id: str, page: dict) -> str: # --- Collectives --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_collectives(): """ List all Collectives (wiki-like knowledge bases) the current user is a member of. @@ -68,7 +80,7 @@ async def list_collectives(): # --- Pages (read) --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_collective_pages(collective_id: int): """ List all pages in a Collective as a flat list with tree information. @@ -81,7 +93,7 @@ async def list_collective_pages(collective_id: int): return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages')) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_page(collective_id: int, page_id: int): """ Get metadata for a single Collectives page (without the markdown body). @@ -93,7 +105,7 @@ async def get_page(collective_id: int, page_id: int): return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_page_content(collective_id: int, page_id: int): """ Get the Markdown content of a Collectives page. @@ -118,7 +130,7 @@ async def get_page_content(collective_id: int, page_id: int): return response.text @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_page_trash(collective_id: int): """ List trashed pages in a Collective. Trashed pages can be restored with restore_page or @@ -132,7 +144,7 @@ async def list_page_trash(collective_id: int): # --- Pages (write) --- @tool - @dangerous_tool + @impulse(collective_radius) async def create_page(collective_id: int, parent_id: int, title: str): """ Create a new page in a Collective as a child of an existing page. @@ -149,7 +161,7 @@ async def create_page(collective_id: int, parent_id: int, title: str): })) @tool - @dangerous_tool + @impulse(collective_radius) async def update_page_content(collective_id: int, page_id: int, content: str): """ Overwrite the Markdown content of a Collectives page. @@ -177,7 +189,7 @@ async def update_page_content(collective_id: int, page_id: int, content: str): return json.dumps({'status': 'success', 'page_id': page_id}) @tool - @dangerous_tool + @impulse(collective_radius) async def rename_page(collective_id: int, page_id: int, title: str): """ Change the title of a Collectives page. Also renames the underlying .md file on disk. @@ -191,7 +203,7 @@ async def rename_page(collective_id: int, page_id: int, title: str): })) @tool - @dangerous_tool + @impulse(collective_radius) async def move_page(collective_id: int, page_id: int, parent_id: int): """ Move a page under a different parent within the same collective. @@ -206,7 +218,7 @@ async def move_page(collective_id: int, page_id: int, parent_id: int): })) @tool - @dangerous_tool + @impulse(collective_radius) async def set_page_emoji(collective_id: int, page_id: int, emoji: str): """ Set or clear the emoji icon for a Collectives page. @@ -221,7 +233,7 @@ async def set_page_emoji(collective_id: int, page_id: int, emoji: str): })) @tool - @dangerous_tool + @impulse(collective_radius) async def trash_page(collective_id: int, page_id: int): """ Soft-delete a page by moving it to the collective's page trash. @@ -235,7 +247,7 @@ async def trash_page(collective_id: int, page_id: int): return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) @tool - @dangerous_tool + @impulse(collective_radius) async def restore_page(collective_id: int, page_id: int): """ Restore a previously trashed page back to the collective. @@ -246,7 +258,7 @@ async def restore_page(collective_id: int, page_id: int): return json.dumps(await nc.ocs('PATCH', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}')) @tool - @dangerous_tool + @impulse(collective_radius) async def delete_page_permanently(collective_id: int, page_id: int): """ Permanently delete a page that is already in the trash. This cannot be undone. diff --git a/ex_app/lib/all_tools/contacts.py b/ex_app/lib/all_tools/contacts.py index 4f540a00..eaa17a21 100644 --- a/ex_app/lib/all_tools/contacts.py +++ b/ex_app/lib/all_tools/contacts.py @@ -8,12 +8,12 @@ import xml.etree.ElementTree as ET import vobject -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def find_person_in_users(search_term: str): """ Search for users @@ -31,7 +31,7 @@ async def find_person_in_users(search_term: str): return json.dumps(dict) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: """ Find a person's contact information from their name @@ -95,7 +95,7 @@ async def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: return contacts @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def find_details_of_current_user() -> dict[str, typing.Any]: """ Find the current user's personal information, such as name, location, timezone, language diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index ad879448..b3ef2758 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -6,13 +6,13 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_context_chat_providers() -> str: """ List the content providers available to context chat (e.g., files, mail). @@ -26,7 +26,7 @@ async def list_context_chat_providers() -> str: return json.dumps(response.json()) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def ask_context_chat( question: str, scope_type: Literal['none', 'source', 'provider'] = 'none', diff --git a/ex_app/lib/all_tools/cookbook.py b/ex_app/lib/all_tools/cookbook.py index 462ce32e..54867887 100644 --- a/ex_app/lib/all_tools/cookbook.py +++ b/ex_app/lib/all_tools/cookbook.py @@ -5,12 +5,12 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_recipes(category: Optional[str] = None): """ List all recipes or filter by category @@ -29,7 +29,7 @@ async def list_recipes(category: Optional[str] = None): return recipes @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def search_recipes(search_term: str): """ Search for recipes by keyword @@ -45,7 +45,7 @@ async def search_recipes(search_term: str): return response.json() @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_recipe_details(recipe_id: int): """ Get complete details of a recipe including ingredients and instructions @@ -59,7 +59,7 @@ async def get_recipe_details(recipe_id: int): return response.json() @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def create_recipe(name: str, description: Optional[str] = None, ingredients: Optional[list[str]] = None, instructions: Optional[str] = None, prep_time: Optional[str] = None, cook_time: Optional[str] = None, category: Optional[str] = None): """ Create a new recipe @@ -98,7 +98,7 @@ async def create_recipe(name: str, description: Optional[str] = None, ingredient @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def delete_recipe(recipe_id: int): """ Delete a recipe @@ -112,7 +112,7 @@ async def delete_recipe(recipe_id: int): return response.json() @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_recipe_categories(): """ List all recipe categories diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index f91cca60..9380d9d0 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -1,17 +1,72 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import json +import time from typing import Optional from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + DECK_API = f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0" + DECK_HEADERS = {"Content-Type": "application/json", "OCS-APIREQUEST": "true"} + # Resolving a card to its board costs one request per board, so the map is kept + # for a short while; a card the agent just created is not in it yet, which is + # what the refresh on a miss is for. + card_board_cache = {'boards': {}, 'fetched_at': 0.0} + + async def deck_get(path): + response = await nc._session._create_adapter().request('GET', f"{DECK_API}{path}", headers=DECK_HEADERS) + return response.json() + + async def board_acl_radius(board): + """Who a board is shared with, read off its access control list.""" + radius = ImpulseRadius.SELF + for entry in board.get('acl') or []: + # 0 = user, 1 = group, 7 = team (circle) + radius = max(radius, ImpulseRadius.INDIVIDUALS if entry.get('type') == 0 else ImpulseRadius.GROUP) + return radius + + async def board_radius(board_id): + """Look the board up to see who can already see what is on it.""" + # The board list carries the full access control list, no details=true needed. + for board in await deck_get('/boards'): + if board.get('id') == int(board_id): + return await board_acl_radius(board) + raise ValueError(f'No board with id {board_id!r}') + + async def assignment_radius(board_id): + """Assigning reaches the assignee on top of whoever the board already reaches.""" + return max(ImpulseRadius.INDIVIDUALS, await board_radius(board_id)) + + async def refresh_card_boards(): + """Map every card the user can reach to the board it lives on.""" + boards = {} + for board in await deck_get('/boards'): + for stack in await deck_get(f"/boards/{board['id']}/stacks"): + for card in stack.get('cards') or []: + boards[card['id']] = board + card_board_cache['boards'] = boards + card_board_cache['fetched_at'] = time.monotonic() + return boards + + async def card_radius(card_id): + """A comment on a card reaches whoever the card's board reaches.""" + boards = card_board_cache['boards'] + if time.monotonic() - card_board_cache['fetched_at'] > 60: + boards = await refresh_card_boards() + if int(card_id) not in boards: + boards = await refresh_card_boards() + board = boards.get(int(card_id)) + if board is None: + raise ValueError(f'No board holds a card with id {card_id!r}') + return await board_acl_radius(board) + @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_boards(): """ List all existing kanban boards available in the Nextcloud Deck app for the current user with their available info @@ -26,7 +81,7 @@ async def list_boards(): return json.dumps(response.json()) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_board_cards(board_id: int, stack_id: Optional[int] = None): """ List all cards in a Deck board with their metadata. @@ -78,7 +133,7 @@ async def list_board_cards(board_id: int, stack_id: Optional[int] = None): return json.dumps(cards) @tool - @dangerous_tool + @impulse(board_radius) async def add_card(board_id: int, stack_id: int, title: str, description: Optional[str] = None, due_date: Optional[str] = None): """ Create a new card in a list of a kanban board in the Nextcloud Deck app. @@ -109,7 +164,7 @@ async def add_card(board_id: int, stack_id: int, title: str, description: Option return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(board_radius) async def add_card_label(board_id: int, stack_id: int, card_id: int, label_id: int): """ Add a label to a card @@ -129,7 +184,7 @@ async def add_card_label(board_id: int, stack_id: int, card_id: int, label_id: i return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(assignment_radius) async def assign_card_to_user(board_id: int, stack_id: int, card_id: int, user_id: str): """ Assign a card to a user @@ -149,7 +204,7 @@ async def assign_card_to_user(board_id: int, stack_id: int, card_id: int, user_i return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(board_radius) async def delete_card(board_id: int, stack_id: int, card_id: int): """ Delete a card from a board @@ -168,7 +223,7 @@ async def delete_card(board_id: int, stack_id: int, card_id: int): # --- Card Comments (OCS API) --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_card_comments(card_id: int, limit: int = 20, offset: int = 0): """ List all comments on a Deck card @@ -183,7 +238,7 @@ async def list_card_comments(card_id: int, limit: int = 20, offset: int = 0): })) @tool - @dangerous_tool + @impulse(card_radius) async def add_card_comment(card_id: int, message: str, parent_id: Optional[int] = None): """ Add a comment to a Deck card @@ -199,7 +254,7 @@ async def add_card_comment(card_id: int, message: str, parent_id: Optional[int] return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments', json=payload)) @tool - @dangerous_tool + @impulse(card_radius) async def update_card_comment(card_id: int, comment_id: int, message: str): """ Update an existing comment on a Deck card. Only the comment author can update their own comments. @@ -214,7 +269,7 @@ async def update_card_comment(card_id: int, comment_id: int, message: str): })) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def delete_card_comment(card_id: int, comment_id: int): """ Delete a comment from a Deck card. Only the comment author can delete their own comments. diff --git a/ex_app/lib/all_tools/doc_gen.py b/ex_app/lib/all_tools/doc_gen.py index 48d5059d..4667250a 100644 --- a/ex_app/lib/all_tools/doc_gen.py +++ b/ex_app/lib/all_tools/doc_gen.py @@ -4,13 +4,13 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def generate_document(input: str, format: str) -> str: """ Generate an office document based on a description of what it should contain diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index 5ff6cb49..4f2d4326 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -7,7 +7,8 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.files.files_async import AsyncFilesAPI, FsNode -from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool +from ex_app.lib.all_tools.lib.audience import file_path_radius +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.all_tools.lib.files import format_fs_node, get_file_content_from_int_link, get_file_id_from_file_url @@ -23,8 +24,16 @@ def _validate_path(path: str) -> str: async def get_tools(nc: AsyncNextcloudApp): + async def path_radius(path): + """Who the file or folder at this path is already shared with, directly or through a parent.""" + return await file_path_radius(nc, path) + + async def transfer_radius(source_path=None, destination_path=None): + """A copy or move reaches whoever can see either end of it.""" + return await file_path_radius(nc, source_path, destination_path) + @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_file_content(file_path: str): """ Get the content of a nextcloud-internal file of the current user @@ -42,7 +51,7 @@ async def get_file_content(file_path: str): return response.text @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_file_content_by_file_link(file_url: str): """ Get the content of a Nextcloud-internal file using its internal file link. @@ -55,7 +64,7 @@ async def get_file_content_by_file_link(file_url: str): return await get_file_content_from_int_link(nc, file_url) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_file_tree(path: str = '/', include_metadata = False, depth: int = 1): """ Get the file tree of the user (lists the folders and files the user has in Nextcloud Files) @@ -73,7 +82,7 @@ async def get_file_tree(path: str = '/', include_metadata = False, depth: int = return [fsnode.user_path for fsnode in fsnode_list] @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_folder_tree(depth: int): """ Get the folder tree of the user (lists only the folders the user has in Nextcloud Files) @@ -84,7 +93,7 @@ async def get_folder_tree(depth: int): return await nc.ocs('GET', '/ocs/v2.php/apps/files/api/v1/folder-tree', params={'depth': depth}, response_type='json') @tool - @dangerous_tool + @impulse(ImpulseRadius.EXTERNAL) async def create_public_sharing_link(path: str): """ Creates a public sharing link for a file or folder @@ -101,7 +110,7 @@ async def create_public_sharing_link(path: str): return response @tool - @dangerous_tool + @impulse(path_radius) async def upload_file(path: str, content: str): """ Upload or create a new file with text content @@ -119,7 +128,7 @@ async def upload_file(path: str, content: str): return {"status": "success", "path": path} @tool - @dangerous_tool + @impulse(path_radius) async def create_folder(path: str): """ Create a new folder @@ -136,7 +145,7 @@ async def create_folder(path: str): return {"status": "success", "path": path} @tool - @dangerous_tool + @impulse(transfer_radius) async def move_file(source_path: str, destination_path: str): """ Move or rename a file or folder @@ -155,7 +164,7 @@ async def move_file(source_path: str, destination_path: str): return {"status": "success", "from": source_path, "to": destination_path} @tool - @dangerous_tool + @impulse(transfer_radius) async def copy_file(source_path: str, destination_path: str): """ Copy a file or folder @@ -174,7 +183,7 @@ async def copy_file(source_path: str, destination_path: str): return {"status": "success", "from": source_path, "to": destination_path} @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_file_id_by_path(file_path: str) -> int: """ Resolve a file or folder path to its Nextcloud file ID. @@ -203,7 +212,7 @@ async def get_file_id_by_path(file_path: str) -> int: return int(fileid_element.text) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_file_path_by_id(file_id: int) -> str: """ Resolve a Nextcloud file ID to its path (relative to the user's files root). @@ -247,7 +256,7 @@ async def get_file_path_by_id(file_id: int) -> str: return href @tool - @dangerous_tool + @impulse(transfer_radius) async def convert_file(source_path: str, target_mime_type: str, destination_path: str | None = None): """ Convert a file from one MIME type to another (e.g., docx to pdf, jpg to png). @@ -290,7 +299,7 @@ async def convert_file(source_path: str, target_mime_type: str, destination_path ) @tool - @dangerous_tool + @impulse(path_radius) async def delete_file(path: str): """ Delete a file or folder diff --git a/ex_app/lib/all_tools/forms.py b/ex_app/lib/all_tools/forms.py index c8a254e9..19e293df 100644 --- a/ex_app/lib/all_tools/forms.py +++ b/ex_app/lib/all_tools/forms.py @@ -4,12 +4,26 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.audience import share_type_radius +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + + async def form_radius(form_id): + """Who can already fill this form in, read off its shares and access settings.""" + form = await nc.ocs('GET', f'/ocs/v2.php/apps/forms/api/v3/forms/{form_id}') + if not isinstance(form, dict) or 'shares' not in form: + raise ValueError(f'Could not read the shares of form {form_id!r}') + radius = ImpulseRadius.SELF + if (form.get('access') or {}).get('permitAllUsers'): + # Open to every account on the instance. + radius = ImpulseRadius.GROUP + for share in form['shares'] or []: + radius = max(radius, share_type_radius(share.get('shareType'))) + return radius @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_forms(): """ List all forms created by the current user @@ -18,7 +32,7 @@ async def list_forms(): return await nc.ocs('GET', '/ocs/v2.php/apps/forms/api/v3/forms') @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_form_details(form_id: int): """ Get detailed information about a specific form including questions @@ -28,7 +42,7 @@ async def get_form_details(form_id: int): return await nc.ocs('GET', f'/ocs/v2.php/apps/forms/api/v3/forms/{form_id}') @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def create_form(title: str, description: Optional[str] = None): """ Create a new form. First creates the form, then updates it with the title and description. @@ -50,7 +64,7 @@ async def create_form(title: str, description: Optional[str] = None): return await nc.ocs('PATCH', f'/ocs/v2.php/apps/forms/api/v3/forms/{form_id}', json={'keyValuePairs': key_value_pairs}) @tool - @dangerous_tool + @impulse(form_radius) async def add_question_to_form(form_id: int, question_text: str, question_type: str, is_required: bool = False, options: Optional[list[str]] = None): """ Add a question to an existing form @@ -83,7 +97,7 @@ async def add_question_to_form(form_id: int, question_text: str, question_type: return question @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_form_responses(form_id: int): """ Get all responses/submissions for a form @@ -93,7 +107,7 @@ async def get_form_responses(form_id: int): return await nc.ocs('GET', f'/ocs/v2.php/apps/forms/api/v3/forms/{form_id}/submissions') @tool - @dangerous_tool + @impulse(form_radius) async def delete_form(form_id: int): """ Delete a form @@ -103,7 +117,7 @@ async def delete_form(form_id: int): return await nc.ocs('DELETE', f'/ocs/v2.php/apps/forms/api/v3/forms/{form_id}') @tool - @dangerous_tool + @impulse(form_radius) async def update_form_settings(form_id: int, is_anonymous: Optional[bool] = None, submit_multiple: Optional[bool] = None, show_expiration: Optional[bool] = None, expires: Optional[int] = None): """ Update form settings diff --git a/ex_app/lib/all_tools/here.py b/ex_app/lib/all_tools/here.py index 737452f5..3ffde5dd 100644 --- a/ex_app/lib/all_tools/here.py +++ b/ex_app/lib/all_tools/here.py @@ -7,13 +7,13 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str, routes: int, departure_time: str | None = None): """ Retrieve a public transport route between two coordinates diff --git a/ex_app/lib/all_tools/image_gen.py b/ex_app/lib/all_tools/image_gen.py index e66d5371..ccd7d7cb 100644 --- a/ex_app/lib/all_tools/image_gen.py +++ b/ex_app/lib/all_tools/image_gen.py @@ -4,13 +4,13 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def generate_image(input: str) -> str: """ Generate an image using AI from a text description input diff --git a/ex_app/lib/all_tools/lib/audience.py b/ex_app/lib/all_tools/lib/audience.py new file mode 100644 index 00000000..9ad4ef8f --- /dev/null +++ b/ex_app/lib/all_tools/lib/audience.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Lookups that answer who can already reach an item in Nextcloud. + +Impulse radius hooks use these instead of assuming an audience from the kind of +item: a file may sit in a folder shared with a team, a table may be owned by +somebody else, a calendar may be shared out. Each lookup costs an API request or +two, which buys an answer instead of a guess. + +A lookup that cannot answer raises instead of returning a small radius, because +:func:`~ex_app.lib.all_tools.lib.impulse.classify_tool_call` turns a failing hook +into the widest radius -- so an unanswerable question makes the agent ask the +user rather than act quietly. +""" +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius + +# How far a share reaches, by Nextcloud share type. Types missing here fall back +# to the widest radius on purpose. +SHARE_TYPE_RADIUS = { + 0: ImpulseRadius.INDIVIDUALS, # user + 1: ImpulseRadius.GROUP, # group + 2: ImpulseRadius.GROUP, # a group share's per-user instance (internal) + 3: ImpulseRadius.EXTERNAL, # public link + 4: ImpulseRadius.EXTERNAL, # email + 6: ImpulseRadius.EXTERNAL, # user on a federated server + 7: ImpulseRadius.GROUP, # team (circle) + 8: ImpulseRadius.INDIVIDUALS, # guest account + 9: ImpulseRadius.EXTERNAL, # group on a federated server + 10: ImpulseRadius.GROUP, # Talk conversation + 12: ImpulseRadius.GROUP, # Deck board + 13: ImpulseRadius.INDIVIDUALS, # a Deck share's per-user instance (internal) + 15: ImpulseRadius.EXTERNAL, # ScienceMesh, i.e. another server +} + + +def share_type_radius(share_type) -> ImpulseRadius: + """How far a share of this type reaches.""" + try: + share_type = int(share_type) + except (TypeError, ValueError): + return ImpulseRadius.EXTERNAL + return SHARE_TYPE_RADIUS.get(share_type, ImpulseRadius.EXTERNAL) + + +def principal_radius(href: str) -> ImpulseRadius: + """How far a DAV principal reaches, e.g. 'principal:principals/groups/sales'.""" + href = (href or '').lower() + if '/groups/' in href or '/circles/' in href or '/teams/' in href: + return ImpulseRadius.GROUP + if '/users/' in href: + return ImpulseRadius.INDIVIDUALS + return ImpulseRadius.EXTERNAL + + +def normalize_path(path: str) -> str: + """'/Projects/Q1/' -> '/Projects/Q1'""" + return '/' + '/'.join(p for p in (path or '').split('/') if p not in ('', '.')) + + +def path_and_parents(path: str) -> set: + """Every path a share would have to cover to reach this one.""" + parts = [p for p in (path or '').split('/') if p not in ('', '.')] + return {'/' + '/'.join(parts[:i]) for i in range(1, len(parts) + 1)} + + +async def file_path_radius(nc, *paths) -> ImpulseRadius: + """Who can reach these files or folders, through a share on them or on a parent. + + Covers both directions: folders the user shared out, and folders that were + shared with the user, where the owner and the other recipients see whatever is + written into them. + """ + covered = set() + for path in paths: + if path: + covered |= path_and_parents(path) + if not covered: + raise ValueError('No path to determine the audience of') + + radius = ImpulseRadius.SELF + for params in ({}, {'shared_with_me': 'true'}): + shares = await nc.ocs('GET', '/ocs/v2.php/apps/files_sharing/api/v1/shares', params=params) + for share in shares or []: + # 'path' is relative to the tree of whoever is asking, for shares the user + # handed out as well as for those they received; 'file_target' is the + # recipient's mount point and only matches for the latter. + share_path = share.get('path') or share.get('file_target') + if normalize_path(share_path) in covered: + radius = max(radius, share_type_radius(share.get('share_type'))) + return radius + + +async def share_id_radius(nc, share_id) -> ImpulseRadius: + """Who an existing share already grants access to.""" + share = await nc.ocs('GET', f'/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}') + if isinstance(share, list): + share = share[0] if share else {} + return share_type_radius(share.get('share_type')) diff --git a/ex_app/lib/all_tools/lib/decorator.py b/ex_app/lib/all_tools/lib/decorator.py index 9084cd38..bf8f2158 100644 --- a/ex_app/lib/all_tools/lib/decorator.py +++ b/ex_app/lib/all_tools/lib/decorator.py @@ -4,14 +4,6 @@ import time from functools import wraps -def safe_tool(tool): - setattr(tool, 'safe', True) - return tool - -def dangerous_tool(tool): - setattr(tool, 'safe', False) - return tool - # cache for get_tools # needs NextcloudApp as first arg in the cached function def timed_memoize(timeout): diff --git a/ex_app/lib/all_tools/lib/impulse.py b/ex_app/lib/all_tools/lib/impulse.py new file mode 100644 index 00000000..65800d7b --- /dev/null +++ b/ex_app/lib/all_tools/lib/impulse.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Impulse radius classification for tools. + +Every tool carries an *impulse radius hook*: a callable that receives the same +parameters the tool itself was called with and answers a single question -- +**who gains access to the item this call touches?** + + ``SELF`` nobody new gains access (reads, edits of the user's own data) + ``INDIVIDUALS`` a bounded, named set of people (a direct share, an invite) + ``GROUP`` a group, team, board or conversation audience + ``EXTERNAL`` anyone outside this Nextcloud (mail, public link, the internet) + +Because the hook sees the arguments, one tool can land in different radii +depending on how it is called: ``schedule_event`` without attendees is ``SELF``, +with attendees it is ``INDIVIDUALS``. Hooks may be async and may call the +Nextcloud API, so ``send_message_to_conversation`` can look the conversation up +and answer ``INDIVIDUALS`` for a one-to-one chat but ``EXTERNAL`` for a public +room. + +For actions that *withdraw* rather than grant access (deleting a share, removing +a team member) nobody gains anything, so they are ``SELF``. For actions that +modify an item which already has an audience (editing a team wiki page, posting +in a conversation) the radius is that existing audience -- the audience is who +the action reaches. + +The radius is compared against the admin-configured threshold to decide whether +the user has to confirm the call; see :func:`needs_confirmation`. +""" +import inspect +from enum import IntEnum + + +class ImpulseRadius(IntEnum): + """Who gains access through a tool call, ordered by how far the call reaches.""" + + SELF = 0 + """Nobody new gains access.""" + INDIVIDUALS = 1 + """A bounded list of named people gains access.""" + GROUP = 2 + """A group, team, board or conversation audience gains access.""" + EXTERNAL = 3 + """Someone outside this Nextcloud instance gains access.""" + + +# Used when a tool carries no hook at all (MCP tools cannot be decorated) and +# when a hook fails: assume the widest reach so the user is always asked. +DEFAULT_IMPULSE_RADIUS = ImpulseRadius.EXTERNAL + +DEFAULT_IMPULSE_THRESHOLD = ImpulseRadius.INDIVIDUALS + +IMPULSE_THRESHOLD_SETTING_ID = 'impulse_radius_threshold' + +_ATTR = 'impulse_hook' + + +def parse_impulse_radius(value, default=DEFAULT_IMPULSE_RADIUS) -> ImpulseRadius: + """Turn a setting value ('self', 'group', 2, ...) into an ImpulseRadius.""" + if isinstance(value, ImpulseRadius): + return value + if isinstance(value, int): + try: + return ImpulseRadius(value) + except ValueError: + return default + if isinstance(value, str): + try: + return ImpulseRadius[value.strip().upper()] + except KeyError: + return default + return default + + +def impulse(radius_or_hook): + """Attach an impulse radius hook to a tool function. + + Pass a constant radius when the reach never depends on the arguments:: + + @tool + @impulse(ImpulseRadius.EXTERNAL) + async def send_email(...): ... + + or a hook taking (a subset of) the tool's parameters when it does. The hook + may be sync or async and may hit the Nextcloud API:: + + async def _share_radius(share_with_group=False, **kwargs): + return ImpulseRadius.GROUP if share_with_group else ImpulseRadius.INDIVIDUALS + + @tool + @impulse(_share_radius) + async def share(...): ... + """ + if callable(radius_or_hook): + hook = radius_or_hook + else: + radius = parse_impulse_radius(radius_or_hook) + + async def hook(**_kwargs): + return radius + + def decorator(tool_func): + setattr(tool_func, _ATTR, hook) + return tool_func + + return decorator + + +def get_impulse_hook(tool): + """Return the impulse hook of a LangChain tool, or None if it carries none.""" + tool_action = getattr(tool, 'coroutine', None) or getattr(tool, 'func', None) + if tool_action is None: + return None + return getattr(tool_action, _ATTR, None) + + +def _select_hook_kwargs(hook, tool_args: dict) -> dict: + """Pass only what the hook declares, unless it takes **kwargs. + + Tool arguments come from a model, so they can be incomplete or carry keys the + hook never asked about. Filtering here keeps hooks free of defensive noise. + """ + try: + parameters = inspect.signature(hook).parameters + except (TypeError, ValueError): + return dict(tool_args) + if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()): + return dict(tool_args) + accepted = { + name for name, p in parameters.items() + if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + } + return {k: v for k, v in tool_args.items() if k in accepted} + + +async def classify_tool_call(tool, tool_args: dict) -> ImpulseRadius: + """Run a tool's impulse hook over the arguments it is about to be called with.""" + hook = get_impulse_hook(tool) + if hook is None: + print(f"No impulse hook on tool '{getattr(tool, 'name', tool)}', assuming {DEFAULT_IMPULSE_RADIUS.name}") + return DEFAULT_IMPULSE_RADIUS + try: + result = hook(**_select_hook_kwargs(hook, tool_args or {})) + if inspect.isawaitable(result): + result = await result + return parse_impulse_radius(result) + except Exception as e: # noqa: BLE001 - a hook must never break a tool call + print(f"Impulse hook for '{getattr(tool, 'name', tool)}' failed ({e!r}), assuming {DEFAULT_IMPULSE_RADIUS.name}") + return DEFAULT_IMPULSE_RADIUS + + +def needs_confirmation(radius: ImpulseRadius, threshold: ImpulseRadius) -> bool: + """Whether a call of this reach must be confirmed by the user.""" + return radius >= threshold diff --git a/ex_app/lib/all_tools/mail.py b/ex_app/lib/all_tools/mail.py index cc59877d..dbc4ec1e 100644 --- a/ex_app/lib/all_tools/mail.py +++ b/ex_app/lib/all_tools/mail.py @@ -7,13 +7,13 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import LogLvl -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.logger import log async def get_tools(nc: AsyncNextcloudApp): @tool - @dangerous_tool + @impulse(ImpulseRadius.EXTERNAL) async def send_email(subject: str, body: str, account_id: int, from_email: str, to_emails: list[str]): """ Send an email to a list of email addresses @@ -45,7 +45,7 @@ async def send_email(subject: str, body: str, account_id: int, from_email: str, raise Exception("Failed to send email") @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_mail_account_list(): """ Lists all available email accounts of the current user including their account id @@ -56,7 +56,7 @@ async def get_mail_account_list(): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_mail_folder_list(account_id: int): """ Lists all mail folders for an email account @@ -66,7 +66,7 @@ async def get_mail_folder_list(account_id: int): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_mails(folder_id: int, n_mails: int = 30): """ Lists all messages in a mailbox folder diff --git a/ex_app/lib/all_tools/memory.py b/ex_app/lib/all_tools/memory.py index 8136c622..544a32b1 100644 --- a/ex_app/lib/all_tools/memory.py +++ b/ex_app/lib/all_tools/memory.py @@ -14,7 +14,7 @@ from nc_py_api.files.files_async import AsyncFilesAPI from pydantic import BaseModel, ValidationError, computed_field, field_validator -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.logger import log @@ -187,7 +187,7 @@ async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_memory_tree(depth: int = 2): """ Recursively list the memories stored in a file tree structure. Max depth is 2. @@ -219,7 +219,7 @@ async def list_memory_tree(depth: int = 2): return '\n'.join(paths) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def load_memory(path: str): """ Load one particular memory from the memory store identified by full path. @@ -244,7 +244,7 @@ async def load_memory(path: str): return response.text @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def store_memory(path: str, content: str): """ Stores a complete memory file overwriting it if it already exists. @@ -282,7 +282,7 @@ async def store_memory(path: str, content: str): return {"status": "success", "path": path} @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def delete_memory(path: str): """ Deletes a particular memory file identified by a full file/memory path. @@ -311,6 +311,7 @@ async def delete_memory(path: str): return {"status": "success", "path": path} @tool + @impulse(ImpulseRadius.SELF) async def delete_memory_folder(path: str): """ Deletes the whole folder of memories by a full path. @@ -333,7 +334,7 @@ async def delete_memory_folder(path: str): return {"status": "success", "path": path} @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def search_memories(query: str, k: int = 5) -> list[dict[str, str]]: """ Do a semantic search over the contents of all the stored memories. diff --git a/ex_app/lib/all_tools/nextcloud_links.py b/ex_app/lib/all_tools/nextcloud_links.py index 26d752f7..590ddc6a 100644 --- a/ex_app/lib/all_tools/nextcloud_links.py +++ b/ex_app/lib/all_tools/nextcloud_links.py @@ -10,7 +10,7 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import LogLvl -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.logger import log # The user-facing absolute base URL is stable for the app's lifetime; resolve it once. @@ -501,7 +501,7 @@ def done(app, entity_type, ids=None, **extra): async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def parse_nextcloud_url(url: str): """ Parse a Nextcloud deep-link URL and extract which app it belongs to, the diff --git a/ex_app/lib/all_tools/openproject.py b/ex_app/lib/all_tools/openproject.py index aaf4b2a7..669d7dba 100644 --- a/ex_app/lib/all_tools/openproject.py +++ b/ex_app/lib/all_tools/openproject.py @@ -5,12 +5,12 @@ from typing import Optional -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_projects(): """ List all projects in OpenProject @@ -20,7 +20,7 @@ async def list_projects(): return await nc.ocs('GET', '/ocs/v2.php/apps/integration_openproject/api/v1/projects') @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_assignees(project_id: int): """ List all available assignees of a project in OpenProject @@ -31,7 +31,7 @@ async def list_assignees(project_id: int): return await nc.ocs('GET', f'/ocs/v2.php/apps/integration_openproject/api/v1/projects/{project_id}/available-assignees') @tool - @dangerous_tool + @impulse(ImpulseRadius.GROUP) async def create_work_package(project_id: int, title: str, description: Optional[str], assignee_id: Optional[int]): """ Create a new work package in a given project in OpenProject diff --git a/ex_app/lib/all_tools/openstreetmap.py b/ex_app/lib/all_tools/openstreetmap.py index 76ee5cb7..05506795 100644 --- a/ex_app/lib/all_tools/openstreetmap.py +++ b/ex_app/lib/all_tools/openstreetmap.py @@ -6,12 +6,12 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_coordinates_for_address(address: str) -> (str, str): """ Calculates the coordinates for a given address @@ -28,7 +28,7 @@ async def get_coordinates_for_address(address: str) -> (str, str): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str,): """ Retrieve a route between two coordinates traveled by foot, car or bike @@ -69,7 +69,7 @@ async def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destinat @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_osm_link(location: str): """ Retrieve a URL for a map of a given location. diff --git a/ex_app/lib/all_tools/search.py b/ex_app/lib/all_tools/search.py index 1761aa7a..77fa90de 100644 --- a/ex_app/lib/all_tools/search.py +++ b/ex_app/lib/all_tools/search.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): tools = [] @@ -27,7 +27,7 @@ async def tool(search_query: dict[str, str]): f"Choose filters from {json.dumps(provider['filters'])}. (The 'person' filter, if available, takes a userID. Use find_person_in_users to obtain it.)" 'For example: {"term": "hans", ...}\n' ) - tools.append(tool(safe_tool(tool_func))) + tools.append(tool(impulse(ImpulseRadius.SELF)(tool_func))) return tools diff --git a/ex_app/lib/all_tools/shares.py b/ex_app/lib/all_tools/shares.py index 3ed637f4..a815860b 100644 --- a/ex_app/lib/all_tools/shares.py +++ b/ex_app/lib/all_tools/shares.py @@ -4,12 +4,18 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.audience import share_id_radius +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + + async def existing_share_radius(share_id): + """Look the share up to see who it already grants access to.""" + return await share_id_radius(nc, share_id) + @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_shares(path: Optional[str] = None, shared_with_me: bool = False): """ List all shares or shares for a specific file/folder @@ -26,7 +32,7 @@ async def list_shares(path: Optional[str] = None, shared_with_me: bool = False): return await nc.ocs('GET', '/ocs/v2.php/apps/files_sharing/api/v1/shares', params=params) @tool - @dangerous_tool + @impulse(ImpulseRadius.INDIVIDUALS) async def share_with_user(path: str, share_with: str, permissions: int = 19): """ Share a file or folder with a user @@ -43,7 +49,7 @@ async def share_with_user(path: str, share_with: str, permissions: int = 19): }) @tool - @dangerous_tool + @impulse(ImpulseRadius.GROUP) async def share_with_group(path: str, share_with: str, permissions: int = 19): """ Share a file or folder with a group @@ -60,7 +66,7 @@ async def share_with_group(path: str, share_with: str, permissions: int = 19): }) @tool - @dangerous_tool + @impulse(existing_share_radius) async def update_share_permissions(share_id: int, permissions: int): """ Update permissions for an existing share @@ -73,7 +79,7 @@ async def update_share_permissions(share_id: int, permissions: int): }) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def delete_share(share_id: int): """ Remove/delete a share @@ -83,7 +89,7 @@ async def delete_share(share_id: int): return await nc.ocs('DELETE', f'/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}') @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_user_groups(): """ List all groups the current user belongs to @@ -93,7 +99,7 @@ async def list_user_groups(): return user_info.get('groups', []) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_share_info(share_id: int): """ Get detailed information about a specific share diff --git a/ex_app/lib/all_tools/skills.py b/ex_app/lib/all_tools/skills.py index 5fd888ef..ee494c4e 100644 --- a/ex_app/lib/all_tools/skills.py +++ b/ex_app/lib/all_tools/skills.py @@ -9,7 +9,8 @@ from nc_py_api._exceptions import NextcloudExceptionNotFound from packaging.version import Version -from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool, timed_memoize +from ex_app.lib.all_tools.lib.decorator import timed_memoize +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.logger import log # Skills follow the agentskills.io spec: each skill is a folder under @@ -82,7 +83,7 @@ async def list_skills_metadata(nc: AsyncNextcloudApp) -> list[dict[str, str]]: async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def load_skill(skill_name: str): """ Load the full content of a skill (frontmatter + markdown body) by name. @@ -113,7 +114,7 @@ async def load_skill(skill_name: str): return res['content'] @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def store_skill(skill_name: str, description: str, content: str): """ Create or overwrite a skill. A skill is a reusable, self-contained markdown diff --git a/ex_app/lib/all_tools/tables.py b/ex_app/lib/all_tools/tables.py index 47893a58..86292bbb 100644 --- a/ex_app/lib/all_tools/tables.py +++ b/ex_app/lib/all_tools/tables.py @@ -5,15 +5,65 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + TABLES_API = f"{nc.app_cfg.endpoint}/index.php/apps/tables/api/1" + TABLES_HEADERS = {"Content-Type": "application/json", "OCS-APIREQUEST": "true"} + + async def tables_get(path): + response = await nc._session._create_adapter().request('GET', f"{TABLES_API}{path}", headers=TABLES_HEADERS) + return response.json() + + async def table_share_radius(table_id): + """Who the table was shared out to, by receiver type.""" + radius = ImpulseRadius.SELF + for share in await tables_get(f'/tables/{int(table_id)}/shares') or []: + receiver = (share.get('receiverType') or '').lower() + if receiver in ('group', 'circle'): + radius = max(radius, ImpulseRadius.GROUP) + elif receiver == 'user': + radius = max(radius, ImpulseRadius.INDIVIDUALS) + else: + # 'link' and 'remote' both leave the instance, as does anything new. + radius = ImpulseRadius.EXTERNAL + return radius + + async def table_radius(table_id): + """Look the table up to see whether anyone besides the user can reach it.""" + table = next((t for t in await tables_get('/tables') if t.get('id') == int(table_id)), None) + if table is None: + raise ValueError(f'No table with id {table_id!r}') + if table.get('isFederated'): + return ImpulseRadius.EXTERNAL + if table.get('isShared'): + # Somebody else shared this table with us, and Tables tells recipients only + # that they received it, not who else did -- so assume the wider audience. + return ImpulseRadius.GROUP + if not table.get('hasShares'): + return ImpulseRadius.SELF + return await table_share_radius(table_id) + + async def column_radius(column_id): + """A column belongs to a table, and reaches whoever that table reaches.""" + column = await tables_get(f'/columns/{int(column_id)}') + if not isinstance(column, dict) or column.get('tableId') is None: + raise ValueError(f'Could not resolve column {column_id!r} to a table') + return await table_radius(column['tableId']) + + async def row_radius(row_id): + """A row belongs to a table, and reaches whoever that table reaches.""" + row = await tables_get(f'/rows/{int(row_id)}') + if not isinstance(row, dict) or row.get('tableId') is None: + raise ValueError(f'Could not resolve row {row_id!r} to a table') + return await table_radius(row['tableId']) + # --- Tables --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_tables(): """ List all tables available to the current user in the Nextcloud Tables app @@ -26,7 +76,7 @@ async def list_tables(): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def create_table(title: str, emoji: Optional[str] = None, template: Optional[str] = None): """ Create a new table in the Nextcloud Tables app @@ -48,7 +98,7 @@ async def create_table(title: str, emoji: Optional[str] = None, template: Option return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(table_radius) async def update_table(table_id: int, title: Optional[str] = None, emoji: Optional[str] = None, archived: Optional[bool] = None): """ Update a table's properties @@ -73,7 +123,7 @@ async def update_table(table_id: int, title: Optional[str] = None, emoji: Option return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(table_radius) async def delete_table(table_id: int): """ Delete a table and all its columns and rows @@ -89,7 +139,7 @@ async def delete_table(table_id: int): # --- Columns --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_columns(table_id: int): """ List all columns defined for a table @@ -103,7 +153,7 @@ async def list_columns(table_id: int): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(table_radius) async def create_column( table_id: int, title: str, @@ -213,7 +263,7 @@ async def create_column( return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(column_radius) async def update_column( column_id: int, title: Optional[str] = None, @@ -271,7 +321,7 @@ async def update_column( return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(column_radius) async def delete_column(column_id: int): """ Delete a column from a table. This also removes all data stored in this column for every row. @@ -287,7 +337,7 @@ async def delete_column(column_id: int): # --- Rows --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_rows(table_id: int, limit: Optional[int] = None, offset: Optional[int] = None): """ List all rows in a table with their data. @@ -311,7 +361,7 @@ async def list_rows(table_id: int, limit: Optional[int] = None, offset: Optional return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(table_radius) async def create_row(table_id: int, data: str): """ Create a new row in a table. @@ -343,7 +393,7 @@ async def create_row(table_id: int, data: str): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(row_radius) async def update_row(row_id: int, data: str, view_id: Optional[int] = None): """ Update an existing row's data. @@ -374,7 +424,7 @@ async def update_row(row_id: int, data: str, view_id: Optional[int] = None): return json.dumps(response.json()) @tool - @dangerous_tool + @impulse(row_radius) async def delete_row(row_id: int): """ Delete a row from a table diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 82098a96..0e419e05 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -6,11 +6,27 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.talk import ConversationType -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): + async def conversation_radius(conversation_name=None): + """How far a conversation reaches: one person, its participants, or anyone with the link.""" + if conversation_name is None: + return ImpulseRadius.EXTERNAL + conversations = await nc.talk.get_user_conversations() + conversation = {conv.display_name: conv for conv in conversations}.get(conversation_name) + if conversation is None: + # The name does not resolve; the tool will fail on it, until then assume the worst. + return ImpulseRadius.EXTERNAL + if conversation.conversation_type == ConversationType.PUBLIC: + # Anyone holding the link can read along, including guests without an account. + return ImpulseRadius.EXTERNAL + if conversation.conversation_type in (ConversationType.ONE_TO_ONE, ConversationType.FORMER): + return ImpulseRadius.INDIVIDUALS + return ImpulseRadius.GROUP + async def _get_token(conversation_name: str) -> str: conversations = await nc.talk.get_user_conversations() conv_map = {conv.display_name: conv for conv in conversations} @@ -19,7 +35,7 @@ async def _get_token(conversation_name: str) -> str: # --- Conversations & Messages (enhanced existing tools) --- @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_talk_conversations(): """ List all conversations of the current user in the Nextcloud Talk app. @@ -35,7 +51,7 @@ async def list_talk_conversations(): } for conv in conversations]) @tool - @dangerous_tool + @impulse(ImpulseRadius.EXTERNAL) async def create_public_conversation(conversation_name: str) -> str: """ Create a new public conversation in the Nextcloud Talk app @@ -46,7 +62,7 @@ async def create_public_conversation(conversation_name: str) -> str: return f"{nc.app_cfg.endpoint}/index.php/call/{conversation.token}" @tool - @dangerous_tool + @impulse(conversation_radius) async def send_message_to_conversation(conversation_name: str, message: str): """ Send a message to a conversation in the Nextcloud Talk app @@ -61,7 +77,7 @@ async def send_message_to_conversation(conversation_name: str, message: str): return "Message sent successfully." @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): """ List messages of a conversation in the Nextcloud Talk app. @@ -85,7 +101,7 @@ async def list_messages_in_conversation(conversation_name: str, n_messages: int # --- Reactions --- @tool - @dangerous_tool + @impulse(conversation_radius) async def add_reaction(conversation_name: str, message_id: int, reaction: str): """ Add an emoji reaction to a message in a Talk conversation @@ -100,7 +116,7 @@ async def add_reaction(conversation_name: str, message_id: int, reaction: str): })) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def remove_reaction(conversation_name: str, message_id: int, reaction: str): """ Remove an emoji reaction from a message in a Talk conversation. @@ -116,7 +132,7 @@ async def remove_reaction(conversation_name: str, message_id: int, reaction: str })) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_reactions(conversation_name: str, message_id: int, reaction: Optional[str] = None): """ List all reactions on a message in a Talk conversation @@ -134,7 +150,7 @@ async def list_reactions(conversation_name: str, message_id: int, reaction: Opti # --- Reply to message --- @tool - @dangerous_tool + @impulse(conversation_radius) async def reply_to_message(conversation_name: str, message_id: int, message: str, silent: bool = False): """ Send a message as a reply to another message in a Talk conversation. @@ -156,7 +172,7 @@ async def reply_to_message(conversation_name: str, message_id: int, message: str # --- Polls --- @tool - @dangerous_tool + @impulse(conversation_radius) async def create_poll(conversation_name: str, question: str, options: list[str], result_mode: int = 0, max_votes: int = 0): """ Create a poll in a Talk conversation @@ -176,7 +192,7 @@ async def create_poll(conversation_name: str, question: str, options: list[str], })) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_poll(conversation_name: str, poll_id: int): """ Get the current state and results of a poll @@ -188,7 +204,7 @@ async def get_poll(conversation_name: str, poll_id: int): return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}')) @tool - @dangerous_tool + @impulse(conversation_radius) async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[int]): """ Vote on a poll in a Talk conversation. @@ -204,7 +220,7 @@ async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[in })) @tool - @dangerous_tool + @impulse(ImpulseRadius.SELF) async def close_poll(conversation_name: str, poll_id: int): """ Close a poll so no more votes can be cast. Only the poll creator or a moderator can close a poll. @@ -219,7 +235,7 @@ async def close_poll(conversation_name: str, poll_id: int): # --- File sharing --- @tool - @dangerous_tool + @impulse(conversation_radius) async def share_file_to_conversation(conversation_name: str, file_path: str, caption: Optional[str] = None): """ Share a file from Nextcloud Files into a Talk conversation. @@ -241,7 +257,7 @@ async def share_file_to_conversation(conversation_name: str, file_path: str, cap return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json=payload)) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_shared_items(conversation_name: str, object_type: str, limit: int = 100): """ List items of a specific type that have been shared in a Talk conversation. @@ -261,7 +277,7 @@ async def list_shared_items(conversation_name: str, object_type: str, limit: int })) @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def list_shared_items_overview(conversation_name: str, limit: int = 7): """ Get an overview of all types of shared items in a Talk conversation (files, media, polls, etc.) diff --git a/ex_app/lib/all_tools/weather.py b/ex_app/lib/all_tools/weather.py index 24be8b8f..f4837aca 100644 --- a/ex_app/lib/all_tools/weather.py +++ b/ex_app/lib/all_tools/weather.py @@ -6,12 +6,12 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing.Any]: """ Retrieve the current weather for a given latitude and longitude diff --git a/ex_app/lib/all_tools/web.py b/ex_app/lib/all_tools/web.py index 39659222..a8acb7d7 100644 --- a/ex_app/lib/all_tools/web.py +++ b/ex_app/lib/all_tools/web.py @@ -4,7 +4,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse from ex_app.lib.all_tools.lib.files import ( MAX_FILE_SIZE, TEXT_LIKE_MIMETYPE_PARTS, @@ -16,7 +16,7 @@ async def get_tools(nc: AsyncNextcloudApp): @tool - @safe_tool + @impulse(ImpulseRadius.SELF) async def web_fetch(url: str) -> str: """ Fetch the contents of an external web page via HTTP. diff --git a/ex_app/lib/graph.py b/ex_app/lib/graph.py index 09d128db..7a858051 100644 --- a/ex_app/lib/graph.py +++ b/ex_app/lib/graph.py @@ -10,6 +10,22 @@ from langgraph.prebuilt import ToolNode, tools_condition from typing_extensions import TypedDict, Annotated +from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_IMPULSE_RADIUS, + DEFAULT_IMPULSE_THRESHOLD, + ImpulseRadius, + classify_tool_call, + needs_confirmation, +) + +# The two tool nodes hold the same tools; only one of them is interrupted before. +# Which one a call is routed to is decided per call from its impulse radius. +# The node names are part of the persisted conversation state, so they are kept +# as-is to not break conversations that are waiting for a confirmation across an +# app update. +AUTO_TOOLS_NODE = "safe_tools" +CONFIRM_TOOLS_NODE = "dangerous_tools" + class AgentState(TypedDict): """The state of the agent.""" @@ -39,47 +55,58 @@ def create_tool_node_with_fallback(tools: list) -> dict: [RunnableLambda(handle_tool_error)], exception_key="error" ) -async def get_graph(call_model, safe_tools, dangerous_tools, checkpointer): - dangerous_tool_names = {tool.name: tool for tool in dangerous_tools} - safe_tool_names = {tool.name: tool for tool in safe_tools} +async def get_graph(call_model, tools, checkpointer, impulse_threshold: ImpulseRadius = DEFAULT_IMPULSE_THRESHOLD): + tools_by_name = {tool.name: tool for tool in tools} # Define a new graph workflow = StateGraph(AgentState) # Define the two nodes we will cycle between workflow.add_node("agent", call_model) - workflow.add_node("safe_tools", create_tool_node_with_fallback(safe_tools)) - workflow.add_node("dangerous_tools", create_tool_node_with_fallback(dangerous_tools)) + workflow.add_node(AUTO_TOOLS_NODE, create_tool_node_with_fallback(tools)) + workflow.add_node(CONFIRM_TOOLS_NODE, create_tool_node_with_fallback(tools)) # Set the entrypoint as `agent` # This means that this node is the first one called workflow.set_entry_point("agent") - def route_tools(state: AgentState): + async def impulse_radius_of(state: AgentState) -> ImpulseRadius: + """The widest radius any of the pending tool calls reaches.""" + radius = ImpulseRadius.SELF + for tool_call in state["messages"][-1].tool_calls: + tool = tools_by_name.get(tool_call["name"]) + if tool is None: + # The model hallucinated a tool; the tool node will error out on it, + # but until then treat it as the widest reach. + call_radius = DEFAULT_IMPULSE_RADIUS + else: + call_radius = await classify_tool_call(tool, tool_call.get("args") or {}) + print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}") + radius = max(radius, call_radius) + return radius + + async def route_tools(state: AgentState): next_node = tools_condition(state) # If no tools are invoked, return to the user if next_node == END: return END - ai_message = state["messages"][-1] - # This assumes single tool calls. To handle parallel tool calling, you'd want to - # use an ANY condition - first_tool_call = ai_message.tool_calls[0] - print('Tool call: ', first_tool_call) - if first_tool_call["name"] in dangerous_tool_names: - return "dangerous_tools" - return "safe_tools" + radius = await impulse_radius_of(state) + if needs_confirmation(radius, impulse_threshold): + print(f"Impulse radius {radius.name} reaches the threshold {impulse_threshold.name}, asking the user") + return CONFIRM_TOOLS_NODE + return AUTO_TOOLS_NODE workflow.add_conditional_edges( - "agent", route_tools, ["safe_tools", "dangerous_tools", END] + "agent", route_tools, [AUTO_TOOLS_NODE, CONFIRM_TOOLS_NODE, END] ) - workflow.add_edge("safe_tools", "agent") - workflow.add_edge("dangerous_tools", "agent") + workflow.add_edge(AUTO_TOOLS_NODE, "agent") + workflow.add_edge(CONFIRM_TOOLS_NODE, "agent") # Now we can compile and visualize our graph graph = workflow.compile( checkpointer=checkpointer, - interrupt_before=["dangerous_tools"], + interrupt_before=[CONFIRM_TOOLS_NODE], debug=False ) - return graph \ No newline at end of file + return graph diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 591f1f2d..d91b47af 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -25,6 +25,11 @@ from ex_app.lib.logger import log from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware from ex_app.lib.provider import provider, multimodal_provider +from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_IMPULSE_THRESHOLD, + IMPULSE_THRESHOLD_SETTING_ID, + ImpulseRadius, +) from ex_app.lib.tools import get_categories PROVIDERS = [provider, multimodal_provider] @@ -92,6 +97,26 @@ async def exapp_lifespan(app: FastAPI): default=dict.fromkeys(categories, True), options={v: k for k, v in categories.items()}, ), + SettingsField( + id=IMPULSE_THRESHOLD_SETTING_ID, + title=_("Ask the user to confirm an action from this impulse radius on"), + description=_( + "Before Context Agent carries out an action it works out its impulse radius: who gains" + " access to the item the action touches. Actions that reach at least this far have to be" + " confirmed by the user, everything below is carried out right away." + ), + type=SettingsFieldType.RADIO, + default=DEFAULT_IMPULSE_THRESHOLD.name.lower(), + options={ + _("Only me - confirm every action, including read-only ones"): ImpulseRadius.SELF.name.lower(), + _("Individual people - confirm when named people gain access, e.g. a share with a user"): + ImpulseRadius.INDIVIDUALS.name.lower(), + _("A group - confirm when a group, team or conversation gains access"): + ImpulseRadius.GROUP.name.lower(), + _("Outside this Nextcloud - confirm only when something leaves the instance, e.g. an email"): + ImpulseRadius.EXTERNAL.name.lower(), + }, + ), SettingsField( id="here_api", title=_("API Key HERE"), diff --git a/ex_app/lib/mcp_server.py b/ex_app/lib/mcp_server.py index 4ed0a6e4..d29bc2e4 100644 --- a/ex_app/lib/mcp_server.py +++ b/ex_app/lib/mcp_server.py @@ -55,12 +55,12 @@ async def on_message( ) -> list[Tool]: global LAST_MCP_TOOL_UPDATE if LAST_MCP_TOOL_UPDATE + 60 < time.time(): - safe, dangerous = await get_tools(context.fastmcp_context.get_state("nextcloud")) + nc_tools = await get_tools(context.fastmcp_context.get_state("nextcloud")) tools = await self.mcp.get_tools() if LAST_MCP_TOOL_UPDATE + 60 < time.time(): for tool in tools.keys(): self.mcp.remove_tool(tool) - for tool in safe + dangerous: + for tool in nc_tools: tool_action = getattr(tool, "coroutine", None) or getattr(tool, "func", None) if tool_action is None: continue @@ -78,8 +78,7 @@ def mcp_tool(tool, tool_name: str | None = None): async def wrapper(*args, **kwargs): ctx = get_context() nc = ctx.get_state('nextcloud') - safe, dangerous = await get_tools(nc) - tools = safe + dangerous + tools = await get_tools(nc) invoked_name = tool_name or tool.__name__ for t in tools: action = getattr(t, "coroutine", None) or getattr(t, "func", None) diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index 5755ff2d..8eed8f37 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -9,6 +9,21 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import timed_memoize +from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_IMPULSE_THRESHOLD, + IMPULSE_THRESHOLD_SETTING_ID, + ImpulseRadius, + parse_impulse_radius, +) + + +async def get_impulse_threshold(nc: AsyncNextcloudApp) -> ImpulseRadius: + """The configured impulse radius from which on a tool call has to be confirmed.""" + configured = await nc.appconfig_ex.get_value( + IMPULSE_THRESHOLD_SETTING_ID, + default=DEFAULT_IMPULSE_THRESHOLD.name.lower(), + ) + return parse_impulse_radius(configured, default=DEFAULT_IMPULSE_THRESHOLD) @timed_memoize(1*60) @@ -16,8 +31,7 @@ async def get_tools(nc: AsyncNextcloudApp): directory = dirname(__file__) + '/all_tools' function_name = "get_tools" - dangerous_tools = [] - safe_tools = [] + tools = [] py_files = [f for f in os.listdir(directory) if f.endswith(".py") and f != "__init__.py"] is_activated = json.loads(await nc.appconfig_ex.get_value('tool_status')) @@ -39,21 +53,16 @@ async def get_tools(nc: AsyncNextcloudApp): if callable(get_tools_from_import): print(f"Invoking {function_name} from {module_name}") imported_tools = await get_tools_from_import(nc) - for tool in imported_tools: - tool_action = getattr(tool, 'coroutine', getattr(tool, 'func', None)) - if tool_action is None: - safe_tools.append(tool) - continue - if not getattr(tool_action, 'safe', False): - dangerous_tools.append(tool) # MCP tools cannot be decorated and should always be dangerous - else: - safe_tools.append(tool) + # Tools carry their impulse radius hook on the wrapped function; tools + # without one (MCP tools cannot be decorated) fall back to the widest + # radius at classification time, so they always need confirmation. + tools.extend(imported_tools) else: print(f"{function_name} in {module_name} is not callable.") else: print(f"{function_name} not found in {module_name}.") - return safe_tools, dangerous_tools + return tools def get_categories(): directory = dirname(__file__) + '/all_tools' From 2afab9a1b8d9d5d7b895e7d381d844b851a34f57 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 09:41:47 +0200 Subject: [PATCH 02/17] feat: Implement destructiveness dimension to confirmation classification Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/agent.py | 5 ++- ex_app/lib/all_tools/assignments.py | 3 +- ex_app/lib/all_tools/bookmarks.py | 3 +- ex_app/lib/all_tools/calendar.py | 3 +- ex_app/lib/all_tools/circles.py | 4 +- ex_app/lib/all_tools/collectives.py | 4 +- ex_app/lib/all_tools/cookbook.py | 3 +- ex_app/lib/all_tools/deck.py | 4 +- ex_app/lib/all_tools/files.py | 3 +- ex_app/lib/all_tools/forms.py | 3 +- ex_app/lib/all_tools/lib/impulse.py | 59 ++++++++++++++++++++++++++--- ex_app/lib/all_tools/memory.py | 4 +- ex_app/lib/all_tools/shares.py | 3 +- ex_app/lib/all_tools/tables.py | 5 ++- ex_app/lib/all_tools/talk.py | 3 +- ex_app/lib/graph.py | 31 ++++++++++----- ex_app/lib/main.py | 21 ++++++++++ ex_app/lib/tools.py | 18 ++++++--- 18 files changed, 145 insertions(+), 34 deletions(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 9d388403..349267f2 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -27,7 +27,7 @@ model, ) from ex_app.lib.signature import add_signature, verify_signature -from ex_app.lib.tools import get_impulse_threshold, get_tools +from ex_app.lib.tools import get_destructive_threshold, get_impulse_threshold, get_tools # Dummy thread id as we return the whole state thread = {"configurable": {"thread_id": "thread-1"}} @@ -114,6 +114,7 @@ async def react( tools = await get_tools(nc) impulse_threshold = await get_impulse_threshold(nc) + destructive_threshold = await get_destructive_threshold(nc) bound_model = model.bind_tools( tools, @@ -202,7 +203,7 @@ async def call_model( # if this fails, we fail the whole task checkpointer = load_conversation_old(task['input']['conversation_token']) - graph = await get_graph(call_model, tools, checkpointer, impulse_threshold) + graph = await get_graph(call_model, tools, checkpointer, impulse_threshold, destructive_threshold) state_snapshot = graph.get_state(thread) diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index 8165a817..470f58bf 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -70,6 +70,7 @@ async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rul @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_scheduled_task(id: int): """ Delete a recurring Assistant Scheduled Task diff --git a/ex_app/lib/all_tools/bookmarks.py b/ex_app/lib/all_tools/bookmarks.py index b286c8aa..bb4c1360 100644 --- a/ex_app/lib/all_tools/bookmarks.py +++ b/ex_app/lib/all_tools/bookmarks.py @@ -6,7 +6,7 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.audience import share_type_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -146,6 +146,7 @@ async def update_bookmark(bookmark_id: int, url: Optional[str] = None, title: Op @tool @impulse(bookmark_radius) + @destructive async def delete_bookmark(bookmark_id: int): """ Delete a bookmark diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 922f8466..a7728049 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -14,7 +14,7 @@ import vobject from ex_app.lib.all_tools.lib.audience import principal_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour @@ -472,6 +472,7 @@ def delete_task_sync(calendar_name: str, task_uid: str): @tool @impulse(calendar_radius) + @destructive async def delete_task(calendar_name: str, task_uid: str): """ Delete a task diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index 5fb0fb3b..bce6c154 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse # Nextcloud Circles member type constants TYPE_USER = 1 @@ -110,6 +110,7 @@ async def add_member_to_circle(circle_id: str, member_id: str, member_type: int @tool @impulse(ImpulseRadius.SELF) + @destructive async def remove_member_from_circle(circle_id: str, member_id: str): """ Remove a member from a circle (team) @@ -140,6 +141,7 @@ async def update_circle(circle_id: str, name: Optional[str] = None, description: @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_circle(circle_id: str): """ Delete a circle (team) diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py index 5b9a972e..b63b9c8c 100644 --- a/ex_app/lib/all_tools/collectives.py +++ b/ex_app/lib/all_tools/collectives.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse # Unlike the other write tools, which append their AI note to a value they create, # update_page_content replaces a whole page the agent usually read back first - so the @@ -234,6 +234,7 @@ async def set_page_emoji(collective_id: int, page_id: int, emoji: str): @tool @impulse(collective_radius) + @destructive async def trash_page(collective_id: int, page_id: int): """ Soft-delete a page by moving it to the collective's page trash. @@ -259,6 +260,7 @@ async def restore_page(collective_id: int, page_id: int): @tool @impulse(collective_radius) + @destructive async def delete_page_permanently(collective_id: int, page_id: int): """ Permanently delete a page that is already in the trash. This cannot be undone. diff --git a/ex_app/lib/all_tools/cookbook.py b/ex_app/lib/all_tools/cookbook.py index 54867887..435c7f86 100644 --- a/ex_app/lib/all_tools/cookbook.py +++ b/ex_app/lib/all_tools/cookbook.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -99,6 +99,7 @@ async def create_recipe(name: str, description: Optional[str] = None, ingredient @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_recipe(recipe_id: int): """ Delete a recipe diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 9380d9d0..6a3afde3 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -6,7 +6,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -205,6 +205,7 @@ async def assign_card_to_user(board_id: int, stack_id: int, card_id: int, user_i @tool @impulse(board_radius) + @destructive async def delete_card(board_id: int, stack_id: int, card_id: int): """ Delete a card from a board @@ -270,6 +271,7 @@ async def update_card_comment(card_id: int, comment_id: int, message: str): @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_card_comment(card_id: int, comment_id: int): """ Delete a comment from a Deck card. Only the comment author can delete their own comments. diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index 4f2d4326..c5d17085 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -8,7 +8,7 @@ from nc_py_api.files.files_async import AsyncFilesAPI, FsNode from ex_app.lib.all_tools.lib.audience import file_path_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse from ex_app.lib.all_tools.lib.files import format_fs_node, get_file_content_from_int_link, get_file_id_from_file_url @@ -300,6 +300,7 @@ async def convert_file(source_path: str, target_mime_type: str, destination_path @tool @impulse(path_radius) + @destructive async def delete_file(path: str): """ Delete a file or folder diff --git a/ex_app/lib/all_tools/forms.py b/ex_app/lib/all_tools/forms.py index 19e293df..250b8512 100644 --- a/ex_app/lib/all_tools/forms.py +++ b/ex_app/lib/all_tools/forms.py @@ -5,7 +5,7 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.audience import share_type_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -108,6 +108,7 @@ async def get_form_responses(form_id: int): @tool @impulse(form_radius) + @destructive async def delete_form(form_id: int): """ Delete a form diff --git a/ex_app/lib/all_tools/lib/impulse.py b/ex_app/lib/all_tools/lib/impulse.py index 65800d7b..2a132c53 100644 --- a/ex_app/lib/all_tools/lib/impulse.py +++ b/ex_app/lib/all_tools/lib/impulse.py @@ -24,8 +24,14 @@ in a conversation) the radius is that existing audience -- the audience is who the action reaches. -The radius is compared against the admin-configured threshold to decide whether -the user has to confirm the call; see :func:`needs_confirmation`. +Radius answers who a call reaches, which says nothing about whether it takes +something away. That is the second dimension: a tool marked :func:`destructive` +deletes something, and deletions are confirmed on their own threshold, so +emptying a folder of your own files can still be worth asking about even though +it discloses nothing. + +Both dimensions feed :func:`needs_confirmation`, which compares each against its +admin-configured threshold. """ import inspect from enum import IntEnum @@ -52,7 +58,14 @@ class ImpulseRadius(IntEnum): IMPULSE_THRESHOLD_SETTING_ID = 'impulse_radius_threshold' +# Deletions are confirmed from this radius on. SELF means every deletion is +# confirmed, since no call reaches less far than that. +DEFAULT_DESTRUCTIVE_THRESHOLD = ImpulseRadius.SELF + +DESTRUCTIVE_THRESHOLD_SETTING_ID = 'destructive_radius_threshold' + _ATTR = 'impulse_hook' +_DESTRUCTIVE_ATTR = 'impulse_destructive' def parse_impulse_radius(value, default=DEFAULT_IMPULSE_RADIUS) -> ImpulseRadius: @@ -106,6 +119,30 @@ def decorator(tool_func): return decorator +def destructive(tool_func): + """Mark a tool as deleting something. + + Applied to the tool function like :func:`impulse`, and independent of it: a + deletion can reach anyone at all, from a note only the user can see to a page + in a team wiki. + + @tool + @impulse(ImpulseRadius.SELF) + @destructive + async def delete_memory(path: str): ... + """ + setattr(tool_func, _DESTRUCTIVE_ATTR, True) + return tool_func + + +def is_destructive(tool) -> bool: + """Whether this tool deletes something.""" + tool_action = getattr(tool, 'coroutine', None) or getattr(tool, 'func', None) + if tool_action is None: + return False + return bool(getattr(tool_action, _DESTRUCTIVE_ATTR, False)) + + def get_impulse_hook(tool): """Return the impulse hook of a LangChain tool, or None if it carries none.""" tool_action = getattr(tool, 'coroutine', None) or getattr(tool, 'func', None) @@ -149,6 +186,18 @@ async def classify_tool_call(tool, tool_args: dict) -> ImpulseRadius: return DEFAULT_IMPULSE_RADIUS -def needs_confirmation(radius: ImpulseRadius, threshold: ImpulseRadius) -> bool: - """Whether a call of this reach must be confirmed by the user.""" - return radius >= threshold +def needs_confirmation( + radius: ImpulseRadius, + threshold: ImpulseRadius, + destroys: bool = False, + destructive_threshold: ImpulseRadius = DEFAULT_DESTRUCTIVE_THRESHOLD, +) -> bool: + """Whether a call of this reach must be confirmed by the user. + + A call is confirmed when it reaches at least as far as the threshold, and a + deletion is confirmed when it reaches at least as far as the deletion + threshold -- which is the lower of the two bars in any sane configuration. + """ + if radius >= threshold: + return True + return destroys and radius >= destructive_threshold diff --git a/ex_app/lib/all_tools/memory.py b/ex_app/lib/all_tools/memory.py index 544a32b1..afaae95d 100644 --- a/ex_app/lib/all_tools/memory.py +++ b/ex_app/lib/all_tools/memory.py @@ -14,7 +14,7 @@ from nc_py_api.files.files_async import AsyncFilesAPI from pydantic import BaseModel, ValidationError, computed_field, field_validator -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.logger import log @@ -283,6 +283,7 @@ async def store_memory(path: str, content: str): @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_memory(path: str): """ Deletes a particular memory file identified by a full file/memory path. @@ -312,6 +313,7 @@ async def delete_memory(path: str): @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_memory_folder(path: str): """ Deletes the whole folder of memories by a full path. diff --git a/ex_app/lib/all_tools/shares.py b/ex_app/lib/all_tools/shares.py index a815860b..5b1e2fcb 100644 --- a/ex_app/lib/all_tools/shares.py +++ b/ex_app/lib/all_tools/shares.py @@ -5,7 +5,7 @@ from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.audience import share_id_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -80,6 +80,7 @@ async def update_share_permissions(share_id: int, permissions: int): @tool @impulse(ImpulseRadius.SELF) + @destructive async def delete_share(share_id: int): """ Remove/delete a share diff --git a/ex_app/lib/all_tools/tables.py b/ex_app/lib/all_tools/tables.py index 86292bbb..2257ba1f 100644 --- a/ex_app/lib/all_tools/tables.py +++ b/ex_app/lib/all_tools/tables.py @@ -5,7 +5,7 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -124,6 +124,7 @@ async def update_table(table_id: int, title: Optional[str] = None, emoji: Option @tool @impulse(table_radius) + @destructive async def delete_table(table_id: int): """ Delete a table and all its columns and rows @@ -322,6 +323,7 @@ async def update_column( @tool @impulse(column_radius) + @destructive async def delete_column(column_id: int): """ Delete a column from a table. This also removes all data stored in this column for every row. @@ -425,6 +427,7 @@ async def update_row(row_id: int, data: str, view_id: Optional[int] = None): @tool @impulse(row_radius) + @destructive async def delete_row(row_id: int): """ Delete a row from a table diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 0e419e05..1e354c69 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -6,7 +6,7 @@ from nc_py_api import AsyncNextcloudApp from nc_py_api.talk import ConversationType -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @@ -117,6 +117,7 @@ async def add_reaction(conversation_name: str, message_id: int, reaction: str): @tool @impulse(ImpulseRadius.SELF) + @destructive async def remove_reaction(conversation_name: str, message_id: int, reaction: str): """ Remove an emoji reaction from a message in a Talk conversation. diff --git a/ex_app/lib/graph.py b/ex_app/lib/graph.py index 7a858051..5485beae 100644 --- a/ex_app/lib/graph.py +++ b/ex_app/lib/graph.py @@ -11,10 +11,12 @@ from typing_extensions import TypedDict, Annotated from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_DESTRUCTIVE_THRESHOLD, DEFAULT_IMPULSE_RADIUS, DEFAULT_IMPULSE_THRESHOLD, ImpulseRadius, classify_tool_call, + is_destructive, needs_confirmation, ) @@ -55,7 +57,13 @@ def create_tool_node_with_fallback(tools: list) -> dict: [RunnableLambda(handle_tool_error)], exception_key="error" ) -async def get_graph(call_model, tools, checkpointer, impulse_threshold: ImpulseRadius = DEFAULT_IMPULSE_THRESHOLD): +async def get_graph( + call_model, + tools, + checkpointer, + impulse_threshold: ImpulseRadius = DEFAULT_IMPULSE_THRESHOLD, + destructive_threshold: ImpulseRadius = DEFAULT_DESTRUCTIVE_THRESHOLD, +): tools_by_name = {tool.name: tool for tool in tools} # Define a new graph @@ -70,29 +78,34 @@ async def get_graph(call_model, tools, checkpointer, impulse_threshold: ImpulseR # This means that this node is the first one called workflow.set_entry_point("agent") - async def impulse_radius_of(state: AgentState) -> ImpulseRadius: - """The widest radius any of the pending tool calls reaches.""" + async def classify_pending_calls(state: AgentState) -> tuple[ImpulseRadius, bool]: + """The widest radius the pending tool calls reach, and whether any deletes.""" radius = ImpulseRadius.SELF + destroys = False for tool_call in state["messages"][-1].tool_calls: tool = tools_by_name.get(tool_call["name"]) if tool is None: # The model hallucinated a tool; the tool node will error out on it, # but until then treat it as the widest reach. - call_radius = DEFAULT_IMPULSE_RADIUS + call_radius, call_destroys = DEFAULT_IMPULSE_RADIUS, False else: call_radius = await classify_tool_call(tool, tool_call.get("args") or {}) - print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}") + call_destroys = is_destructive(tool) + print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}" + f"{', deletes something' if call_destroys else ''}") radius = max(radius, call_radius) - return radius + destroys = destroys or call_destroys + return radius, destroys async def route_tools(state: AgentState): next_node = tools_condition(state) # If no tools are invoked, return to the user if next_node == END: return END - radius = await impulse_radius_of(state) - if needs_confirmation(radius, impulse_threshold): - print(f"Impulse radius {radius.name} reaches the threshold {impulse_threshold.name}, asking the user") + radius, destroys = await classify_pending_calls(state) + if needs_confirmation(radius, impulse_threshold, destroys, destructive_threshold): + threshold = destructive_threshold if destroys and radius < impulse_threshold else impulse_threshold + print(f"Impulse radius {radius.name} reaches the threshold {threshold.name}, asking the user") return CONFIRM_TOOLS_NODE return AUTO_TOOLS_NODE diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index d91b47af..51bf9be5 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -26,7 +26,9 @@ from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware from ex_app.lib.provider import provider, multimodal_provider from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_DESTRUCTIVE_THRESHOLD, DEFAULT_IMPULSE_THRESHOLD, + DESTRUCTIVE_THRESHOLD_SETTING_ID, IMPULSE_THRESHOLD_SETTING_ID, ImpulseRadius, ) @@ -117,6 +119,25 @@ async def exapp_lifespan(app: FastAPI): ImpulseRadius.EXTERNAL.name.lower(), }, ), + SettingsField( + id=DESTRUCTIVE_THRESHOLD_SETTING_ID, + title=_("Ask the user to confirm a deletion from this impulse radius on"), + description=_( + "Deleting something takes it away without giving anyone access to it, so deletions are" + " judged on their own bar. Pick how far a deletion has to reach before Context Agent asks." + ), + type=SettingsFieldType.RADIO, + default=DEFAULT_DESTRUCTIVE_THRESHOLD.name.lower(), + options={ + _("Only me - confirm every deletion"): ImpulseRadius.SELF.name.lower(), + _("Individual people - confirm deletions of what named people can see"): + ImpulseRadius.INDIVIDUALS.name.lower(), + _("A group - confirm deletions of what a group, team or conversation can see"): + ImpulseRadius.GROUP.name.lower(), + _("Outside this Nextcloud - confirm only deletions of what left the instance"): + ImpulseRadius.EXTERNAL.name.lower(), + }, + ), SettingsField( id="here_api", title=_("API Key HERE"), diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index 8eed8f37..f98dfc2f 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -10,20 +10,28 @@ from ex_app.lib.all_tools.lib.decorator import timed_memoize from ex_app.lib.all_tools.lib.impulse import ( + DEFAULT_DESTRUCTIVE_THRESHOLD, DEFAULT_IMPULSE_THRESHOLD, + DESTRUCTIVE_THRESHOLD_SETTING_ID, IMPULSE_THRESHOLD_SETTING_ID, ImpulseRadius, parse_impulse_radius, ) +async def _get_threshold(nc: AsyncNextcloudApp, setting_id: str, default: ImpulseRadius) -> ImpulseRadius: + configured = await nc.appconfig_ex.get_value(setting_id, default=default.name.lower()) + return parse_impulse_radius(configured, default=default) + + async def get_impulse_threshold(nc: AsyncNextcloudApp) -> ImpulseRadius: """The configured impulse radius from which on a tool call has to be confirmed.""" - configured = await nc.appconfig_ex.get_value( - IMPULSE_THRESHOLD_SETTING_ID, - default=DEFAULT_IMPULSE_THRESHOLD.name.lower(), - ) - return parse_impulse_radius(configured, default=DEFAULT_IMPULSE_THRESHOLD) + return await _get_threshold(nc, IMPULSE_THRESHOLD_SETTING_ID, DEFAULT_IMPULSE_THRESHOLD) + + +async def get_destructive_threshold(nc: AsyncNextcloudApp) -> ImpulseRadius: + """The configured impulse radius from which on a deletion has to be confirmed.""" + return await _get_threshold(nc, DESTRUCTIVE_THRESHOLD_SETTING_ID, DEFAULT_DESTRUCTIVE_THRESHOLD) @timed_memoize(1*60) From ac9be82fdd5e962355a83e449479878d98832991 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:10:34 +0200 Subject: [PATCH 03/17] fix: Always confirm scheduled task writes, add confirmation for implicitly destructive tools Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/assignments.py | 4 +- ex_app/lib/all_tools/files.py | 35 +++++++++- ex_app/lib/all_tools/lib/impulse.py | 102 +++++++++++++++++++++++++--- ex_app/lib/graph.py | 34 ++++++---- ex_app/lib/main.py | 15 ++-- 5 files changed, 158 insertions(+), 32 deletions(-) diff --git a/ex_app/lib/all_tools/assignments.py b/ex_app/lib/all_tools/assignments.py index 470f58bf..d5149bac 100644 --- a/ex_app/lib/all_tools/assignments.py +++ b/ex_app/lib/all_tools/assignments.py @@ -6,13 +6,14 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, always_confirm, destructive, impulse async def get_tools(nc: AsyncNextcloudApp): @tool @impulse(ImpulseRadius.SELF) + @always_confirm async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str|None = None, starts_at: None|str = None): """ Create a Scheduled Task for the assistant that will be carried out autonomously. @@ -50,6 +51,7 @@ async def list_scheduled_tasks(): @tool @impulse(ImpulseRadius.SELF) + @always_confirm async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rule: None|str = None, timezone: str|None = None, starts_at: None|str = None): """ Update a assistant Scheduled Task diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index c5d17085..e5ffb738 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -5,10 +5,11 @@ from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp +from nc_py_api._exceptions import NextcloudException from nc_py_api.files.files_async import AsyncFilesAPI, FsNode from ex_app.lib.all_tools.lib.audience import file_path_radius -from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse +from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, destructive_if, impulse from ex_app.lib.all_tools.lib.files import format_fs_node, get_file_content_from_int_link, get_file_id_from_file_url @@ -32,6 +33,35 @@ async def transfer_radius(source_path=None, destination_path=None): """A copy or move reaches whoever can see either end of it.""" return await file_path_radius(nc, source_path, destination_path) + async def _target_taken(target): + """Whether a write to this path would land on something, and so replace it. + + Both PUT and COPY overwrite by default, so whether anything is lost comes + down to the target being free. A free path is the common case and says so + by raising 404, not by returning empty. + """ + if not target: + # Without a target the call cannot succeed; let the tool report that + # rather than asking the user about a write that will not happen. + return False + try: + existing = await AsyncFilesAPI(nc._session).by_path(_validate_path(target)) + except NextcloudException as e: + if e.status_code == 404: + return False + # Anything else leaves the question open, and an open question is + # answered by asking the user. + raise + return existing is not None + + async def path_taken(path=None): + """Whether writing to this path would replace a file that is already there.""" + return await _target_taken(path) + + async def destination_taken(destination_path=None): + """Whether a copy would land on something, and so replace it.""" + return await _target_taken(destination_path) + @tool @impulse(ImpulseRadius.SELF) async def get_file_content(file_path: str): @@ -111,6 +141,7 @@ async def create_public_sharing_link(path: str): @tool @impulse(path_radius) + @destructive_if(path_taken) async def upload_file(path: str, content: str): """ Upload or create a new file with text content @@ -146,6 +177,7 @@ async def create_folder(path: str): @tool @impulse(transfer_radius) + @destructive async def move_file(source_path: str, destination_path: str): """ Move or rename a file or folder @@ -165,6 +197,7 @@ async def move_file(source_path: str, destination_path: str): @tool @impulse(transfer_radius) + @destructive_if(destination_taken) async def copy_file(source_path: str, destination_path: str): """ Copy a file or folder diff --git a/ex_app/lib/all_tools/lib/impulse.py b/ex_app/lib/all_tools/lib/impulse.py index 2a132c53..0c3e2752 100644 --- a/ex_app/lib/all_tools/lib/impulse.py +++ b/ex_app/lib/all_tools/lib/impulse.py @@ -26,12 +26,20 @@ Radius answers who a call reaches, which says nothing about whether it takes something away. That is the second dimension: a tool marked :func:`destructive` -deletes something, and deletions are confirmed on their own threshold, so -emptying a folder of your own files can still be worth asking about even though -it discloses nothing. - -Both dimensions feed :func:`needs_confirmation`, which compares each against its -admin-configured threshold. +deletes or overwrites something, and those are confirmed on their own threshold, +so emptying a folder of your own files can still be worth asking about even +though it discloses nothing. Like the radius, it can depend on the arguments -- +:func:`destructive_if` takes a hook, because copying onto a free path destroys +nothing while copying onto a taken one replaces a file. + +Neither dimension says anything about what a call lets the agent do *later*. A +handful of tools hand the agent a lever it can pull unattended afterwards -- a +scheduled task runs on its own, with its own prompt, reaching wherever its tools +reach. Their reach is not knowable at classification time, so they carry +:func:`always_confirm` and are asked about no matter how the thresholds are set. + +All three feed :func:`needs_confirmation`, which compares the first two against +their admin-configured thresholds and honours the third unconditionally. """ import inspect from enum import IntEnum @@ -66,6 +74,7 @@ class ImpulseRadius(IntEnum): _ATTR = 'impulse_hook' _DESTRUCTIVE_ATTR = 'impulse_destructive' +_ALWAYS_CONFIRM_ATTR = 'impulse_always_confirm' def parse_impulse_radius(value, default=DEFAULT_IMPULSE_RADIUS) -> ImpulseRadius: @@ -120,7 +129,7 @@ def decorator(tool_func): def destructive(tool_func): - """Mark a tool as deleting something. + """Mark a tool as destroying content the user had. Applied to the tool function like :func:`impulse`, and independent of it: a deletion can reach anyone at all, from a note only the user can see to a page @@ -130,17 +139,83 @@ def destructive(tool_func): @impulse(ImpulseRadius.SELF) @destructive async def delete_memory(path: str): ... + + Deleting is the obvious case, but overwriting is the same loss by another + name: writing a file that already exists leaves the user just as short of what + was there before. Use :func:`destructive_if` when only some arguments do that. """ setattr(tool_func, _DESTRUCTIVE_ATTR, True) return tool_func -def is_destructive(tool) -> bool: - """Whether this tool deletes something.""" +def destructive_if(hook): + """Mark a tool as destroying something only for certain arguments. + + Some tools overwrite or create depending on what is already there: copying + onto a free path costs nothing, copying onto a taken one replaces a file. The + hook takes (a subset of) the tool's parameters, exactly like an impulse hook, + and answers whether *this* call destroys something:: + + async def _overwrites(destination_path=None): + return await file_exists(nc, destination_path) + + @tool + @impulse(transfer_radius) + @destructive_if(_overwrites) + async def copy_file(...): ... + + A hook that raises is read as destroying something, so a question that cannot + be answered still reaches the user. + """ + def decorator(tool_func): + setattr(tool_func, _DESTRUCTIVE_ATTR, hook) + return tool_func + + return decorator + + +async def classify_destructive(tool, tool_args: dict) -> bool: + """Whether this call deletes or overwrites something.""" tool_action = getattr(tool, 'coroutine', None) or getattr(tool, 'func', None) if tool_action is None: return False - return bool(getattr(tool_action, _DESTRUCTIVE_ATTR, False)) + marker = getattr(tool_action, _DESTRUCTIVE_ATTR, False) + if not callable(marker): + return bool(marker) + try: + result = marker(**_select_hook_kwargs(marker, tool_args or {})) + if inspect.isawaitable(result): + result = await result + return bool(result) + except Exception as e: # noqa: BLE001 - a hook must never break a tool call + print(f"Destructiveness hook for '{getattr(tool, 'name', tool)}' failed ({e!r}), assuming it destroys something") + return True + + +def always_confirm(tool_func): + """Mark a tool as needing confirmation whatever the thresholds are set to. + + For the few tools whose reach is not the reach of this call: scheduling a task + discloses nothing now, but hands the agent a prompt it will run unattended + later, with whatever radius the tools it then picks happen to have. There is + no radius that describes that honestly, so these opt out of the comparison + instead of being given an inflated one. + + @tool + @impulse(ImpulseRadius.SELF) + @always_confirm + async def create_scheduled_task(...): ... + """ + setattr(tool_func, _ALWAYS_CONFIRM_ATTR, True) + return tool_func + + +def is_always_confirmed(tool) -> bool: + """Whether this tool is confirmed regardless of its radius.""" + tool_action = getattr(tool, 'coroutine', None) or getattr(tool, 'func', None) + if tool_action is None: + return False + return bool(getattr(tool_action, _ALWAYS_CONFIRM_ATTR, False)) def get_impulse_hook(tool): @@ -156,6 +231,7 @@ def _select_hook_kwargs(hook, tool_args: dict) -> dict: Tool arguments come from a model, so they can be incomplete or carry keys the hook never asked about. Filtering here keeps hooks free of defensive noise. + Shared by impulse radius hooks and :func:`destructive_if` hooks. """ try: parameters = inspect.signature(hook).parameters @@ -191,13 +267,17 @@ def needs_confirmation( threshold: ImpulseRadius, destroys: bool = False, destructive_threshold: ImpulseRadius = DEFAULT_DESTRUCTIVE_THRESHOLD, + always: bool = False, ) -> bool: """Whether a call of this reach must be confirmed by the user. A call is confirmed when it reaches at least as far as the threshold, and a deletion is confirmed when it reaches at least as far as the deletion - threshold -- which is the lower of the two bars in any sane configuration. + threshold -- which is the lower of the two bars in any sane configuration. A + call marked :func:`always_confirm` is confirmed without consulting either. """ + if always: + return True if radius >= threshold: return True return destroys and radius >= destructive_threshold diff --git a/ex_app/lib/graph.py b/ex_app/lib/graph.py index 5485beae..08db2c00 100644 --- a/ex_app/lib/graph.py +++ b/ex_app/lib/graph.py @@ -15,8 +15,9 @@ DEFAULT_IMPULSE_RADIUS, DEFAULT_IMPULSE_THRESHOLD, ImpulseRadius, + classify_destructive, classify_tool_call, - is_destructive, + is_always_confirmed, needs_confirmation, ) @@ -78,34 +79,43 @@ async def get_graph( # This means that this node is the first one called workflow.set_entry_point("agent") - async def classify_pending_calls(state: AgentState) -> tuple[ImpulseRadius, bool]: - """The widest radius the pending tool calls reach, and whether any deletes.""" + async def classify_pending_calls(state: AgentState) -> tuple[ImpulseRadius, bool, bool]: + """The widest radius the pending tool calls reach, whether any destroys + something, and whether any is confirmed regardless of its radius.""" radius = ImpulseRadius.SELF destroys = False + always = False for tool_call in state["messages"][-1].tool_calls: tool = tools_by_name.get(tool_call["name"]) if tool is None: # The model hallucinated a tool; the tool node will error out on it, # but until then treat it as the widest reach. - call_radius, call_destroys = DEFAULT_IMPULSE_RADIUS, False + call_radius, call_destroys, call_always = DEFAULT_IMPULSE_RADIUS, False, False else: - call_radius = await classify_tool_call(tool, tool_call.get("args") or {}) - call_destroys = is_destructive(tool) + call_args = tool_call.get("args") or {} + call_radius = await classify_tool_call(tool, call_args) + call_destroys = await classify_destructive(tool, call_args) + call_always = is_always_confirmed(tool) print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}" - f"{', deletes something' if call_destroys else ''}") + f"{', destroys something' if call_destroys else ''}" + f"{', always confirmed' if call_always else ''}") radius = max(radius, call_radius) destroys = destroys or call_destroys - return radius, destroys + always = always or call_always + return radius, destroys, always async def route_tools(state: AgentState): next_node = tools_condition(state) # If no tools are invoked, return to the user if next_node == END: return END - radius, destroys = await classify_pending_calls(state) - if needs_confirmation(radius, impulse_threshold, destroys, destructive_threshold): - threshold = destructive_threshold if destroys and radius < impulse_threshold else impulse_threshold - print(f"Impulse radius {radius.name} reaches the threshold {threshold.name}, asking the user") + radius, destroys, always = await classify_pending_calls(state) + if needs_confirmation(radius, impulse_threshold, destroys, destructive_threshold, always): + if always: + print("A pending tool call is always confirmed, asking the user") + else: + threshold = destructive_threshold if destroys and radius < impulse_threshold else impulse_threshold + print(f"Impulse radius {radius.name} reaches the threshold {threshold.name}, asking the user") return CONFIRM_TOOLS_NODE return AUTO_TOOLS_NODE diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 51bf9be5..c1f3c08c 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -121,20 +121,21 @@ async def exapp_lifespan(app: FastAPI): ), SettingsField( id=DESTRUCTIVE_THRESHOLD_SETTING_ID, - title=_("Ask the user to confirm a deletion from this impulse radius on"), + title=_("Ask the user to confirm losing content from this impulse radius on"), description=_( - "Deleting something takes it away without giving anyone access to it, so deletions are" - " judged on their own bar. Pick how far a deletion has to reach before Context Agent asks." + "Deleting something takes it away without giving anyone access to it, and overwriting it" + " loses it just the same, so both are judged on their own bar. Pick how far the content" + " being lost has to reach before Context Agent asks." ), type=SettingsFieldType.RADIO, default=DEFAULT_DESTRUCTIVE_THRESHOLD.name.lower(), options={ - _("Only me - confirm every deletion"): ImpulseRadius.SELF.name.lower(), - _("Individual people - confirm deletions of what named people can see"): + _("Only me - confirm every deletion or overwrite"): ImpulseRadius.SELF.name.lower(), + _("Individual people - confirm losing what named people can see"): ImpulseRadius.INDIVIDUALS.name.lower(), - _("A group - confirm deletions of what a group, team or conversation can see"): + _("A group - confirm losing what a group, team or conversation can see"): ImpulseRadius.GROUP.name.lower(), - _("Outside this Nextcloud - confirm only deletions of what left the instance"): + _("Outside this Nextcloud - confirm only losing what left the instance"): ImpulseRadius.EXTERNAL.name.lower(), }, ), From f4d9016ad4d7104d3f1efc729c5c83b2895c5ddb Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:14:11 +0200 Subject: [PATCH 04/17] fix(talk): Make close_poll use conversation radius for confirmation classification Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/talk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 1e354c69..a6ab7027 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -221,7 +221,7 @@ async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[in })) @tool - @impulse(ImpulseRadius.SELF) + @impulse(conversation_radius) async def close_poll(conversation_name: str, poll_id: int): """ Close a poll so no more votes can be cast. Only the poll creator or a moderator can close a poll. From 39776d0175354818084537bdd85c628e0dcf8efe Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:19:33 +0200 Subject: [PATCH 05/17] fix: Make impulse radius aware of team folders Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/lib/audience.py | 68 ++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/ex_app/lib/all_tools/lib/audience.py b/ex_app/lib/all_tools/lib/audience.py index 9ad4ef8f..49d5d4a3 100644 --- a/ex_app/lib/all_tools/lib/audience.py +++ b/ex_app/lib/all_tools/lib/audience.py @@ -12,6 +12,8 @@ into the widest radius -- so an unanswerable question makes the agent ask the user rather than act quietly. """ +import xml.etree.ElementTree as ET + from ex_app.lib.all_tools.lib.impulse import ImpulseRadius # How far a share reaches, by Nextcloud share type. Types missing here fall back @@ -63,12 +65,69 @@ def path_and_parents(path: str) -> set: return {'/' + '/'.join(parts[:i]) for i in range(1, len(parts) + 1)} +# How far the storage a file sits on reaches, by DAV mount type. A share is not +# the only way somebody else gets to see a file, and the ones that are not shares +# leave no trace in the sharing API at all. +MOUNT_TYPE_RADIUS = { + # A Team folder (group folder) is mounted for every group and team it is + # assigned to, without a single share existing anywhere. + 'group': ImpulseRadius.GROUP, + # A received share: somebody else owns the storage and sees what lands in it. + # Only a floor -- the share lookup raises it when it can match the path to a + # share and read its type. + 'shared': ImpulseRadius.INDIVIDUALS, + # 'external' is deliberately absent: external storage is just as often a + # personal mount nobody else can reach as it is a shared one, and guessing + # either way would be worse than letting the share lookup answer. +} + +MOUNT_TYPE_PROPFIND = ( + '' + '' + '' + '' +) + + +async def mount_type_radius(nc, path) -> ImpulseRadius: + """How far the storage this path sits on reaches, read off its DAV mount. + + Nextcloud reports a mount type for every node, and a mount covers its whole + subtree, so one lookup on the path answers for it and every folder above it. + + A path that does not exist yet has no mount of its own but inherits the one it + will land in, so the closest ancestor that does exist is asked instead. That + keeps a file written into a Team folder from looking private just because it + is not there yet. + """ + user_id = await nc.user + adapter = nc._session._create_adapter(True) + # Deepest first, then the user's root, which always resolves. + candidates = sorted(path_and_parents(path), key=len, reverse=True) + [''] + for candidate in candidates: + response = await adapter.request( + 'PROPFIND', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{candidate.lstrip('/')}", + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"}, + data=MOUNT_TYPE_PROPFIND, + ) + if response.status_code == 404: + continue + if response.status_code != 207: + raise ValueError(f'Could not read the mount of {candidate!r}: HTTP {response.status_code}') + element = ET.fromstring(response.text).find('.//{http://nextcloud.org/ns}mount-type') + mount_type = (element.text or '').strip().lower() if element is not None else '' + return MOUNT_TYPE_RADIUS.get(mount_type, ImpulseRadius.SELF) + return ImpulseRadius.SELF + + async def file_path_radius(nc, *paths) -> ImpulseRadius: """Who can reach these files or folders, through a share on them or on a parent. - Covers both directions: folders the user shared out, and folders that were - shared with the user, where the owner and the other recipients see whatever is - written into them. + Covers three ways in: folders the user shared out, folders that were shared + with the user, where the owner and the other recipients see whatever is + written into them, and Team folders, which a whole group has mounted without + any share existing to find. """ covered = set() for path in paths: @@ -78,6 +137,9 @@ async def file_path_radius(nc, *paths) -> ImpulseRadius: raise ValueError('No path to determine the audience of') radius = ImpulseRadius.SELF + for path in paths: + if path: + radius = max(radius, await mount_type_radius(nc, path)) for params in ({}, {'shared_with_me': 'true'}): shares = await nc.ocs('GET', '/ocs/v2.php/apps/files_sharing/api/v1/shares', params=params) for share in shares or []: From f024d1cfc4b9ae5855702937e6f2dc5ada057bf4 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:25:04 +0200 Subject: [PATCH 06/17] fix(deck): More caching Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/deck.py | 39 +++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 6a3afde3..99fa671e 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -8,15 +8,21 @@ from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, destructive, impulse +# Resolving a card to its board costs one request per board the user can see, so +# the map is kept per user and out here, rather than in the get_tools closure that +# timed_memoize drops every minute along with the tools themselves. +CARD_BOARD_TTL = 60 +# A miss is worth one crawl -- a card the agent just created is not in the map +# yet. Right after a crawl it is not: an id that is simply not there would +# otherwise walk every board again on every call that mentions it. +CARD_BOARD_MISS_INTERVAL = 10 +_card_boards: dict[str, dict] = {} + async def get_tools(nc: AsyncNextcloudApp): DECK_API = f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0" DECK_HEADERS = {"Content-Type": "application/json", "OCS-APIREQUEST": "true"} - # Resolving a card to its board costs one request per board, so the map is kept - # for a short while; a card the agent just created is not in it yet, which is - # what the refresh on a miss is for. - card_board_cache = {'boards': {}, 'fetched_at': 0.0} async def deck_get(path): response = await nc._session._create_adapter().request('GET', f"{DECK_API}{path}", headers=DECK_HEADERS) @@ -42,25 +48,30 @@ async def assignment_radius(board_id): """Assigning reaches the assignee on top of whoever the board already reaches.""" return max(ImpulseRadius.INDIVIDUALS, await board_radius(board_id)) - async def refresh_card_boards(): - """Map every card the user can reach to the board it lives on.""" + async def refresh_card_boards(cache): + """Map every card the user can reach to the board it lives on. + + One request for the board list, then one per board -- the stacks of a board + carry its cards, so the cards themselves cost nothing extra. + """ boards = {} for board in await deck_get('/boards'): for stack in await deck_get(f"/boards/{board['id']}/stacks"): for card in stack.get('cards') or []: boards[card['id']] = board - card_board_cache['boards'] = boards - card_board_cache['fetched_at'] = time.monotonic() + cache['boards'] = boards + cache['fetched_at'] = time.monotonic() return boards async def card_radius(card_id): """A comment on a card reaches whoever the card's board reaches.""" - boards = card_board_cache['boards'] - if time.monotonic() - card_board_cache['fetched_at'] > 60: - boards = await refresh_card_boards() - if int(card_id) not in boards: - boards = await refresh_card_boards() - board = boards.get(int(card_id)) + card_id = int(card_id) + cache = _card_boards.setdefault(await nc.user, {'boards': {}, 'fetched_at': 0.0}) + age = time.monotonic() - cache['fetched_at'] + # At most one crawl per call, whether the map went stale or the card is new. + if age > CARD_BOARD_TTL or (card_id not in cache['boards'] and age > CARD_BOARD_MISS_INTERVAL): + await refresh_card_boards(cache) + board = cache['boards'].get(card_id) if board is None: raise ValueError(f'No board holds a card with id {card_id!r}') return await board_acl_radius(board) From 509461dd376ec2be0ef977a1b2016fadd3cd3895 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:25:24 +0200 Subject: [PATCH 07/17] fix(tables): Small consistency fix Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ex_app/lib/all_tools/tables.py b/ex_app/lib/all_tools/tables.py index 2257ba1f..519c1895 100644 --- a/ex_app/lib/all_tools/tables.py +++ b/ex_app/lib/all_tools/tables.py @@ -28,7 +28,7 @@ async def table_share_radius(table_id): radius = max(radius, ImpulseRadius.INDIVIDUALS) else: # 'link' and 'remote' both leave the instance, as does anything new. - radius = ImpulseRadius.EXTERNAL + radius = max(radius, ImpulseRadius.EXTERNAL) return radius async def table_radius(table_id): From 821637fce40ff2705ab97c13e69cca84e6d38d08 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:26:01 +0200 Subject: [PATCH 08/17] fix: Cache threshold settings Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/tools.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index f98dfc2f..b93c00aa 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -24,11 +24,17 @@ async def _get_threshold(nc: AsyncNextcloudApp, setting_id: str, default: Impuls return parse_impulse_radius(configured, default=default) +# Cached like get_tools below, and for the same reason: every turn reads these, an +# admin changes them once in a while. Each gets its own memoize closure because +# timed_memoize keys on the user alone -- sharing one would serve whichever +# threshold was asked for first. +@timed_memoize(1*60) async def get_impulse_threshold(nc: AsyncNextcloudApp) -> ImpulseRadius: """The configured impulse radius from which on a tool call has to be confirmed.""" return await _get_threshold(nc, IMPULSE_THRESHOLD_SETTING_ID, DEFAULT_IMPULSE_THRESHOLD) +@timed_memoize(1*60) async def get_destructive_threshold(nc: AsyncNextcloudApp) -> ImpulseRadius: """The configured impulse radius from which on a deletion has to be confirmed.""" return await _get_threshold(nc, DESTRUCTIVE_THRESHOLD_SETTING_ID, DEFAULT_DESTRUCTIVE_THRESHOLD) From c3bfe6a9476307241f911e5fbee285ca65832383 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:27:24 +0200 Subject: [PATCH 09/17] fix: Classify tool calls concurrently Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/graph.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/ex_app/lib/graph.py b/ex_app/lib/graph.py index 08db2c00..b90c76f1 100644 --- a/ex_app/lib/graph.py +++ b/ex_app/lib/graph.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import traceback from typing import Sequence @@ -79,23 +80,36 @@ async def get_graph( # This means that this node is the first one called workflow.set_entry_point("agent") + async def classify_one(tool_call) -> tuple[ImpulseRadius, bool, bool]: + """Classify a single pending call. Both hooks hit the network, so run them + against each other rather than one after the other.""" + tool = tools_by_name.get(tool_call["name"]) + if tool is None: + # The model hallucinated a tool; the tool node will error out on it, + # but until then treat it as the widest reach. + return DEFAULT_IMPULSE_RADIUS, False, False + call_args = tool_call.get("args") or {} + call_radius, call_destroys = await asyncio.gather( + classify_tool_call(tool, call_args), + classify_destructive(tool, call_args), + ) + return call_radius, call_destroys, is_always_confirmed(tool) + async def classify_pending_calls(state: AgentState) -> tuple[ImpulseRadius, bool, bool]: """The widest radius the pending tool calls reach, whether any destroys - something, and whether any is confirmed regardless of its radius.""" + something, and whether any is confirmed regardless of its radius. + + The calls are classified against each other -- a batch of them would + otherwise pay for every lookup in series before any tool runs -- and folded + together afterwards, so what gets logged stays in the model's order. + """ + tool_calls = state["messages"][-1].tool_calls + classified = await asyncio.gather(*(classify_one(tc) for tc in tool_calls)) + radius = ImpulseRadius.SELF destroys = False always = False - for tool_call in state["messages"][-1].tool_calls: - tool = tools_by_name.get(tool_call["name"]) - if tool is None: - # The model hallucinated a tool; the tool node will error out on it, - # but until then treat it as the widest reach. - call_radius, call_destroys, call_always = DEFAULT_IMPULSE_RADIUS, False, False - else: - call_args = tool_call.get("args") or {} - call_radius = await classify_tool_call(tool, call_args) - call_destroys = await classify_destructive(tool, call_args) - call_always = is_always_confirmed(tool) + for tool_call, (call_radius, call_destroys, call_always) in zip(tool_calls, classified): print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}" f"{', destroys something' if call_destroys else ''}" f"{', always confirmed' if call_always else ''}") From 3d0810ea77d497ed22f662bee66fba72ec58f129 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:28:19 +0200 Subject: [PATCH 10/17] fix(cricles): Make update_circle aware of impulse radius Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/circles.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index bce6c154..b0d6e675 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -38,6 +38,28 @@ def new_member_radius(member_type=TYPE_USER): return ImpulseRadius.EXTERNAL return ImpulseRadius.INDIVIDUALS + async def circle_radius(circle_id): + """Who a team already reaches, read off the members it already has. + + Changing a team's name or description changes what every member of it sees, + so the audience of the change is the membership -- which is nobody at all + while the user is still the only one in it. + """ + _validate_circle_id(circle_id) + members = await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members') + user_id = await nc.user + radius = ImpulseRadius.SELF + for member in members or []: + member_type = member.get('userType') + if member_type in (TYPE_GROUP, TYPE_CIRCLE, TYPE_MAIL): + # A group, a nested team or a mail address: reuse the reach that adding + # such a member would have had. + radius = max(radius, new_member_radius(member_type)) + elif member.get('userId') != user_id: + # Anyone else in the team makes this a team rather than a private note. + radius = max(radius, ImpulseRadius.GROUP) + return radius + @tool @impulse(ImpulseRadius.SELF) async def list_circles(): @@ -123,7 +145,7 @@ async def remove_member_from_circle(circle_id: str, member_id: str): return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/{member_id}')) @tool - @impulse(ImpulseRadius.SELF) + @impulse(circle_radius) async def update_circle(circle_id: str, name: Optional[str] = None, description: Optional[str] = None): """ Update circle (team) information From 1464bd39f31dfd3c2bba9f181dd8af09c0383b1e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:46:37 +0200 Subject: [PATCH 11/17] fix(deck, talk): Fix some radii Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/deck.py | 2 +- ex_app/lib/all_tools/talk.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 99fa671e..cf00d305 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -281,7 +281,7 @@ async def update_card_comment(card_id: int, comment_id: int, message: str): })) @tool - @impulse(ImpulseRadius.SELF) + @impulse(card_radius) @destructive async def delete_card_comment(card_id: int, comment_id: int): """ diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index a6ab7027..973db136 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -116,7 +116,7 @@ async def add_reaction(conversation_name: str, message_id: int, reaction: str): })) @tool - @impulse(ImpulseRadius.SELF) + @impulse(conversation_radius) @destructive async def remove_reaction(conversation_name: str, message_id: int, reaction: str): """ From f5b53e974b12c359dfe0d9ab0ed80e66e11471d6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:49:27 +0200 Subject: [PATCH 12/17] fix(file_path_radius): Make it aware of sub folder shares Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/lib/audience.py | 30 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ex_app/lib/all_tools/lib/audience.py b/ex_app/lib/all_tools/lib/audience.py index 49d5d4a3..3b7731bb 100644 --- a/ex_app/lib/all_tools/lib/audience.py +++ b/ex_app/lib/all_tools/lib/audience.py @@ -122,17 +122,28 @@ async def mount_type_radius(nc, path) -> ImpulseRadius: async def file_path_radius(nc, *paths) -> ImpulseRadius: - """Who can reach these files or folders, through a share on them or on a parent. + """Who can reach these files or folders, through a share anywhere around them. - Covers three ways in: folders the user shared out, folders that were shared + Covers four ways in: folders the user shared out, folders that were shared with the user, where the owner and the other recipients see whatever is - written into them, and Team folders, which a whole group has mounted without - any share existing to find. + written into them, Team folders, which a whole group has mounted without any + share existing to find, and shares sitting *inside* a folder being acted on -- + a folder that gets deleted or moved takes everything shared out of it along + with it, and those recipients lose it without a share on the folder itself + ever mentioning them. """ covered = set() + targets = set() for path in paths: - if path: - covered |= path_and_parents(path) + if not path: + continue + parents = path_and_parents(path) + if not parents: + # The user's root, which names no folder to ask about. Left out so the + # question stays unanswered rather than being answered for nothing. + continue + covered |= parents + targets.add(normalize_path(path)) if not covered: raise ValueError('No path to determine the audience of') @@ -146,8 +157,11 @@ async def file_path_radius(nc, *paths) -> ImpulseRadius: # 'path' is relative to the tree of whoever is asking, for shares the user # handed out as well as for those they received; 'file_target' is the # recipient's mount point and only matches for the latter. - share_path = share.get('path') or share.get('file_target') - if normalize_path(share_path) in covered: + share_path = normalize_path(share.get('path') or share.get('file_target')) + # On the path itself or on a folder above it: the share reaches it. Below + # it: the share goes down with it. Every target is a real folder, never + # '/', so no prefix here can swallow the whole tree. + if share_path in covered or any(share_path.startswith(f'{target}/') for target in targets): radius = max(radius, share_type_radius(share.get('share_type'))) return radius From 91915450187605eb0c281f3cdaa7ce40c47dcd11 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 10:55:24 +0200 Subject: [PATCH 13/17] fix: delete_share and remove_member_from_circle should not be SELF Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/circles.py | 17 ++++++++++++++++- ex_app/lib/all_tools/lib/impulse.py | 15 ++++++++++----- ex_app/lib/all_tools/shares.py | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index b0d6e675..65e764a2 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -38,6 +38,21 @@ def new_member_radius(member_type=TYPE_USER): return ImpulseRadius.EXTERNAL return ImpulseRadius.INDIVIDUALS + async def removed_member_radius(circle_id, member_id): + """What a member loses when they are taken out of a team. + + Nobody gains anything, but the access being withdrawn is exactly as wide as + adding that member was: a single account loses a team, a group or a nested + team loses it for everybody in it. + """ + _validate_circle_id(circle_id) + _validate_member_id(member_id) + members = await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members') + for member in members or []: + if member_id in (member.get('id'), member.get('singleId')): + return new_member_radius(member.get('userType')) + raise ValueError(f'No member {member_id!r} in team {circle_id!r}') + async def circle_radius(circle_id): """Who a team already reaches, read off the members it already has. @@ -131,7 +146,7 @@ async def add_member_to_circle(circle_id: str, member_id: str, member_type: int return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/multi', json=payload)) @tool - @impulse(ImpulseRadius.SELF) + @impulse(removed_member_radius) @destructive async def remove_member_from_circle(circle_id: str, member_id: str): """ diff --git a/ex_app/lib/all_tools/lib/impulse.py b/ex_app/lib/all_tools/lib/impulse.py index 0c3e2752..3e92d55b 100644 --- a/ex_app/lib/all_tools/lib/impulse.py +++ b/ex_app/lib/all_tools/lib/impulse.py @@ -18,11 +18,16 @@ and answer ``INDIVIDUALS`` for a one-to-one chat but ``EXTERNAL`` for a public room. -For actions that *withdraw* rather than grant access (deleting a share, removing -a team member) nobody gains anything, so they are ``SELF``. For actions that -modify an item which already has an audience (editing a team wiki page, posting -in a conversation) the radius is that existing audience -- the audience is who -the action reaches. +For actions that modify an item which already has an audience (editing a team +wiki page, posting in a conversation) the radius is that existing audience -- the +audience is who the action reaches. + +Actions that *withdraw* access (deleting a share, removing a team member) disclose +nothing, but they are :func:`destructive`, and the radius of a destructive call is +read as how far the thing being lost reached. So a withdrawal reports the reach of +the access it takes away: revoking a group share is ``GROUP``, because a group is +what loses something. ``SELF`` is for a call that takes away nothing anybody else +had -- deleting a private note, not evicting a team from a folder. Radius answers who a call reaches, which says nothing about whether it takes something away. That is the second dimension: a tool marked :func:`destructive` diff --git a/ex_app/lib/all_tools/shares.py b/ex_app/lib/all_tools/shares.py index 5b1e2fcb..ea3e1464 100644 --- a/ex_app/lib/all_tools/shares.py +++ b/ex_app/lib/all_tools/shares.py @@ -79,7 +79,7 @@ async def update_share_permissions(share_id: int, permissions: int): }) @tool - @impulse(ImpulseRadius.SELF) + @impulse(existing_share_radius) @destructive async def delete_share(share_id: int): """ From ff063372ab7d57c67acfc6e2b2f5de60aa0b2ab6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 11:00:09 +0200 Subject: [PATCH 14/17] fix: Do not ask for confirmation on hallucinated tool names Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/graph.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/ex_app/lib/graph.py b/ex_app/lib/graph.py index b90c76f1..3e437bfa 100644 --- a/ex_app/lib/graph.py +++ b/ex_app/lib/graph.py @@ -13,7 +13,6 @@ from ex_app.lib.all_tools.lib.impulse import ( DEFAULT_DESTRUCTIVE_THRESHOLD, - DEFAULT_IMPULSE_RADIUS, DEFAULT_IMPULSE_THRESHOLD, ImpulseRadius, classify_destructive, @@ -80,14 +79,20 @@ async def get_graph( # This means that this node is the first one called workflow.set_entry_point("agent") - async def classify_one(tool_call) -> tuple[ImpulseRadius, bool, bool]: - """Classify a single pending call. Both hooks hit the network, so run them - against each other rather than one after the other.""" + async def classify_one(tool_call) -> tuple[ImpulseRadius, bool, bool] | None: + """Classify a single pending call, or None if there is nothing to classify. + + Both hooks hit the network, so run them against each other rather than one + after the other. + """ tool = tools_by_name.get(tool_call["name"]) if tool is None: - # The model hallucinated a tool; the tool node will error out on it, - # but until then treat it as the widest reach. - return DEFAULT_IMPULSE_RADIUS, False, False + # The model named a tool that does not exist. Neither node can run it -- + # both hold the same list -- so the call reaches nobody, and the node's + # error handler hands the model its mistake to correct on the next turn. + # Asking the user to confirm it would be asking about something that + # cannot happen, so it is left out of the reckoning entirely. + return None call_args = tool_call.get("args") or {} call_radius, call_destroys = await asyncio.gather( classify_tool_call(tool, call_args), @@ -109,7 +114,11 @@ async def classify_pending_calls(state: AgentState) -> tuple[ImpulseRadius, bool radius = ImpulseRadius.SELF destroys = False always = False - for tool_call, (call_radius, call_destroys, call_always) in zip(tool_calls, classified): + for tool_call, classification in zip(tool_calls, classified): + if classification is None: + print(f"Tool call: {tool_call['name']} -> no such tool, leaving it to the tool node to report") + continue + call_radius, call_destroys, call_always = classification print(f"Tool call: {tool_call['name']} -> impulse radius {call_radius.name}" f"{', destroys something' if call_destroys else ''}" f"{', always confirmed' if call_always else ''}") From 05b7aec10cd9d706f7da464cc9b59f71fba97f63 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 11:08:06 +0200 Subject: [PATCH 15/17] fix(deck): Do not let cache grow unbounded Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/deck.py | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index cf00d305..859f616b 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -10,13 +10,27 @@ # Resolving a card to its board costs one request per board the user can see, so # the map is kept per user and out here, rather than in the get_tools closure that -# timed_memoize drops every minute along with the tools themselves. +# timed_memoize drops every minute along with the tools themselves. Only the radius +# each board works out to is kept, never the board itself: the boards are large, +# and the radius is all anybody asks for. CARD_BOARD_TTL = 60 # A miss is worth one crawl -- a card the agent just created is not in the map # yet. Right after a crawl it is not: an id that is simply not there would # otherwise walk every board again on every call that mentions it. CARD_BOARD_MISS_INTERVAL = 10 -_card_boards: dict[str, dict] = {} +_card_radii: dict[str, dict] = {} + + +def _forget_stale_card_radii(now: float) -> None: + """Drop the maps that the next call would refetch anyway. + + Nothing here is ever handed back to a user who stopped asking, so without this + the process keeps a map per user that has ever used a Deck tool for as long as + it runs. Sweeping on the way in costs a pass over a dict that is as long as the + list of users active in the last minute. + """ + for user_id in [u for u, cache in _card_radii.items() if now - cache['fetched_at'] > CARD_BOARD_TTL]: + del _card_radii[user_id] async def get_tools(nc: AsyncNextcloudApp): @@ -48,33 +62,40 @@ async def assignment_radius(board_id): """Assigning reaches the assignee on top of whoever the board already reaches.""" return max(ImpulseRadius.INDIVIDUALS, await board_radius(board_id)) - async def refresh_card_boards(cache): - """Map every card the user can reach to the board it lives on. + async def refresh_card_radii(cache): + """Map every card the user can reach to the radius of the board it lives on. One request for the board list, then one per board -- the stacks of a board - carry its cards, so the cards themselves cost nothing extra. + carry its cards, so the cards themselves cost nothing extra. Each board is + reduced to its radius as it goes past, so what stays behind is one small + integer per card rather than the board it came from. """ - boards = {} + radii = {} for board in await deck_get('/boards'): + radius = await board_acl_radius(board) for stack in await deck_get(f"/boards/{board['id']}/stacks"): for card in stack.get('cards') or []: - boards[card['id']] = board - cache['boards'] = boards + radii[card['id']] = radius + cache['cards'] = radii cache['fetched_at'] = time.monotonic() - return boards + return radii async def card_radius(card_id): """A comment on a card reaches whoever the card's board reaches.""" card_id = int(card_id) - cache = _card_boards.setdefault(await nc.user, {'boards': {}, 'fetched_at': 0.0}) - age = time.monotonic() - cache['fetched_at'] + now = time.monotonic() + _forget_stale_card_radii(now) + # A map that has just been swept, or was never there, reads as infinitely old + # and so gets fetched rather than being trusted while it is still empty. + cache = _card_radii.setdefault(await nc.user, {'cards': {}, 'fetched_at': float('-inf')}) + age = now - cache['fetched_at'] # At most one crawl per call, whether the map went stale or the card is new. - if age > CARD_BOARD_TTL or (card_id not in cache['boards'] and age > CARD_BOARD_MISS_INTERVAL): - await refresh_card_boards(cache) - board = cache['boards'].get(card_id) - if board is None: + if age > CARD_BOARD_TTL or (card_id not in cache['cards'] and age > CARD_BOARD_MISS_INTERVAL): + await refresh_card_radii(cache) + radius = cache['cards'].get(card_id) + if radius is None: raise ValueError(f'No board holds a card with id {card_id!r}') - return await board_acl_radius(board) + return radius @tool @impulse(ImpulseRadius.SELF) From 06fb12b98c4d10540a2720cd66d9bda3ec5c0753 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 11:20:36 +0200 Subject: [PATCH 16/17] fix(circles): delete_circle should have circle radius not SELF Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/circles.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index 65e764a2..a89cb80e 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -58,7 +58,10 @@ async def circle_radius(circle_id): Changing a team's name or description changes what every member of it sees, so the audience of the change is the membership -- which is nobody at all - while the user is still the only one in it. + while the user is still the only one in it. Deleting the team is the same + membership on the other side of the ledger: everyone in it loses it at once, + which is why the reach of a deletion is read off the members too rather than + being called SELF because nobody gains anything. """ _validate_circle_id(circle_id) members = await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members') @@ -177,7 +180,7 @@ async def update_circle(circle_id: str, name: Optional[str] = None, description: return @tool - @impulse(ImpulseRadius.SELF) + @impulse(circle_radius) @destructive async def delete_circle(circle_id: str): """ From d41ea26e957c2fd6b5c3d27810a20ee582c27216 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 15 Sep 2026 11:24:05 +0200 Subject: [PATCH 17/17] fix(deck): Add in-flight guard to refresh_card_radii Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/deck.py | 57 ++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 859f616b..dfd66a3c 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import json import time from typing import Optional @@ -28,8 +29,18 @@ def _forget_stale_card_radii(now: float) -> None: the process keeps a map per user that has ever used a Deck tool for as long as it runs. Sweeping on the way in costs a pass over a dict that is as long as the list of users active in the last minute. + + A map somebody is still crawling is left alone: dropping it would leave the + crawl filling in a dict nobody reads any more, and send everyone waiting on it + off to walk every board over again. A map that has never been filled in reads + as infinitely old, so without this the first crawl of all would be swept out + from under itself by the next call to arrive. """ - for user_id in [u for u, cache in _card_radii.items() if now - cache['fetched_at'] > CARD_BOARD_TTL]: + stale = [ + user_id for user_id, cache in _card_radii.items() + if now - cache['fetched_at'] > CARD_BOARD_TTL and cache['crawl'] is None + ] + for user_id in stale: del _card_radii[user_id] @@ -62,7 +73,7 @@ async def assignment_radius(board_id): """Assigning reaches the assignee on top of whoever the board already reaches.""" return max(ImpulseRadius.INDIVIDUALS, await board_radius(board_id)) - async def refresh_card_radii(cache): + async def crawl_card_radii(cache): """Map every card the user can reach to the radius of the board it lives on. One request for the board list, then one per board -- the stacks of a board @@ -70,15 +81,37 @@ async def refresh_card_radii(cache): reduced to its radius as it goes past, so what stays behind is one small integer per card rather than the board it came from. """ - radii = {} - for board in await deck_get('/boards'): - radius = await board_acl_radius(board) - for stack in await deck_get(f"/boards/{board['id']}/stacks"): - for card in stack.get('cards') or []: - radii[card['id']] = radius - cache['cards'] = radii - cache['fetched_at'] = time.monotonic() - return radii + try: + radii = {} + for board in await deck_get('/boards'): + radius = await board_acl_radius(board) + for stack in await deck_get(f"/boards/{board['id']}/stacks"): + for card in stack.get('cards') or []: + radii[card['id']] = radius + cache['cards'] = radii + cache['fetched_at'] = time.monotonic() + finally: + # Cleared even when the crawl failed, so the next call tries again rather + # than waiting on a task that is never coming back. + cache['crawl'] = None + + async def refresh_card_radii(cache): + """Fill the map in, or wait for the crawl that is already doing it. + + The pending tool calls of one turn are classified against each other, so a + batch naming several cards the map has not seen yet arrives here all at once. + Each of those would otherwise walk every board of its own, for the same + answer. The first one to get here starts the crawl and the rest wait on it, + shielded so that a caller giving up does not cancel the crawl the others are + still waiting for. + """ + crawl = cache['crawl'] + if crawl is None: + crawl = asyncio.ensure_future(crawl_card_radii(cache)) + # Set before the task gets to run: ensure_future only schedules it, and + # there is no await between here and there for it to start in. + cache['crawl'] = crawl + await asyncio.shield(crawl) async def card_radius(card_id): """A comment on a card reaches whoever the card's board reaches.""" @@ -87,7 +120,7 @@ async def card_radius(card_id): _forget_stale_card_radii(now) # A map that has just been swept, or was never there, reads as infinitely old # and so gets fetched rather than being trusted while it is still empty. - cache = _card_radii.setdefault(await nc.user, {'cards': {}, 'fetched_at': float('-inf')}) + cache = _card_radii.setdefault(await nc.user, {'cards': {}, 'fetched_at': float('-inf'), 'crawl': None}) age = now - cache['fetched_at'] # At most one crawl per call, whether the map went stale or the card is new. if age > CARD_BOARD_TTL or (card_id not in cache['cards'] and age > CARD_BOARD_MISS_INTERVAL):