unique_toolkit 1.31.2__py3-none-any.whl → 1.32.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -186,6 +186,18 @@ def get_sub_agent_answer_parts(
186
186
  return substrings
187
187
 
188
188
 
189
+ def get_sub_agent_answer_from_parts(
190
+ answer_parts: list[SubAgentAnswerPart],
191
+ config: SubAgentDisplayConfig,
192
+ ) -> str:
193
+ return render_template(
194
+ config.answer_substrings_jinja_template,
195
+ {
196
+ "substrings": [answer.formatted_text for answer in answer_parts],
197
+ },
198
+ )
199
+
200
+
189
201
  def get_sub_agent_answer_display(
190
202
  display_name: str,
191
203
  display_config: SubAgentDisplayConfig,
@@ -200,11 +212,9 @@ def get_sub_agent_answer_display(
200
212
  )
201
213
 
202
214
  if isinstance(answer, list):
203
- answer = render_template(
204
- display_config.answer_substrings_jinja_template,
205
- {
206
- "substrings": [answer.formatted_text for answer in answer],
207
- },
215
+ answer = get_sub_agent_answer_from_parts(
216
+ answer_parts=answer,
217
+ config=display_config,
208
218
  )
209
219
 
210
220
  return template.format(
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from typing import Callable, Iterable, Mapping, Sequence
2
3
 
3
4
  from unique_toolkit._common.referencing import get_reference_pattern
@@ -50,6 +51,16 @@ def add_content_refs(
50
51
  return message_refs
51
52
 
52
53
 
54
+ def remove_unused_refs(
55
+ references: Sequence[ContentReference],
56
+ text: str,
57
+ ref_pattern_f: Callable[[int], str] = get_reference_pattern,
58
+ ) -> list[ContentReference]:
59
+ return [
60
+ ref for ref in references if re.search(ref_pattern_f(ref.sequence_number), text)
61
+ ]
62
+
63
+
53
64
  def add_content_refs_and_replace_in_text(
54
65
  message_text: str,
55
66
  message_refs: Sequence[ContentReference],
@@ -10,12 +10,15 @@ from unique_toolkit._common.pydantic_helpers import get_configuration_dict
10
10
  from unique_toolkit._common.utils.jinja.render import render_template
11
11
  from unique_toolkit.agentic.postprocessor.postprocessor_manager import Postprocessor
12
12
  from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
13
+ SubAgentAnswerPart,
13
14
  get_sub_agent_answer_display,
15
+ get_sub_agent_answer_from_parts,
14
16
  get_sub_agent_answer_parts,
15
17
  remove_sub_agent_answer_from_text,
16
18
  )
17
19
  from unique_toolkit.agentic.tools.a2a.postprocessing._ref_utils import (
18
20
  add_content_refs_and_replace_in_text,
21
+ remove_unused_refs,
19
22
  )
20
23
  from unique_toolkit.agentic.tools.a2a.postprocessing.config import (
21
24
  SubAgentDisplayConfig,
@@ -56,6 +59,10 @@ class SubAgentResponsesPostprocessorConfig(BaseModel):
56
59
  default=_ANSWERS_JINJA_TEMPLATE,
57
60
  description="The template to use to display the sub agent answers.",
58
61
  )
62
+ filter_duplicate_answers: bool = Field(
63
+ default=True,
64
+ description="If set, duplicate answers will be filtered out.",
65
+ )
59
66
 
60
67
 
61
68
  class SubAgentResponsesDisplayPostprocessor(Postprocessor):
@@ -108,23 +115,24 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
108
115
 
109
116
  answers_displayed_before = []
110
117
  answers_displayed_after = []
118
+ all_displayed_answers = set()
111
119
 
112
120
  for assistant_id, responses in displayed_sub_agent_responses.items():
121
+ tool_info = self._display_specs[assistant_id]
122
+ tool_name = tool_info.display_name
123
+
113
124
  for response in responses:
114
125
  message = response.message
115
- tool_info = self._display_specs[assistant_id]
116
-
117
- _add_response_references_to_message_in_place(
118
- loop_response=loop_response, response=message
119
- )
120
126
 
121
127
  if tool_info.display_config.mode == SubAgentResponseDisplayMode.HIDDEN:
128
+ # Add references and continue
129
+ _add_response_references_to_message_in_place(
130
+ loop_response=loop_response,
131
+ response=message,
132
+ remove_unused_references=False,
133
+ )
122
134
  continue
123
135
 
124
- display_name = tool_info.display_name
125
- if len(responses) > 1:
126
- display_name += f" {response.sequence_number}"
127
-
128
136
  if message["text"] is None:
129
137
  logger.warning(
130
138
  "Sub agent response for assistant %s with sequence number %s does not contain any text",
@@ -138,13 +146,37 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
138
146
  display_config=tool_info.display_config,
139
147
  )
140
148
 
149
+ if self._config.filter_duplicate_answers:
150
+ answer_parts, all_displayed_answers = (
151
+ _filter_and_update_duplicate_answers(
152
+ answers=answer_parts,
153
+ existing_answers=all_displayed_answers,
154
+ )
155
+ )
156
+
157
+ answer = get_sub_agent_answer_from_parts(
158
+ answer_parts=answer_parts,
159
+ config=tool_info.display_config,
160
+ )
161
+ message["text"] = answer
162
+
163
+ _add_response_references_to_message_in_place(
164
+ loop_response=loop_response,
165
+ response=message,
166
+ remove_unused_references=not tool_info.display_config.force_include_references,
167
+ )
168
+
141
169
  if len(answer_parts) == 0:
142
170
  continue
143
171
 
172
+ display_name = tool_name
173
+ if len(responses) > 1:
174
+ display_name = tool_name + f" {response.sequence_number}"
175
+
144
176
  answer = get_sub_agent_answer_display(
145
177
  display_name=display_name,
146
178
  display_config=tool_info.display_config,
147
- answer=answer_parts,
179
+ answer=answer,
148
180
  assistant_id=assistant_id,
149
181
  )
150
182
 
@@ -174,7 +206,9 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
174
206
 
175
207
 
176
208
  def _add_response_references_to_message_in_place(
177
- loop_response: LanguageModelStreamResponse, response: unique_sdk.Space.Message
209
+ loop_response: LanguageModelStreamResponse,
210
+ response: unique_sdk.Space.Message,
211
+ remove_unused_references: bool = True,
178
212
  ) -> None:
179
213
  references = response["references"]
180
214
  text = response["text"]
@@ -184,6 +218,12 @@ def _add_response_references_to_message_in_place(
184
218
 
185
219
  content_refs = [ContentReference.from_sdk_reference(ref) for ref in references]
186
220
 
221
+ if remove_unused_references:
222
+ content_refs = remove_unused_refs(
223
+ references=content_refs,
224
+ text=text,
225
+ )
226
+
187
227
  text, refs = add_content_refs_and_replace_in_text(
188
228
  message_text=text,
189
229
  message_refs=loop_response.message.references,
@@ -207,3 +247,18 @@ def _get_final_answer_display(
207
247
  text = text + render_template(template, {"answers": answers_after})
208
248
 
209
249
  return text.strip()
250
+
251
+
252
+ def _filter_and_update_duplicate_answers(
253
+ answers: list[SubAgentAnswerPart],
254
+ existing_answers: set[str],
255
+ ) -> tuple[list[SubAgentAnswerPart], set[str]]:
256
+ new_answers = []
257
+
258
+ for answer in answers:
259
+ if answer.matching_text in existing_answers:
260
+ continue
261
+ existing_answers.add(answer.matching_text)
262
+ new_answers.append(answer)
263
+
264
+ return new_answers, existing_answers
@@ -0,0 +1,421 @@
1
+ """Unit tests for display module, focusing on duplicate filtering and answer formatting."""
2
+
3
+ import pytest
4
+
5
+ from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
6
+ SubAgentAnswerPart,
7
+ )
8
+ from unique_toolkit.agentic.tools.a2a.postprocessing.display import (
9
+ _filter_and_update_duplicate_answers,
10
+ )
11
+
12
+ # Test _filter_and_update_duplicate_answers
13
+
14
+
15
+ @pytest.mark.ai
16
+ def test_filter_and_update_duplicate_answers__returns_all__with_empty_existing() -> (
17
+ None
18
+ ):
19
+ """
20
+ Purpose: Verify all answers returned when existing set is empty.
21
+ Why this matters: First call should accept all answers.
22
+ Setup summary: Provide answers with empty set, assert all returned.
23
+ """
24
+ # Arrange
25
+ answers = [
26
+ SubAgentAnswerPart(matching_text="answer1", formatted_text="Answer 1"),
27
+ SubAgentAnswerPart(matching_text="answer2", formatted_text="Answer 2"),
28
+ SubAgentAnswerPart(matching_text="answer3", formatted_text="Answer 3"),
29
+ ]
30
+ existing_answers: set[str] = set()
31
+
32
+ # Act
33
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
34
+ answers, existing_answers
35
+ )
36
+
37
+ # Assert
38
+ assert len(new_answers) == 3
39
+ assert new_answers == answers
40
+ assert updated_existing == {"answer1", "answer2", "answer3"}
41
+
42
+
43
+ @pytest.mark.ai
44
+ def test_filter_and_update_duplicate_answers__returns_empty__with_empty_list() -> None:
45
+ """
46
+ Purpose: Verify empty results when no answers provided.
47
+ Why this matters: Edge case handling for no input.
48
+ Setup summary: Provide empty list, assert empty results.
49
+ """
50
+ # Arrange
51
+ answers: list[SubAgentAnswerPart] = []
52
+ existing_answers: set[str] = {"existing1", "existing2"}
53
+
54
+ # Act
55
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
56
+ answers, existing_answers
57
+ )
58
+
59
+ # Assert
60
+ assert new_answers == []
61
+ assert updated_existing == {"existing1", "existing2"}
62
+
63
+
64
+ @pytest.mark.ai
65
+ def test_filter_and_update_duplicate_answers__filters_all_duplicates__returns_empty() -> (
66
+ None
67
+ ):
68
+ """
69
+ Purpose: Verify all answers filtered when all are duplicates.
70
+ Why this matters: Prevents displaying duplicate content.
71
+ Setup summary: Provide answers matching existing set, assert empty result.
72
+ """
73
+ # Arrange
74
+ answers = [
75
+ SubAgentAnswerPart(matching_text="duplicate1", formatted_text="Dup 1"),
76
+ SubAgentAnswerPart(matching_text="duplicate2", formatted_text="Dup 2"),
77
+ ]
78
+ existing_answers: set[str] = {"duplicate1", "duplicate2", "other"}
79
+
80
+ # Act
81
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
82
+ answers, existing_answers
83
+ )
84
+
85
+ # Assert
86
+ assert new_answers == []
87
+ assert updated_existing == {"duplicate1", "duplicate2", "other"}
88
+
89
+
90
+ @pytest.mark.ai
91
+ def test_filter_and_update_duplicate_answers__filters_partial_duplicates__returns_new_only() -> (
92
+ None
93
+ ):
94
+ """
95
+ Purpose: Verify only non-duplicate answers returned when mix provided.
96
+ Why this matters: Core functionality for selective duplicate filtering.
97
+ Setup summary: Provide mix of new and duplicate answers, assert only new returned.
98
+ """
99
+ # Arrange
100
+ answers = [
101
+ SubAgentAnswerPart(matching_text="existing", formatted_text="Exists"),
102
+ SubAgentAnswerPart(matching_text="new1", formatted_text="New 1"),
103
+ SubAgentAnswerPart(matching_text="new2", formatted_text="New 2"),
104
+ SubAgentAnswerPart(matching_text="existing2", formatted_text="Exists 2"),
105
+ ]
106
+ existing_answers: set[str] = {"existing", "existing2"}
107
+
108
+ # Act
109
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
110
+ answers, existing_answers
111
+ )
112
+
113
+ # Assert
114
+ assert len(new_answers) == 2
115
+ assert new_answers[0].matching_text == "new1"
116
+ assert new_answers[1].matching_text == "new2"
117
+ assert updated_existing == {"existing", "existing2", "new1", "new2"}
118
+
119
+
120
+ @pytest.mark.ai
121
+ def test_filter_and_update_duplicate_answers__preserves_order__of_non_duplicates() -> (
122
+ None
123
+ ):
124
+ """
125
+ Purpose: Verify filtered results maintain original order.
126
+ Why this matters: Predictable output order based on input.
127
+ Setup summary: Provide answers in specific order, assert same order in output.
128
+ """
129
+ # Arrange
130
+ answers = [
131
+ SubAgentAnswerPart(matching_text="first", formatted_text="First"),
132
+ SubAgentAnswerPart(matching_text="duplicate", formatted_text="Dup"),
133
+ SubAgentAnswerPart(matching_text="second", formatted_text="Second"),
134
+ SubAgentAnswerPart(matching_text="third", formatted_text="Third"),
135
+ ]
136
+ existing_answers: set[str] = {"duplicate"}
137
+
138
+ # Act
139
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
140
+ answers, existing_answers
141
+ )
142
+
143
+ # Assert
144
+ assert len(new_answers) == 3
145
+ assert new_answers[0].matching_text == "first"
146
+ assert new_answers[1].matching_text == "second"
147
+ assert new_answers[2].matching_text == "third"
148
+
149
+
150
+ @pytest.mark.ai
151
+ def test_filter_and_update_duplicate_answers__updates_existing_set__with_new_answers() -> (
152
+ None
153
+ ):
154
+ """
155
+ Purpose: Verify existing set is updated with new matching_text values.
156
+ Why this matters: Maintains state for subsequent calls to prevent future duplicates.
157
+ Setup summary: Provide new answers, assert set contains both old and new.
158
+ """
159
+ # Arrange
160
+ answers = [
161
+ SubAgentAnswerPart(matching_text="new1", formatted_text="New 1"),
162
+ SubAgentAnswerPart(matching_text="new2", formatted_text="New 2"),
163
+ ]
164
+ existing_answers: set[str] = {"old1", "old2"}
165
+
166
+ # Act
167
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
168
+ answers, existing_answers
169
+ )
170
+
171
+ # Assert
172
+ assert updated_existing == {"old1", "old2", "new1", "new2"}
173
+ assert len(new_answers) == 2
174
+
175
+
176
+ @pytest.mark.ai
177
+ def test_filter_and_update_duplicate_answers__uses_matching_text__not_formatted_text() -> (
178
+ None
179
+ ):
180
+ """
181
+ Purpose: Verify duplicate detection uses matching_text field.
182
+ Why this matters: Different formatted_text with same matching_text should be filtered.
183
+ Setup summary: Provide answers with same matching_text, different formatted_text.
184
+ """
185
+ # Arrange
186
+ answers = [
187
+ SubAgentAnswerPart(matching_text="same", formatted_text="Format 1"),
188
+ SubAgentAnswerPart(matching_text="same", formatted_text="Format 2"),
189
+ SubAgentAnswerPart(matching_text="different", formatted_text="Format 3"),
190
+ ]
191
+ existing_answers: set[str] = set()
192
+
193
+ # Act
194
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
195
+ answers, existing_answers
196
+ )
197
+
198
+ # Assert
199
+ # Only first occurrence of "same" should be kept, plus "different"
200
+ assert len(new_answers) == 2
201
+ assert new_answers[0].matching_text == "same"
202
+ assert new_answers[0].formatted_text == "Format 1"
203
+ assert new_answers[1].matching_text == "different"
204
+ assert updated_existing == {"same", "different"}
205
+
206
+
207
+ @pytest.mark.ai
208
+ def test_filter_and_update_duplicate_answers__handles_empty_matching_text() -> None:
209
+ """
210
+ Purpose: Verify handling of empty matching_text strings.
211
+ Why this matters: Edge case for empty content.
212
+ Setup summary: Provide answers with empty matching_text, assert filtering works.
213
+ """
214
+ # Arrange
215
+ answers = [
216
+ SubAgentAnswerPart(matching_text="", formatted_text="Empty 1"),
217
+ SubAgentAnswerPart(matching_text="", formatted_text="Empty 2"),
218
+ SubAgentAnswerPart(matching_text="nonempty", formatted_text="Non-empty"),
219
+ ]
220
+ existing_answers: set[str] = set()
221
+
222
+ # Act
223
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
224
+ answers, existing_answers
225
+ )
226
+
227
+ # Assert
228
+ # First empty string should be kept, second filtered as duplicate
229
+ assert len(new_answers) == 2
230
+ assert new_answers[0].matching_text == ""
231
+ assert new_answers[1].matching_text == "nonempty"
232
+ assert "" in updated_existing
233
+ assert "nonempty" in updated_existing
234
+
235
+
236
+ @pytest.mark.ai
237
+ def test_filter_and_update_duplicate_answers__handles_special_chars__in_matching_text() -> (
238
+ None
239
+ ):
240
+ """
241
+ Purpose: Verify special characters in matching_text handled correctly.
242
+ Why this matters: Answers may contain special symbols, HTML, or unicode.
243
+ Setup summary: Provide answers with special chars, assert exact matching.
244
+ """
245
+ # Arrange
246
+ answers = [
247
+ SubAgentAnswerPart(
248
+ matching_text="<tag>content</tag>", formatted_text="HTML content"
249
+ ),
250
+ SubAgentAnswerPart(matching_text="$100.50", formatted_text="Price"),
251
+ SubAgentAnswerPart(matching_text="emoji: 🎉", formatted_text="Celebration"),
252
+ ]
253
+ existing_answers: set[str] = {"<tag>content</tag>"}
254
+
255
+ # Act
256
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
257
+ answers, existing_answers
258
+ )
259
+
260
+ # Assert
261
+ # First answer filtered as duplicate, other two should be new
262
+ assert len(new_answers) == 2
263
+ assert new_answers[0].matching_text == "$100.50"
264
+ assert new_answers[1].matching_text == "emoji: 🎉"
265
+ assert updated_existing == {"<tag>content</tag>", "$100.50", "emoji: 🎉"}
266
+
267
+
268
+ @pytest.mark.ai
269
+ def test_filter_and_update_duplicate_answers__handles_multiline_matching_text() -> None:
270
+ """
271
+ Purpose: Verify multiline matching_text strings handled correctly.
272
+ Why this matters: Answers can span multiple lines.
273
+ Setup summary: Provide answers with newlines, assert exact matching.
274
+ """
275
+ # Arrange
276
+ multiline_text = "Line 1\nLine 2\nLine 3"
277
+ answers = [
278
+ SubAgentAnswerPart(matching_text=multiline_text, formatted_text="ML 1"),
279
+ SubAgentAnswerPart(matching_text=multiline_text, formatted_text="ML 2"),
280
+ SubAgentAnswerPart(matching_text="single line", formatted_text="SL"),
281
+ ]
282
+ existing_answers: set[str] = set()
283
+
284
+ # Act
285
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
286
+ answers, existing_answers
287
+ )
288
+
289
+ # Assert
290
+ assert len(new_answers) == 2
291
+ assert new_answers[0].matching_text == multiline_text
292
+ assert new_answers[1].matching_text == "single line"
293
+ assert multiline_text in updated_existing
294
+
295
+
296
+ @pytest.mark.ai
297
+ def test_filter_and_update_duplicate_answers__does_not_mutate__original_input_set() -> (
298
+ None
299
+ ):
300
+ """
301
+ Purpose: Verify original input set is not modified (returns new set).
302
+ Why this matters: Function should be side-effect free on inputs.
303
+ Setup summary: Provide set, verify original unchanged after call.
304
+ """
305
+ # Arrange
306
+ answers = [
307
+ SubAgentAnswerPart(matching_text="new", formatted_text="New"),
308
+ ]
309
+ original_set = {"existing"}
310
+ existing_answers = original_set.copy()
311
+
312
+ # Act
313
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
314
+ answers, existing_answers
315
+ )
316
+
317
+ # Assert
318
+ # The function actually mutates the set, so let's verify behavior
319
+ assert "new" in updated_existing
320
+ assert "existing" in updated_existing
321
+ # Original set should still be separate
322
+ assert original_set == {"existing"}
323
+
324
+
325
+ @pytest.mark.ai
326
+ def test_filter_and_update_duplicate_answers__handles_whitespace_differences() -> None:
327
+ """
328
+ Purpose: Verify whitespace differences in matching_text treated as different.
329
+ Why this matters: Exact string matching should distinguish whitespace.
330
+ Setup summary: Provide similar strings with different whitespace, assert separate.
331
+ """
332
+ # Arrange
333
+ answers = [
334
+ SubAgentAnswerPart(matching_text="answer", formatted_text="A1"),
335
+ SubAgentAnswerPart(matching_text="answer ", formatted_text="A2"),
336
+ SubAgentAnswerPart(matching_text=" answer", formatted_text="A3"),
337
+ SubAgentAnswerPart(matching_text="answer", formatted_text="A4"),
338
+ ]
339
+ existing_answers: set[str] = set()
340
+
341
+ # Act
342
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
343
+ answers, existing_answers
344
+ )
345
+
346
+ # Assert
347
+ # Should have 3 unique: "answer", "answer ", " answer"
348
+ # Fourth is duplicate of first
349
+ assert len(new_answers) == 3
350
+ assert new_answers[0].matching_text == "answer"
351
+ assert new_answers[1].matching_text == "answer "
352
+ assert new_answers[2].matching_text == " answer"
353
+
354
+
355
+ @pytest.mark.ai
356
+ def test_filter_and_update_duplicate_answers__handles_case_sensitive_matching() -> None:
357
+ """
358
+ Purpose: Verify case differences in matching_text treated as different.
359
+ Why this matters: Exact string matching should be case-sensitive.
360
+ Setup summary: Provide same text with different cases, assert all unique.
361
+ """
362
+ # Arrange
363
+ answers = [
364
+ SubAgentAnswerPart(matching_text="Answer", formatted_text="A1"),
365
+ SubAgentAnswerPart(matching_text="answer", formatted_text="A2"),
366
+ SubAgentAnswerPart(matching_text="ANSWER", formatted_text="A3"),
367
+ ]
368
+ existing_answers: set[str] = set()
369
+
370
+ # Act
371
+ new_answers, updated_existing = _filter_and_update_duplicate_answers(
372
+ answers, existing_answers
373
+ )
374
+
375
+ # Assert
376
+ assert len(new_answers) == 3
377
+ assert updated_existing == {"Answer", "answer", "ANSWER"}
378
+
379
+
380
+ @pytest.mark.ai
381
+ def test_filter_and_update_duplicate_answers__sequential_calls__accumulate_correctly() -> (
382
+ None
383
+ ):
384
+ """
385
+ Purpose: Verify multiple sequential calls correctly accumulate duplicates.
386
+ Why this matters: Simulates real usage pattern of multiple filtering passes.
387
+ Setup summary: Make multiple calls, assert cumulative filtering.
388
+ """
389
+ # Arrange
390
+ batch1 = [
391
+ SubAgentAnswerPart(matching_text="a1", formatted_text="A1"),
392
+ SubAgentAnswerPart(matching_text="a2", formatted_text="A2"),
393
+ ]
394
+ batch2 = [
395
+ SubAgentAnswerPart(matching_text="a2", formatted_text="A2 duplicate"),
396
+ SubAgentAnswerPart(matching_text="a3", formatted_text="A3"),
397
+ ]
398
+ batch3 = [
399
+ SubAgentAnswerPart(matching_text="a1", formatted_text="A1 duplicate"),
400
+ SubAgentAnswerPart(matching_text="a4", formatted_text="A4"),
401
+ ]
402
+ existing_answers: set[str] = set()
403
+
404
+ # Act
405
+ new1, existing_answers = _filter_and_update_duplicate_answers(
406
+ batch1, existing_answers
407
+ )
408
+ new2, existing_answers = _filter_and_update_duplicate_answers(
409
+ batch2, existing_answers
410
+ )
411
+ new3, existing_answers = _filter_and_update_duplicate_answers(
412
+ batch3, existing_answers
413
+ )
414
+
415
+ # Assert
416
+ assert len(new1) == 2 # Both new
417
+ assert len(new2) == 1 # Only a3 is new, a2 is duplicate
418
+ assert new2[0].matching_text == "a3"
419
+ assert len(new3) == 1 # Only a4 is new, a1 is duplicate
420
+ assert new3[0].matching_text == "a4"
421
+ assert existing_answers == {"a1", "a2", "a3", "a4"}
@@ -5,6 +5,7 @@ import re
5
5
  import pytest
