From ea180ebcb687d9a0db3a0c27a15e13cb87986dce Mon Sep 17 00:00:00 2001 From: Viacheslav Huliaiev Date: Wed, 21 Oct 2026 22:12:15 +0200 Subject: [PATCH 1/2] fix: support additional user instructions in the system prompt Declare a new optional `user_instructions` input on the agent providers (classic and multimodal). When set, e.g. by the Assistant app with its "Chat User Instructions" admin setting, the text is appended to the agent's own system prompt. It does not replace it, so the tool calling instructions stay intact. Refs nextcloud/assistant#338 Signed-off-by: Viacheslav Huliaiev --- ex_app/lib/agent.py | 15 +++++++++++ ex_app/lib/main.py | 1 + ex_app/lib/provider.py | 7 ++++- tests/test_user_instructions.py | 46 +++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/test_user_instructions.py diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 28647427..8d1e9e3a 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -110,6 +110,20 @@ def export_conversation(checkpointer): conversation_token = add_signature(serialized_state.decode('utf-8'), key) return conversation_token +def format_user_instructions(user_instructions: str | None) -> str: + """ + Format the additional instructions configured by the admin (e.g. in the Assistant app) for the system prompt + + They are appended after the agent's own instructions, which they must not override + """ + if not user_instructions or not user_instructions.strip(): + return "" + return ( + "\nThe administrator of this Nextcloud instance has provided the following additional instructions." + " Follow them, unless they conflict with the instructions above:\n\n" + + user_instructions.strip() + "\n" + ) + async def react( task, nc: AsyncNextcloudApp, @@ -213,6 +227,7 @@ async def call_model( # this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible system_prompt = SystemMessage( system_prompt_text.replace("{CURRENT_DATE}", current_date).replace("{DATE_SUFFIX}", date_suffix) + + format_user_instructions(task['input'].get('user_instructions')) ) response = await bound_model.ainvoke([system_prompt] + state["messages"], config) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 9959125f..2002eab6 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -275,6 +275,7 @@ async def background_thread_task(): 'confirmation': task['input']['confirmation'], 'conversation_token': '', 'memories': task['input'].get('memories', None), + 'user_instructions': task['input'].get('user_instructions', None), 'input_attachments': task['input'].get('input_attachments', None), })) except Exception as e: # noqa diff --git a/ex_app/lib/provider.py b/ex_app/lib/provider.py index f6e1ac27..a19b3ec5 100644 --- a/ex_app/lib/provider.py +++ b/ex_app/lib/provider.py @@ -16,7 +16,12 @@ name="memories", description="Injected memories", shape_type=ShapeType.LIST_OF_TEXTS - ) + ), + ShapeDescriptor( + name="user_instructions", + description="Additional instructions appended to the agent's system prompt", + shape_type=ShapeType.TEXT + ), ] provider = TaskProcessingProvider( diff --git a/tests/test_user_instructions.py b/tests/test_user_instructions.py new file mode 100644 index 00000000..9080d33e --- /dev/null +++ b/tests/test_user_instructions.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Formatting of the admin-provided user instructions appended to the system prompt. + +Run standalone: + + PYTHONPATH=. python tests/test_user_instructions.py + +or via pytest when available. +""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# agent.py stores its signing key in the persistent storage on import +os.environ.setdefault("APP_PERSISTENT_STORAGE", tempfile.mkdtemp()) + +from ex_app.lib.agent import format_user_instructions # noqa: E402 +from ex_app.lib.provider import multimodal_provider, provider # noqa: E402 + + +def test_missing_or_blank_instructions_add_nothing(): + assert format_user_instructions(None) == "" + assert format_user_instructions("") == "" + assert format_user_instructions(" \n\t ") == "" + + +def test_instructions_are_appended_with_precedence_note(): + text = format_user_instructions(" Always answer like a pirate.\n") + assert text.startswith("\n") + assert text.endswith("Always answer like a pirate.\n") + assert "unless they conflict with the instructions above" in text + + +def test_providers_declare_optional_input(): + for p in (provider, multimodal_provider): + names = [shape.name for shape in p.optional_input_shape] + assert "user_instructions" in names + + +if __name__ == "__main__": + for name, fn in list(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f"ok {name}") From 216d7fd4265c815ecad35f38fedcf12a85851f5e Mon Sep 17 00:00:00 2001 From: Viacheslav Huliaiev Date: Thu, 22 Oct 2026 13:13:44 +0200 Subject: [PATCH 2/2] fix: ask for plain text when the reply is read out loud Add the optional "output_mode" input. With "speech", the agent's own system prompt asks for a reply without Markdown or other formatting, since it is sent to text-to-speech. This takes precedence over the admin-provided instructions. Signed-off-by: Viacheslav Huliaiev --- ex_app/lib/agent.py | 19 +++++++++++++++++++ ex_app/lib/main.py | 1 + ex_app/lib/provider.py | 5 +++++ tests/test_user_instructions.py | 12 +++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 8d1e9e3a..741b42ac 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -124,6 +124,23 @@ def format_user_instructions(user_instructions: str | None) -> str: + user_instructions.strip() + "\n" ) +# Value of the optional "output_mode" input when the agent's reply is converted to speech +OUTPUT_MODE_SPEECH = "speech" + +def format_output_mode(output_mode: str | None) -> str: + """ + Formatting rules for the agent's reply, depending on how it is presented to the user + + They are part of the agent's own instructions, so they take precedence over the admin-provided ones + """ + if output_mode == OUTPUT_MODE_SPEECH: + return ( + "Your reply will be converted to speech and read out loud to the user." + " Answer in plain spoken text only: do not use Markdown or any other formatting" + " (no headings, bullet or numbered lists, tables, bold or italic text, code blocks or emojis).\n" + ) + return "" + async def react( task, nc: AsyncNextcloudApp, @@ -224,6 +241,8 @@ async def call_model( " or when they describe a clearly reusable procedure that should be remembered.\n" ) + system_prompt_text += format_output_mode(task['input'].get('output_mode')) + # this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible system_prompt = SystemMessage( system_prompt_text.replace("{CURRENT_DATE}", current_date).replace("{DATE_SUFFIX}", date_suffix) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 2002eab6..d64784c0 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -276,6 +276,7 @@ async def background_thread_task(): 'conversation_token': '', 'memories': task['input'].get('memories', None), 'user_instructions': task['input'].get('user_instructions', None), + 'output_mode': task['input'].get('output_mode', None), 'input_attachments': task['input'].get('input_attachments', None), })) except Exception as e: # noqa diff --git a/ex_app/lib/provider.py b/ex_app/lib/provider.py index a19b3ec5..dbd45aff 100644 --- a/ex_app/lib/provider.py +++ b/ex_app/lib/provider.py @@ -22,6 +22,11 @@ description="Additional instructions appended to the agent's system prompt", shape_type=ShapeType.TEXT ), + ShapeDescriptor( + name="output_mode", + description="How the reply is presented to the user: \"speech\" if it is read out loud (no formatting)", + shape_type=ShapeType.TEXT + ), ] provider = TaskProcessingProvider( diff --git a/tests/test_user_instructions.py b/tests/test_user_instructions.py index 9080d33e..64a7cd66 100644 --- a/tests/test_user_instructions.py +++ b/tests/test_user_instructions.py @@ -16,7 +16,7 @@ # agent.py stores its signing key in the persistent storage on import os.environ.setdefault("APP_PERSISTENT_STORAGE", tempfile.mkdtemp()) -from ex_app.lib.agent import format_user_instructions # noqa: E402 +from ex_app.lib.agent import OUTPUT_MODE_SPEECH, format_output_mode, format_user_instructions # noqa: E402 from ex_app.lib.provider import multimodal_provider, provider # noqa: E402 @@ -37,6 +37,16 @@ def test_providers_declare_optional_input(): for p in (provider, multimodal_provider): names = [shape.name for shape in p.optional_input_shape] assert "user_instructions" in names + assert "output_mode" in names + + +def test_speech_output_mode_forbids_formatting(): + assert format_output_mode(None) == "" + assert format_output_mode("text") == "" + text = format_output_mode(OUTPUT_MODE_SPEECH) + assert "read out loud" in text + assert "Markdown" in text + assert text.endswith("\n") if __name__ == "__main__":