Skip to content

Commit 3964ab4

Browse files
authored
fix: attachment notifications wording #131 (#130)
1 parent 6f89515 commit 3964ab4

File tree

5 files changed

+179
-23
lines changed

5 files changed

+179
-23
lines changed

docs/agent.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,9 @@ The processor builds an aggregated result containing all accumulated data for th
241241

242242
The system uses two separate mechanisms to inform the agent about available files:
243243

244-
- **User attachments**: The `_AttachmentFilter` (used in `AssistantInvoker`) appends text metadata to user message
245-
content (e.g., `Attachment X, of type Y, url Z`). This is simple, direct, and preserves the natural conversation
246-
flow.
244+
- **Attachments**: The `_AttachmentFilter` (used in `AssistantInvoker`) appends structured XML metadata
245+
(`<attachments>`) to message content. Each attachment is represented as an `<attachment>` element with
246+
`<title>`, `<type>`, `<url>`, and optionally `<reference_url>` sub-elements.
247247
- **Admin context files**: The Attachment Notification Injector uses synthetic tool call/result messages via the
248248
`available_context` internal tool. This provides structured metadata without modifying user messages.
249249

src/quickapp/agent/_attachment_filter.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import copy
22
import logging
3+
from xml.sax.saxutils import escape
34

4-
from aidial_sdk.chat_completion import Message, Role
5+
from aidial_sdk.chat_completion import Attachment, Message, Role
56

67
from quickapp.common.utils import matches_type
78

@@ -24,23 +25,38 @@ def filter_attachments(self, messages: list[Message]) -> list[Message]:
2425
for item in messages
2526
]
2627

27-
def _filter(self, message: Message):
28+
@staticmethod
29+
def _build_attachment_xml(attachments: list[Attachment]) -> str:
30+
xml_parts = ["<attachments>"]
31+
for attachment in attachments:
32+
xml_parts.append(" <attachment>")
33+
xml_parts.append(f" <title>{escape(str(attachment.title or ''))}</title>")
34+
xml_parts.append(f" <type>{escape(str(attachment.type or ''))}</type>")
35+
xml_parts.append(f" <url>{escape(str(attachment.url or ''))}</url>")
36+
if attachment.reference_url is not None:
37+
xml_parts.append(
38+
f" <reference_url>{escape(str(attachment.reference_url))}</reference_url>"
39+
)
40+
xml_parts.append(" </attachment>")
41+
xml_parts.append("</attachments>")
42+
return "\n".join(xml_parts)
43+
44+
def _filter(self, message: Message) -> Message:
2845
updated_attachments = []
2946
if message.content is None:
3047
message.content = ""
3148
content = message.content if isinstance(message.content, str) else str(message.content)
3249
if self._has_attachments(message):
50+
all_attachments: list[Attachment] = []
3351
for attachment in message.custom_content.attachments: # type: ignore[union-attr]
3452
if message.role == Role.USER and matches_type(
3553
attachment.type, self.SUPPORTED_ATTACHMENTS
3654
):
3755
updated_attachments.append(attachment)
38-
# Inform agent that message had contained some attachment.
39-
# As adapter would resolve the actual bytes and URL would be lost.
40-
content += (
41-
f"\r\nAttachment {attachment.title}, of type {attachment.type}, "
42-
f"url {attachment.url}, reference_url {attachment.reference_url}\r\n"
43-
)
56+
all_attachments.append(attachment)
57+
# Inform agent that message had contained some attachment.
58+
# As adapter would resolve the actual bytes and URL would be lost.
59+
content += "\n" + self._build_attachment_xml(all_attachments)
4460
message.custom_content.attachments = updated_attachments # type: ignore[union-attr]
4561
message.content = content
4662

src/quickapp/attachment_processing/_context_entries.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class ContextEntry(BaseModel):
2525

2626
class AvailableContextToolResponse(BaseModel):
2727
entries: list[ContextEntry] = Field()
28+
disclaimer: str = Field(
29+
default=(
30+
"This information is related only to the files configured by admin. It does not contain any information "
31+
"on attachments from user or from tool results."
32+
)
33+
)
2834