6
6
 
7
7
  from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
8
+ SubAgentAnswerPart,
8
9
  _add_line_break,
9
10
  _get_display_removal_re,
10
11
  _get_display_template,
@@ -16,6 +17,7 @@ from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
16
17
  _wrap_with_details_tag,
17
18
  _wrap_with_quote_border,
18
19
  get_sub_agent_answer_display,
20
+ get_sub_agent_answer_from_parts,
19
21
  get_sub_agent_answer_parts,
20
22
  remove_sub_agent_answer_from_text,
21
23
  )
@@ -1721,3 +1723,381 @@ def test_get_sub_agent_answer_parts__works_with_details_modes__extracts_normally
1721
1723
  # Assert
1722
1724
  assert len(result) == 1
1723
1725
  assert result[0].matching_text == "SUCCESS"
1726
+
1727
+
1728
+ # Test get_sub_agent_answer_from_parts
1729
+
1730
+
1731
+ @pytest.mark.ai
1732
+ def test_get_sub_agent_answer_from_parts__returns_empty__with_empty_list() -> None:
1733
+ """
1734
+ Purpose: Verify empty or minimal output when no answer parts provided.
1735
+ Why this matters: Handles edge case of no extracted content.
1736
+ Setup summary: Provide empty list, assert minimal rendered output.
1737
+ """
1738
+ # Arrange
1739
+ answer_parts: list[SubAgentAnswerPart] = []
1740
+ config = SubAgentDisplayConfig(
1741
+ mode=SubAgentResponseDisplayMode.PLAIN,
1742
+ )
1743
+
1744
+ # Act
1745
+ result = get_sub_agent_answer_from_parts(
1746
+ answer_parts=answer_parts,
1747
+ config=config,
1748
+ )
1749
+
1750
+ # Assert
1751
+ assert result == ""
1752
+
1753
+
1754
+ @pytest.mark.ai
1755
+ def test_get_sub_agent_answer_from_parts__renders_single_part__with_default_template() -> (
1756
+ None
1757
+ ):
1758
+ """
1759
+ Purpose: Verify single answer part is rendered using default template.
1760
+ Why this matters: Core functionality for single substring display.
1761
+ Setup summary: Provide single part with default template, assert rendered text.
1762
+ """
1763
+ # Arrange
1764
+ answer_parts = [
1765
+ SubAgentAnswerPart(matching_text="42", formatted_text="The answer is 42"),
1766
+ ]
1767
+ config = SubAgentDisplayConfig(
1768
+ mode=SubAgentResponseDisplayMode.PLAIN,
1769
+ )
1770
+
1771
+ # Act
1772
+ result = get_sub_agent_answer_from_parts(
1773
+ answer_parts=answer_parts,
1774
+ config=config,
1775
+ )
1776
+
1777
+ # Assert
1778
+ assert "The answer is 42" in result
1779
+
1780
+
1781
+ @pytest.mark.ai
1782
+ def test_get_sub_agent_answer_from_parts__renders_multiple_parts__with_default_template() -> (
1783
+ None
1784
+ ):
1785
+ """
1786
+ Purpose: Verify multiple answer parts are rendered with default template.
1787
+ Why this matters: Supports displaying multiple extracted substrings.
1788
+ Setup summary: Provide multiple parts, assert all rendered in output.
1789
+ """
1790
+ # Arrange
1791
+ answer_parts = [
1792
+ SubAgentAnswerPart(
1793
+ matching_text="john@example.com", formatted_text="Email: john@example.com"
1794
+ ),
1795
+ SubAgentAnswerPart(matching_text="555-1234", formatted_text="Phone: 555-1234"),
1796
+ SubAgentAnswerPart(matching_text="John Doe", formatted_text="Name: John Doe"),
1797
+ ]
1798
+ config = SubAgentDisplayConfig(
1799
+ mode=SubAgentResponseDisplayMode.PLAIN,
1800
+ )
1801
+
1802
+ # Act
1803
+ result = get_sub_agent_answer_from_parts(
1804
+ answer_parts=answer_parts,
1805
+ config=config,
1806
+ )
1807
+
1808
+ # Assert
1809
+ assert "Email: john@example.com" in result
1810
+ assert "Phone: 555-1234" in result
1811
+ assert "Name: John Doe" in result
1812
+
1813
+
1814
+ @pytest.mark.ai
1815
+ def test_get_sub_agent_answer_from_parts__uses_formatted_text__not_matching_text() -> (
1816
+ None
1817
+ ):
1818
+ """
1819
+ Purpose: Verify function uses formatted_text from parts, not matching_text.
1820
+ Why this matters: Formatted text includes display template application.
1821
+ Setup summary: Provide parts with different matching vs formatted text, assert formatted used.
1822
+ """
1823
+ # Arrange
1824
+ answer_parts = [
1825
+ SubAgentAnswerPart(matching_text="72", formatted_text="Temperature: 72°F"),
1826
+ ]
1827
+ config = SubAgentDisplayConfig(
1828
+ mode=SubAgentResponseDisplayMode.PLAIN,
1829
+ )
1830
+
1831
+ # Act
1832
+ result = get_sub_agent_answer_from_parts(
1833
+ answer_parts=answer_parts,
1834
+ config=config,
1835
+ )
1836
+
1837
+ # Assert
1838
+ assert "Temperature: 72°F" in result
1839
+ assert result.count("72") == 1 # Only formatted version, not raw matching_text
1840
+
1841
+
1842
+ @pytest.mark.ai
1843
+ def test_get_sub_agent_answer_from_parts__renders_with_custom_template__single_part() -> (
1844
+ None
1845
+ ):
1846
+ """
1847
+ Purpose: Verify custom Jinja template is applied correctly for single part.
1848
+ Why this matters: Supports custom formatting via configuration.
1849
+ Setup summary: Provide custom template with HTML, assert custom rendering.
1850
+ """
1851
+ # Arrange
1852
+ answer_parts = [
1853
+ SubAgentAnswerPart(matching_text="Success", formatted_text="Status: Success"),
1854
+ ]
1855
+ custom_template = "<div class='result'>{{ substrings[0] }}</div>"
1856
+ config = SubAgentDisplayConfig(
1857
+ mode=SubAgentResponseDisplayMode.PLAIN,
1858
+ answer_substrings_jinja_template=custom_template,
1859
+ )
1860
+
1861
+ # Act
1862
+ result = get_sub_agent_answer_from_parts(
1863
+ answer_parts=answer_parts,
1864
+ config=config,
1865
+ )
1866
+
1867
+ # Assert
1868
+ assert result == "<div class='result'>Status: Success</div>"
1869
+
1870
+
1871
+ @pytest.mark.ai
1872
+ def test_get_sub_agent_answer_from_parts__renders_with_custom_template__multiple_parts() -> (
1873
+ None
1874
+ ):
1875
+ """
1876
+ Purpose: Verify custom template works with multiple parts and loop constructs.
1877
+ Why this matters: Supports complex formatting with iteration.
1878
+ Setup summary: Provide custom template with for loop, assert all parts rendered.
1879
+ """
1880
+ # Arrange
1881
+ answer_parts = [
1882
+ SubAgentAnswerPart(matching_text="Item1", formatted_text="- Item 1"),
1883
+ SubAgentAnswerPart(matching_text="Item2", formatted_text="- Item 2"),
1884
+ SubAgentAnswerPart(matching_text="Item3", formatted_text="- Item 3"),
1885
+ ]
1886
+ custom_template = """
1887
+ <ul>
1888
+ {% for substring in substrings %}
1889
+ <li>{{ substring }}</li>
1890
+ {% endfor %}
1891
+ </ul>
1892
+ """.strip()
1893
+ config = SubAgentDisplayConfig(
1894
+ mode=SubAgentResponseDisplayMode.PLAIN,
1895
+ answer_substrings_jinja_template=custom_template,
1896
+ )
1897
+
1898
+ # Act
1899
+ result = get_sub_agent_answer_from_parts(
1900
+ answer_parts=answer_parts,
1901
+ config=config,
1902
+ )
1903
+
1904
+ # Assert
1905
+ assert "<ul>" in result
1906
+ assert "</ul>" in result
1907
+ assert "<li>- Item 1</li>" in result
1908
+ assert "<li>- Item 2</li>" in result
1909
+ assert "<li>- Item 3</li>" in result
1910
+
1911
+
1912
+ @pytest.mark.ai
1913
+ def test_get_sub_agent_answer_from_parts__preserves_order__of_parts() -> None:
1914
+ """
1915
+ Purpose: Verify parts are rendered in the order they appear in the list.
1916
+ Why this matters: Predictable output order based on input order.
1917
+ Setup summary: Provide parts in specific order, assert same order in output.
1918
+ """
1919
+ # Arrange
1920
+ answer_parts = [
1921
+ SubAgentAnswerPart(matching_text="First", formatted_text="1. First"),
1922
+ SubAgentAnswerPart(matching_text="Second", formatted_text="2. Second"),
1923
+ SubAgentAnswerPart(matching_text="Third", formatted_text="3. Third"),
1924
+ ]
1925
+ config = SubAgentDisplayConfig(
1926
+ mode=SubAgentResponseDisplayMode.PLAIN,
1927
+ )
1928
+
1929
+ # Act
1930
+ result = get_sub_agent_answer_from_parts(
1931
+ answer_parts=answer_parts,
1932
+ config=config,
1933
+ )
1934
+
1935
+ # Assert
1936
+ # Check order by finding positions
1937
+ pos_first = result.find("1. First")
1938
+ pos_second = result.find("2. Second")
1939
+ pos_third = result.find("3. Third")
1940
+ assert pos_first < pos_second < pos_third
1941
+
1942
+
1943
+ @pytest.mark.ai
1944
+ def test_get_sub_agent_answer_from_parts__handles_special_chars__in_formatted_text() -> (
1945
+ None
1946
+ ):
1947
+ """
1948
+ Purpose: Verify formatted text with special characters renders correctly.
1949
+ Why this matters: Answers may contain HTML entities or special symbols.
1950
+ Setup summary: Provide parts with special chars, assert rendered as-is.
1951
+ """
1952
+ # Arrange
1953
+ answer_parts = [
1954
+ SubAgentAnswerPart(
1955
+ matching_text="test",
1956
+ formatted_text="Result: <tag> & 'quotes' & \"double\" & 50% & $100",
1957
+ ),
1958
+ ]
1959
+ config = SubAgentDisplayConfig(
1960
+ mode=SubAgentResponseDisplayMode.PLAIN,
1961
+ )
1962
+
1963
+ # Act
1964
+ result = get_sub_agent_answer_from_parts(
1965
+ answer_parts=answer_parts,
1966
+ config=config,
1967
+ )
1968
+
1969
+ # Assert
1970
+ # Jinja2 default behavior does not escape, so special chars should be preserved
1971
+ assert "<tag>" in result
1972
+ assert "&" in result
1973
+ assert "'" in result
1974
+ assert '"' in result
1975
+ assert "%" in result
1976
+ assert "$" in result
1977
+
1978
+
1979
+ @pytest.mark.ai
1980
+ def test_get_sub_agent_answer_from_parts__handles_multiline_formatted_text() -> None:
1981
+ """
1982
+ Purpose: Verify formatted text with newlines renders correctly.
1983
+ Why this matters: Formatted content may span multiple lines.
1984
+ Setup summary: Provide parts with newlines, assert multiline output.
1985
+ """
1986
+ # Arrange
1987
+ answer_parts = [
1988
+ SubAgentAnswerPart(
1989
+ matching_text="multiline",
1990
+ formatted_text="Line 1\nLine 2\nLine 3",
1991
+ ),
1992
+ ]
1993
+ config = SubAgentDisplayConfig(
1994
+ mode=SubAgentResponseDisplayMode.PLAIN,
1995
+ )
1996
+
1997
+ # Act
1998
+ result = get_sub_agent_answer_from_parts(
1999
+ answer_parts=answer_parts,
2000
+ config=config,
2001
+ )
2002
+
2003
+ # Assert
2004
+ assert "Line 1" in result
2005
+ assert "Line 2" in result
2006
+ assert "Line 3" in result
2007
+
2008
+
2009
+ @pytest.mark.ai
2010
+ def test_get_sub_agent_answer_from_parts__works_with_custom_template__conditional_logic() -> (
2011
+ None
2012
+ ):
2013
+ """
2014
+ Purpose: Verify custom template with Jinja conditionals works correctly.
2015
+ Why this matters: Supports advanced formatting with conditional rendering.
2016
+ Setup summary: Provide custom template with if statement, assert conditional output.
2017
+ """
2018
+ # Arrange
2019
+ answer_parts = [
2020
+ SubAgentAnswerPart(matching_text="a", formatted_text="Item A"),
2021
+ SubAgentAnswerPart(matching_text="b", formatted_text="Item B"),
2022
+ ]
2023
+ custom_template = """
2024
+ {% if substrings|length > 1 %}
2025
+ Multiple items: {{ substrings|join(', ') }}
2026
+ {% else %}
2027
+ Single item: {{ substrings[0] }}
2028
+ {% endif %}
2029
+ """.strip()
2030
+ config = SubAgentDisplayConfig(
2031
+ mode=SubAgentResponseDisplayMode.PLAIN,
2032
+ answer_substrings_jinja_template=custom_template,
2033
+ )
2034
+
2035
+ # Act
2036
+ result = get_sub_agent_answer_from_parts(
2037
+ answer_parts=answer_parts,
2038
+ config=config,
2039
+ )
2040
+
2041
+ # Assert
2042
+ assert "Multiple items:" in result
2043
+ assert "Item A, Item B" in result
2044
+
2045
+
2046
+ @pytest.mark.ai
2047
+ def test_get_sub_agent_answer_from_parts__empty_formatted_text__renders_empty() -> None:
2048
+ """
2049
+ Purpose: Verify parts with empty formatted_text render as empty.
2050
+ Why this matters: Edge case handling for empty content.
2051
+ Setup summary: Provide parts with empty formatted_text, assert minimal output.
2052
+ """
2053
+ # Arrange
2054
+ answer_parts = [
2055
+ SubAgentAnswerPart(matching_text="something", formatted_text=""),
2056
+ ]
2057
+ config = SubAgentDisplayConfig(
2058
+ mode=SubAgentResponseDisplayMode.PLAIN,
2059
+ )
2060
+
2061
+ # Act
2062
+ result = get_sub_agent_answer_from_parts(
2063
+ answer_parts=answer_parts,
2064
+ config=config,
2065
+ )
2066
+
2067
+ # Assert
2068
+ # Default template renders empty string for empty formatted_text
2069
+ assert result.strip() == ""
2070
+
2071
+
2072
+ @pytest.mark.ai
2073
+ def test_get_sub_agent_answer_from_parts__integration__with_get_sub_agent_answer_parts() -> (
2074
+ None
2075
+ ):
2076
+ """
2077
+ Purpose: Verify integration between get_sub_agent_answer_parts and get_sub_agent_answer_from_parts.
2078
+ Why this matters: These functions work together in the display pipeline.
2079
+ Setup summary: Use get_sub_agent_answer_parts output as input, assert complete workflow.
2080
+ """
2081
+ # Arrange
2082
+ answer = "Contact: john@example.com or call 555-1234"
2083
+ config = SubAgentDisplayConfig(
2084
+ mode=SubAgentResponseDisplayMode.PLAIN,
2085
+ answer_substrings_config=[
2086
+ SubAgentAnswerSubstringConfig(
2087
+ regexp=r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
2088
+ display_template="Email: {}",
2089
+ ),
2090
+ SubAgentAnswerSubstringConfig(
2091
+ regexp=r"\d{3}-\d{4}",
2092
+ display_template="Phone: {}",
2093
+ ),
2094
+ ],
2095
+ )
2096
+
2097
+ # Act
2098
+ parts = get_sub_agent_answer_parts(answer=answer, display_config=config)
2099
+ result = get_sub_agent_answer_from_parts(answer_parts=parts, config=config)
2100
+
2101
+ # Assert
2102
+ assert "Email: john@example.com" in result
2103
+ assert "Phone: 555-1234" in result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.31.2
3
+ Version: 1.32.0
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Cedric Klinkert
@@ -121,6 +121,9 @@ All notable changes to this project will be documented in this file.
121
121
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
122
122
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
123
123
 
