Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions ex_app/lib/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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_destructive_threshold, get_impulse_threshold, get_tools

# Dummy thread id as we return the whole state
thread = {"configurable": {"thread_id": "thread-1"}}
Expand Down Expand Up @@ -112,9 +112,9 @@ 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)
destructive_threshold = await get_destructive_threshold(nc)

bound_model = model.bind_tools(
tools,
Expand Down Expand Up @@ -203,12 +203,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, destructive_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": [
Expand Down Expand Up @@ -289,7 +289,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 = {
Expand Down
13 changes: 8 additions & 5 deletions ex_app/lib/all_tools/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
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, always_confirm, destructive, impulse


async def get_tools(nc: AsyncNextcloudApp):

@tool
@dangerous_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.
Expand All @@ -39,7 +40,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.
Expand All @@ -49,7 +50,8 @@ async def list_scheduled_tasks():
return await nc.ocs('GET', f'/ocs/v2.php/apps/assistant/assignments')

@tool
@dangerous_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
Expand All @@ -69,7 +71,8 @@ async def update_scheduled_task(id: int, prompt: None|str = None, recurrence_rul
})

@tool
@dangerous_tool
@impulse(ImpulseRadius.SELF)
@destructive
async def delete_scheduled_task(id: int):
"""
Delete a recurring Assistant Scheduled Task
Expand Down
4 changes: 2 additions & 2 deletions ex_app/lib/all_tools/audio2text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 43 additions & 9 deletions ex_app/lib/all_tools/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, destructive, 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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -112,7 +145,8 @@ async def update_bookmark(bookmark_id: int, url: Optional[str] = None, title: Op
return json.dumps(response.json())

@tool
@dangerous_tool
@impulse(bookmark_radius)
@destructive
async def delete_bookmark(bookmark_id: int):
"""
Delete a bookmark
Expand All @@ -126,7 +160,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
Expand All @@ -139,7 +173,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
Expand All @@ -160,7 +194,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
Expand Down
62 changes: 53 additions & 9 deletions ex_app/lib/all_tools/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,64 @@
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, destructive, impulse
from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour


async def get_tools(nc: AsyncNextcloudApp):
ncSync = NextcloudApp()
ncSync.set_user(await nc.user)

CALENDAR_PROPFIND = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
'<d:prop><d:displayname/><d:owner/><oc:invite/></d:prop>'
'</d: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)
# <oc:invite> holds one <oc:user> per sharee, each with the principal it
# was shared to; an <oc:organizer> 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -428,7 +471,8 @@ def delete_task_sync(calendar_name: str, task_uid: str):
return False

@tool
@dangerous_tool
@impulse(calendar_radius)
@destructive
async def delete_task(calendar_name: str, task_uid: str):
"""
Delete a task
Expand Down
4 changes: 2 additions & 2 deletions ex_app/lib/all_tools/calendar_advanced_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading