Skip to content

Commit e34627f

Browse files
committed
Merge branch 'main' into feat/talk-reactions-polls-files
2 parents ecb5c38 + fa4f978 commit e34627f

11 files changed

Lines changed: 2514 additions & 1704 deletions

File tree

.github/workflows/integration_test.yml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,22 +150,31 @@ jobs:
150150
sudo apt-get update
151151
sudo apt install pipx
152152
pipx install poetry
153+
mkdir -p ../llm2-persistent_storage
153154
poetry install
154155
156+
- name: Cache llm2 models
157+
uses: actions/cache/restore@v5
158+
id: cache-llm2-models-restore
159+
env:
160+
cache-name: cache-llm2-models
161+
with:
162+
path: llm2-persistent_storage/
163+
key: ${{ runner.os }}-llm2-models-${{ env.cache-name }}-${{ hashFiles('llm2/lib/main.py') }}
164+
155165
- name: Init llm2
156166
working-directory: llm2/lib
157167
env:
158168
APP_ID: llm2
159169
APP_PORT: 9080
160170
APP_VERSION: ${{ fromJson(steps.llm2_appinfo.outputs.result).version }}
161171
run: |
162-
poetry run python3 main.py > ../logs 2>&1 &
172+
APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent-storage/" poetry run python3 main.py > ../logs 2>&1 &
163173
164174
- name: Register backend
165175
run: |
166176
./occ app_api:app:register llm2 manual_install --json-info "{\"appid\":\"llm2\",\"name\":\"Local large language model\",\"daemon_config_name\":\"manual_install\",\"version\":\"${{ fromJson(steps.llm2_appinfo.outputs.result).version }}\",\"secret\":\"12345\",\"port\":9080,\"scopes\":[\"AI_PROVIDERS\", \"TASK_PROCESSING\"],\"system_app\":0}" --force-scopes --wait-finish
167177
168-
169178
- name: Install context_agent app
170179
working-directory: ${{ env.APP_NAME }}
171180
run: |
@@ -244,6 +253,14 @@ jobs:
244253
[ "$STATUS" = "200" ]
245254
echo "$BODY"
246255
echo "$BODY" | grep -q 'list_calendars'
256+
257+
- name: Cache llm2 models
258+
uses: actions/cache/save@v5
259+
env:
260+
cache-name: cache-llm2-models
261+
with:
262+
path: llm2-persistent_storage/
263+
key: ${{ steps.cache-llm2-models-restore.outputs.cache-primary-key }}
247264

248265
- name: Run task
249266
env:
@@ -273,7 +290,7 @@ jobs:
273290
run: |
274291
tail data/nextcloud.log
275292
276-
- name: Show context_chat logs
293+
- name: Show context_agent logs
277294
if: always()
278295
run: |
279296
[ -f ${{ env.APP_NAME }}/logs ] && cat ${{ env.APP_NAME }}/logs || echo "No context_chat logs"

ex_app/lib/agent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ async def call_model(
127127
At the end of each message to the user, if you have carried out a task or answered a question, suggest up to three actions for things you can do for the user based on the tools you have available and details of the previous task. For example: If the user wants to know the weather for some location, they might be planning an event, you can suggest to create an event for them, or if they searched for a file, they may want to share it with others, suggest to create a share link for them, if they want a summary of something, you can suggest them to send the summary to somebody.
128128
"""
129129
if tool_enabled("duckduckgo_results_json"):
130-
system_prompt_text += "Only use the duckduckgo_results_json tool if the user explicitly asks for a web search.\n"
130+
system_prompt_text += "Use the duckduckgo_results_json tool if the user explicitly asks for a web search or you don't know about a topic or concept that the user is referencing.\n"
131131
if tool_enabled("list_talk_conversations"):
132132
system_prompt_text += "Use the list_talk_conversations tool to check which conversations exist.\n"
133133
if tool_enabled("list_calendars"):
@@ -137,11 +137,13 @@ async def call_model(
137137
if tool_enabled("find_person_in_users"):
138138
system_prompt_text += "Use the find_person_in_users tool to find a person's userId and user details.\n"
139139
if tool_enabled("find_details_of_current_user"):
140-
system_prompt_text += "Use the find_details_of_current_user tool to find the current user's location.\n"
140+
system_prompt_text += "Use the find_details_of_current_user tool to find the current user's location and timezone.\n"
141141
if tool_enabled("list_mails"):
142142
system_prompt_text += "Always check for the mail account id before requesting a folder list.\n"
143+
if tool_enabled("web_fetch"):
144+
system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n"
143145

144-
if task['input'].get('memories', None) is not None:
146+
if task['input'].get('memories', None) is not None and task['input'].get('memories', None) is not []:
145147
system_prompt_text += "You can remember things from other conversations with the user. If relevant, take into account the following memories:\n\n" + "\n".join(task['input']['memories']) + "\n\n"
146148
# this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible
147149
system_prompt = SystemMessage(
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
import datetime
4+
5+
import pytz
6+
from langchain_core.tools import tool
7+
from nc_py_api import AsyncNextcloudApp
8+
9+
from ex_app.lib.all_tools.lib.decorator import dangerous_tool, safe_tool
10+
11+
12+
async def get_tools(nc: AsyncNextcloudApp):
13+
14+
@tool
15+
@dangerous_tool
16+
async def create_scheduled_task(title: str, prompt: str, recurrence_rule: str, timezone: str|None = None, starts_at: None|str = None):
17+
"""
18+
Create a Scheduled Task for the assistant that will be carried out autonomously.
19+
The user will still have to approve sensitive actions.
20+
For example, the user could ask to transcribe new files in a certain folder every hour. Then the
21+
prompt argument for this tool would be "Transcribe new files in folder /Audio" and the recurrence_rule would be "FREQ=HOURLY".
22+
After having created the Scheduled Task, let the user know that the Scheduled Task will run in a newly created chat session.
23+
:param title: A title for the Scheduled Task, e.g. "Transcribe audio files" -- This is only for the user's reference and has no effect on the execution of the Scheduled Task.
24+
:param prompt: The instructions for the AI carrying out the Scheduled Task
25+
:param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day, an empty string as the recurrence_rule means the task does not repeat.
26+
:param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Make sure to use the user's timezone for this, obtainable with find_details_of_current_user
27+
:param timezone: Timezone (e.g., 'America/New_York') defaults to the user's current time zone
28+
:return:
29+
"""
30+
31+
await nc.ocs('POST', f'/ocs/v2.php/apps/assistant/assignments', json={
32+
'title': title,
33+
'prompt': prompt,
34+
'recurrence': recurrence_rule,
35+
'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else datetime.datetime.now(datetime.UTC).timestamp(),
36+
'timezone': pytz.timezone(timezone).zone if timezone is not None else None
37+
})
38+
39+
return True
40+
41+
@tool
42+
@safe_tool
43+
async def list_scheduled_tasks():
44+
"""
45+
List all assistant Scheduled Tasks by the current user.
46+
:return:
47+
"""
48+
49+
return await nc.ocs('GET', f'/ocs/v2.php/apps/assistant/assignments')
50+
51+
@tool
52+
@dangerous_tool
53+
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):
54+
"""
55+
Update a assistant Scheduled Task
56+
:param id: The ID of the Scheduled Task to update, you can obtain this from the list_scheduled_tasks tool
57+
:param prompt: The instructions for the AI carrying out the Scheduled Task. Pass `None` to leave this unchanged.
58+
:param recurrence_rule: An RRule compliant with RFC 5545 that defines the recurrence rule for the Scheduled Task. For example "FREQ=DAILY;INTERVAL=1" to run the Scheduled Task every day. An empty string means, it does not repeat. Pass `None` to leave this unchanged.
59+
:param timezone A timezone for the scheduled task, set to None to leave this as is.
60+
:param starts_at: A date time string in ISO 8601 format that defines when the Scheduled Task should start. For example "2025-01-01T09:00:00Z". If not provided, the Scheduled Task will start immediately. Pass `None` to leave this unchanged.
61+
:return:
62+
"""
63+
64+
return await nc.ocs('PATCH', f'/ocs/v2.php/apps/assistant/assignments/{id}', json={
65+
'prompt': prompt,
66+
'recurrence': recurrence_rule,
67+
'startsAt': int(datetime.datetime.fromisoformat(starts_at.replace('Z', '+00:00')).timestamp()) if starts_at is not None else None,
68+
'timezone': pytz.timezone(timezone).zone if timezone is not None else None
69+
})
70+
71+
@tool
72+
@dangerous_tool
73+
async def delete_scheduled_task(id: int):
74+
"""
75+
Delete a recurring Assistant Scheduled Task
76+
:param id: The ID of the Scheduled Task to delete, you can obtain this from the list_scheduled_tasks tool
77+
:return:
78+
"""
79+
return await nc.ocs('DELETE', f'/ocs/v2.php/apps/assistant/assignments/{id}')
80+
81+
return [
82+
create_scheduled_task,
83+
list_scheduled_tasks,
84+
update_scheduled_task,
85+
delete_scheduled_task,
86+
]
87+
88+
def get_category_name():
89+
return "Assistant Scheduled Tasks"
90+
91+
async def is_available(nc: AsyncNextcloudApp):
92+
return True

ex_app/lib/all_tools/calendar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration:
165165
headers={
166166
"Content-Type": "text/calendar; charset=utf-8",
167167
"Depth": "0",
168-
}, content=freebusyRequest)
168+
}, data=freebusyRequest)
169169
print(freebusyRequest)
170170
print(response.text)
171171

ex_app/lib/all_tools/contacts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]:
7070
response = await nc._session._create_adapter(True).request('REPORT', f"{nc.app_cfg.endpoint}{link}", headers={
7171
"Content-Type": "application/xml; charset=utf-8",
7272
"Depth": "1",
73-
}, content=xml_body)
73+
}, data=xml_body)
7474

7575
if response.status_code != 207: # Multi-Status
7676
raise Exception(f"Error: {response.status_code} - {response.reason_phrase}")
@@ -98,7 +98,7 @@ async def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]:
9898
@safe_tool
9999
async def find_details_of_current_user() -> dict[str, typing.Any]:
100100
"""
101-
Find the current user's personal information
101+
Find the current user's personal information, such as name, location, timezone, language
102102
:return: a dictionary with the person's personal information
103103
"""
104104

ex_app/lib/all_tools/files.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async def get_tools(nc: AsyncNextcloudApp):
1515
@safe_tool
1616
async def get_file_content(file_path: str):
1717
"""
18-
Get the content of a file
18+
Get the content of a nextcloud-internal file of the current user
1919
:param file_path: the path of the file
2020
:return:
2121
"""
@@ -33,7 +33,7 @@ async def get_file_content(file_path: str):
3333
async def get_file_content_by_file_link(file_url: str):
3434
"""
3535
Get the content of a file given an internal Nextcloud link (e.g., https://host/index.php/f/12345)
36-
:param file_url: the internal file URL
36+
:param file_url: the nextcloud-internal file URL
3737
:return: text content of the file
3838
"""
3939

ex_app/lib/all_tools/web.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
import niquests
4+
from langchain_core.tools import tool
5+
from nc_py_api import AsyncNextcloudApp
6+
7+
from ex_app.lib.all_tools.lib.decorator import safe_tool
8+
9+
10+
async def get_tools(nc: AsyncNextcloudApp):
11+
12+
@tool
13+
@safe_tool
14+
async def web_fetch(url: str) -> str:
15+
"""
16+
Get the contents of a web page via HTTP
17+
:param url: The HTTP URL to the web page (e.g. https://nextcloud.com/team/ )
18+
:return: the web page content
19+
"""
20+
res = await niquests.get(url)
21+
return res.text()
22+
23+
return [
24+
web_fetch,
25+
]
26+
27+
def get_category_name():
28+
return "Web access"
29+
30+
async def is_available(nc: AsyncNextcloudApp):
31+
return True

ex_app/lib/mcp_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from fastmcp.server.dependencies import get_context
99
from nc_py_api import AsyncNextcloudApp, NextcloudApp
1010
from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext
11+
from fastmcp.server.dependencies import get_http_headers
1112
from fastmcp.tools import Tool
1213
from mcp import types as mt
1314
from ex_app.lib.tools import get_tools
@@ -30,7 +31,7 @@ def get_user(authorization_header: str, nc: AsyncNextcloudApp) -> str:
3031
class UserAuthMiddleware(Middleware):
3132
async def on_message(self, context: MiddlewareContext, call_next):
3233
# Middleware stores user info in context state
33-
authorization_header = context.fastmcp_context.request_context.request.headers.get("Authorization")
34+
authorization_header = get_http_headers().get("authorization")
3435
if authorization_header is None:
3536
raise Exception("Authorization header is missing/invalid")
3637
nc = AsyncNextcloudApp()

ex_app/lib/nc_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class ChatWithNextcloud(BaseChatModel):
3737
tools: Sequence[
3838
Union[typing.Dict[str, Any], type, Callable, BaseTool]] = []
3939
TIMEOUT: int = 60 * 30 # 30 minutes
40-
MAX_MESSAGE_HISTORY: int = 13
40+
MAX_MESSAGE_HISTORY: int = 42
4141

4242
def _generate(self, messages: list[BaseMessage], stop: Optional[list[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any):
4343
raise Exception("Use _agenerate instead")

0 commit comments

Comments
 (0)