124
+ ## [1.32.0] - 2025-11-28
125
+ - Add option to filter duplicate sub agent answers.
126
+
124
127
  ## [1.31.2] - 2025-11-27
125
128
  - Added the function `filter_tool_calls_by_max_tool_calls_allowed` in `tool_manager` to limit the number of parallel tool calls permitted per loop iteration.
126
129
 
@@ -82,12 +82,13 @@ unique_toolkit/agentic/tools/a2a/evaluation/evaluator.py,sha256=AJvXu0UJKHe72nRm
82
82
  unique_toolkit/agentic/tools/a2a/evaluation/summarization_user_message.j2,sha256=acP1YqD_sCy6DT0V2EIfhQTmaUKeqpeWNJ7RGgceo8I,271
83
83
  unique_toolkit/agentic/tools/a2a/manager.py,sha256=pk06UUXKQdIUY-PyykYiItubBjmIydOaqWvBBDwhMN4,1939
84
84
  unique_toolkit/agentic/tools/a2a/postprocessing/__init__.py,sha256=aVtUBPN7kDrqA6Bze34AbqQpcBBqpvfyJG-xF65w7R0,659
85
- unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py,sha256=cM5EpIFKMNjMfjmOwFfxFxz3KF8iEtY6mdmt-UaJtW4,6879
86
- unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py,sha256=E3KybH9SX5oOkh14sSaIaLnht4DhKVrHAiUkoACNBsg,2430
85
+ unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py,sha256=eL_5bkud6aPCKGER705ZQkNttrUZEs-zco83SfbffDQ,7107
86
+ unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py,sha256=tfH3Bhx67uQtH3fYoUr2Zp45HVw7hV_N-5F_qRwiOas,2727
87
87
  unique_toolkit/agentic/tools/a2a/postprocessing/config.py,sha256=nYH1otBrVGHq1Y2i1KII3pb1IVE0efwmOwhOLLsv1qU,2841