2935

3036
def build_context_entries(

src/quickapp/attachment_processing/_tool_configs.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
function=OpenAiToolFunction(
1616
name=f"{INTERNAL_TOOL_NAME_PREFIX}available_context",
1717
description=(
18-
"Returns metadata about admin-configured context files"
19-
" attached to this application."
18+
"Returns metadata about admin-configured context files."
19+
" **IMPORTANT**: this tool is not applicable to user-attached files or files from tool results, "
20+
"and will not return any information about them. If you see file in <attachments> section of user "
21+
"message, it means that the file was attached by the user, and is available for you to use."
2022
),
2123
parameters=OpenAiToolFunctionParameters(
2224
type=JsonTypeEnum.object,

src/tests/unit_tests/agent_tests/test_attachment_filter.py

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,30 @@
33
from quickapp.agent._attachment_filter import _AttachmentFilter
44

55

6-
def _user_msg(content: str = "", attachments: list[Attachment] | None = None) -> Message:
7-
msg = Message(role=Role.USER, content=content)
6+
def _msg(
7+
role: Role, content: str | None = "", attachments: list[Attachment] | None = None
8+
) -> Message:
9+
msg = Message(role=role, content=content)
810
if attachments:
911
msg.custom_content = CustomContent(attachments=attachments)
1012
return msg
1113

1214

13-
def _attachment(title: str, url: str, mime_type: str) -> Attachment:
15+
def _user_msg(content: str = "", attachments: list[Attachment] | None = None) -> Message:
16+
return _msg(Role.USER, content, attachments)
17+
18+
19+
def _attachment(
20+
title: str,
21+
url: str,
22+
mime_type: str,
23+
reference_url: str | None = None,
24+
) -> Attachment:
1425
return Attachment(
1526
title=title,
1627
url=url,
1728
type=mime_type,
29+
reference_url=reference_url,
1830
)
1931

2032

@@ -38,7 +50,7 @@ def test_non_image_attachments_removed(self):
3850
result = transformer.filter_attachments([msg])
3951
assert len(result[0].custom_content.attachments) == 0
4052

41-
def test_text_metadata_injected_for_attachments(self):
53+
def test_xml_metadata_injected_for_attachments(self):
4254
transformer = _AttachmentFilter()
4355
msg = _user_msg(
4456
"original content",
@@ -49,12 +61,13 @@ def test_text_metadata_injected_for_attachments(self):
4961
)
5062
result = transformer.filter_attachments([msg])
5163
content = str(result[0].content)
52-
assert "Attachment doc.pdf" in content
53-
assert "application/pdf" in content
64+
assert "<attachments>" in content
65+
assert "<title>doc.pdf</title>" in content
66+
assert "<type>application/pdf</type>" in content
67+
assert "<title>photo.png</title>" in content
68+
assert "<type>image/png</type>" in content
5469

55-
# Image attachments are kept inline AND get text metadata injected
56-
assert "Attachment photo.png" in content
57-
assert "image/png" in content
70+
# Image attachments are kept inline AND get XML metadata injected
5871
assert result[0].custom_content.attachments[0].type == "image/png"
5972
assert result[0].custom_content.attachments[0].title == "photo.png"
6073

@@ -105,4 +118,123 @@ def test_filter_idempotent_on_repeated_calls(self):
105118
second_content = str(second_pass[0].content)
106119

107120
assert first_content == second_content
108-
assert first_content.count("Attachment doc.pdf") == 1
121+
assert first_content.count("<title>doc.pdf</title>") == 1
122+
123+
# --- Multi-message tests ---
124+
125+
def test_multi_message_each_filtered_independently(self):
126+
transformer = _AttachmentFilter()
127+
msg1 = _user_msg(
128+
"first",
129+
[
130+
_attachment("doc.pdf", "/files/doc.pdf", "application/pdf"),
131+
_attachment("photo.png", "/files/photo.png", "image/png"),
132+
],
133+
)
134+
msg2 = _user_msg(
135+
"second",
136+
[_attachment("data.csv", "/files/data.csv", "text/csv")],
137+
)
138+
result = transformer.filter_attachments([msg1, msg2])
139+
140+
# First message: image kept, pdf removed
141+
assert len(result[0].custom_content.attachments) == 1
142+
assert result[0].custom_content.attachments[0].type == "image/png"
143+
content0 = str(result[0].content)
144+
assert "<title>doc.pdf</title>" in content0
145+
assert "<title>photo.png</title>" in content0
146+
147+
# Second message: csv removed
148+
assert len(result[1].custom_content.attachments) == 0
149+
content1 = str(result[1].content)
150+
assert "<title>data.csv</title>" in content1
151+
152+
def test_multi_message_non_attachment_messages_unchanged(self):
153+
transformer = _AttachmentFilter()
154+
plain_msg = _user_msg("just text")
155+
attach_msg = _user_msg(
156+
"with file",
157+
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
158+
)
159+
result = transformer.filter_attachments([plain_msg, attach_msg])
160+
161+
# Plain message is passed through as-is (same object, no deepcopy)
162+
assert result[0] is plain_msg
163+
assert result[0].content == "just text"
164+
165+
# Attachment message is filtered
166+
assert "<title>doc.pdf</title>" in str(result[1].content)
167+
168+
# --- Role-based tests ---
169+
170+
def test_assistant_message_image_attachments_stripped(self):
171+
transformer = _AttachmentFilter()
172+
msg = _msg(
173+
Role.ASSISTANT,
174+
"response",
175+
[_attachment("photo.png", "/files/photo.png", "image/png")],
176+
)
177+
result = transformer.filter_attachments([msg])
178+
# Non-USER roles: images are NOT kept inline
179+
assert len(result[0].custom_content.attachments) == 0
180+
content = str(result[0].content)
181+
assert "<title>photo.png</title>" in content
182+
183+
def test_tool_message_attachments_stripped(self):
184+
transformer = _AttachmentFilter()
185+
msg = _msg(
186+
Role.TOOL,
187+
"tool output",
188+
[_attachment("result.png", "/files/result.png", "image/png")],
189+
)
190+
result = transformer.filter_attachments([msg])
191+
assert len(result[0].custom_content.attachments) == 0
192+
content = str(result[0].content)
193+
assert "<title>result.png</title>" in content
194+
195+
# --- Edge case tests ---
196+
197+
def test_empty_message_list(self):
198+
transformer = _AttachmentFilter()
199+
result = transformer.filter_attachments([])
200+
assert result == []
201+
202+
def test_content_none_with_attachments(self):
203+
transformer = _AttachmentFilter()
204+
msg = _msg(
205+
Role.USER,
206+
None,
207+
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
208+
)
209+
result = transformer.filter_attachments([msg])
210+
content = str(result[0].content)
211+
assert "<attachments>" in content
212+
assert "<title>doc.pdf</title>" in content
213+
214+
def test_reference_url_conditional_absent(self):
215+
transformer = _AttachmentFilter()
216+
msg = _user_msg(
217+
"test",
218+
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
219+
)
220+
result = transformer.filter_attachments([msg])
221+
content = str(result[0].content)
222+
# reference_url is None by default → no element
223+
assert "<reference_url>" not in content
224+
225+
def test_reference_url_conditional_present(self):
226+
transformer = _AttachmentFilter()
227+
msg = _user_msg(
228+
"test",
229+
[
230+
_attachment(
231+
"doc.pdf",
232+
"/files/doc.pdf",
233+
"application/pdf",
234+
reference_url="/refs/doc.pdf",
235+
)
236+
],
237+
)
238+
result = transformer.filter_attachments([msg])
239+
content = str(result[0].content)
240+
assert "<reference_url>/refs/doc.pdf</reference_url>" in content

0 commit comments

Comments
 (0)