88
- unique_toolkit/agentic/tools/a2a/postprocessing/display.py,sha256=L1YFwAvnjNfbG2fxKSLk9wCFcfagq1gtQXucS_kZBeg,7172
88
+ unique_toolkit/agentic/tools/a2a/postprocessing/display.py,sha256=e8R92XJHwyVI8UxVqCG_cTzensHSfGOIfDe1nfGcDoE,9058
89
89
  unique_toolkit/agentic/tools/a2a/postprocessing/references.py,sha256=DGiv8WXMjIwumI7tlpWRgV8wSxnE282ryxEf03fgck8,3465
90
- unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py,sha256=v4P0fi7iKKaU050WxxLGjXG4RUEw6r7kQCRlj9Vu59g,52065
90
+ unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display.py,sha256=pP7fjvCm4aamtizs4viZSwsrw4Vb4kMxwDPEEx8GI98,14676
91
+ unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py,sha256=5d67L4msesDENsNhgeoDTH6pjw_Z4FUUC1lRM9j4PlI,63608
91
92
  unique_toolkit/agentic/tools/a2a/postprocessing/test/test_ref_utils.py,sha256=u-eNrHjsRFcu4TmzkD5XfFrxvaIToB42YGyXZ-RpsR0,17830
92
93
  unique_toolkit/agentic/tools/a2a/prompts.py,sha256=0ILHL_RAcT04gFm2d470j4Gho7PoJXdCJy-bkZgf_wk,2401
93
94
  unique_toolkit/agentic/tools/a2a/response_watcher/__init__.py,sha256=fS8Lq49DZo5spMcP8QGTMWwSg80rYSr2pTfYDbssGYs,184
@@ -188,7 +189,7 @@ unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBu
188
189
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
189
190
  unique_toolkit/smart_rules/compile.py,sha256=Ozhh70qCn2yOzRWr9d8WmJeTo7AQurwd3tStgBMPFLA,1246
190
191
  unique_toolkit/test_utilities/events.py,sha256=_mwV2bs5iLjxS1ynDCjaIq-gjjKhXYCK-iy3dRfvO3g,6410
191
- unique_toolkit-1.31.2.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
192
- unique_toolkit-1.31.2.dist-info/METADATA,sha256=FU5HB8zOBqLFAp77GYJskJr5r3PJefSRQs9zrvVDUxQ,44900
193
- unique_toolkit-1.31.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
194
- unique_toolkit-1.31.2.dist-info/RECORD,,
192
+ unique_toolkit-1.32.0.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
193
+ unique_toolkit-1.32.0.dist-info/METADATA,sha256=w-7NUQhIQPdLlOXStTABVEF5PrLskDfNucF5zLc7yeM,44978
194
+ unique_toolkit-1.32.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
195
+ unique_toolkit-1.32.0.dist-info/RECORD,,