unique_toolkit 1.31.2__py3-none-any.whl → 1.32.1__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
@@ -263,7 +263,7 @@ def create_message(
263
263
  references: list[ContentReference] | None = None,
264
264
  debug_info: dict | None = None,
265
265
  set_completed_at: bool | None = False,
266
- ):
266
+ ) -> ChatMessage:
267
267
  """Creates a message in the chat session synchronously.
268
268
 
269
269
  Args:
@@ -144,7 +144,7 @@ def search_contents(
144
144
  chat_id: str,
145
145
  where: dict,
146
146
  include_failed_content: bool = False,
147
- ):
147
+ ) -> list[Content]:
148
148
  """
149
149
  Performs an asynchronous search for content files in the knowledge base by filter.
150
150
 
@@ -297,7 +297,7 @@ def upload_content_from_bytes(
297
297
  skip_ingestion: bool = False,
298
298
  ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
299
299
  metadata: dict[str, Any] | None = None,
300
- ):
300
+ ) -> Content:
301
301
  """
302
302
  Uploads content to the knowledge base.
303
303
 
@@ -347,7 +347,7 @@ def upload_content(
347
347
  skip_excel_ingestion: bool = False,
348
348
  ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
349
349
  metadata: dict[str, Any] | None = None,
350
- ):
350
+ ) -> Content:
351
351
  """
352
352
  Uploads content to the knowledge base.
353
353
 
@@ -399,7 +399,7 @@ def _trigger_upload_content(
399
399
  skip_excel_ingestion: bool = False,
400
400
  ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
401
401
  metadata: dict[str, Any] | None = None,
402
- ):
402
+ ) -> Content:
403
403
  """
404
404
  Uploads content to the knowledge base.
405
405
 
@@ -528,7 +528,7 @@ class ContentService:
528
528
  skip_excel_ingestion: bool = False,
529
529
  ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
530
530
  metadata: dict[str, Any] | None = None,
531
- ):
531
+ ) -> Content:
532
532
  """
533
533
  Uploads content to the knowledge base.
534
534
 
@@ -145,7 +145,7 @@ class EmbeddingService(BaseService):
145
145
  Embed text.
146
146
 
147
147
  Args:
148
- text (str): The text to embed.
148
+ texts (list[str]): The texts to embed.
149
149
  timeout (int): The timeout in milliseconds. Defaults to 600000.
150
150
 
151
151
  Returns:
@@ -0,0 +1,10 @@
1
+ """Langchain framework utilities."""
2
+
3
+ try:
4
+ from .client import LangchainNotInstalledError, get_langchain_client
5
+
6
+ __all__ = ["get_langchain_client", "LangchainNotInstalledError"]
7
+ except (ImportError, Exception):
8
+ # If langchain is not installed, don't export anything
9
+ # This handles both ImportError and LangchainNotInstalledError
10
+ __all__ = []
@@ -30,7 +30,8 @@ def get_openai_client(
30
30
  """Get an OpenAI client instance.
31
31
 
32
32
  Args:
33
- env_file: Optional path to environment file
33
+ unique_settings (UniqueSettings | None): Optional UniqueSettings instance
34
+ additional_headers (dict[str, str] | None): Optional additional headers to add to the request
34
35
 
35
36
  Returns:
36
37
  OpenAI client instance
@@ -377,7 +377,6 @@ class KnowledgeBaseService:
377
377
  mime_type (str): The MIME type of the content.
378
378
  scope_id (str | None): The scope ID. Defaults to None.
379
379
  skip_ingestion (bool): Whether to skip ingestion. Defaults to False.
380
- skip_excel_ingestion (bool): Whether to skip excel ingestion. Defaults to False.
381
380
  ingestion_config (unique_sdk.Content.IngestionConfig | None): The ingestion configuration. Defaults to None.
382
381
  metadata (dict | None): The metadata to associate with the content. Defaults to None.
383
382
 
@@ -449,7 +448,7 @@ class KnowledgeBaseService:
449
448
  skip_excel_ingestion: bool = False,
450
449
  ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
451
450
  metadata: dict[str, Any] | None = None,
452
- ):
451
+ ) -> Content:
453
452
  """
454
453
  Uploads content to the knowledge base.
455
454
 
@@ -487,14 +486,14 @@ class KnowledgeBaseService:
487
486
  content_id: str,
488
487
  output_dir_path: Path | None = None,
489
488
  output_filename: str | None = None,
490
- ):
489
+ ) -> Path:
491
490
  """
492
491
  Downloads content from a chat and saves it to a file.
493
492
 
494
493
  Args:
495
494
  content_id (str): The ID of the content to download.
496
- filename (str | None): The name of the file to save the content as. If not provided, the original filename will be used. Defaults to None.
497
- tmp_dir_path (str | Path | None): The path to the temporary directory where the content will be saved. Defaults to "/tmp".
495
+ output_filename (str | None): The name of the file to save the content as. If not provided, the original filename will be used. Defaults to None.
496
+ output_dir_path (str | Path | None): The path to the temporary directory where the content will be saved. Defaults to "/tmp".
498
497
 
499
498
  Returns:
500
499
  Path: The path to the downloaded file.
@@ -522,7 +521,6 @@ class KnowledgeBaseService:
522
521
 
523
522
  Args:
524
523
  content_id (str): The id of the uploaded content.
525
- chat_id (Optional[str]): The chat_id, defaults to None.
526
524
 
527
525
  Returns:
528
526
  bytes: The downloaded content.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.31.2
3
+ Version: 1.32.1
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Cedric Klinkert
@@ -121,6 +121,12 @@ 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.1] - 2025-12-01
125
+ - Added documentation for the toolkit,some missing type hints and doc string fixes.
126
+
127
+ ## [1.32.0] - 2025-11-28
128
+ - Add option to filter duplicate sub agent answers.
129
+
124
130
  ## [1.31.2] - 2025-11-27
125
131
  - 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
132
 
@@ -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
@@ -137,7 +138,7 @@ unique_toolkit/app/webhook.py,sha256=k7DP1UTR3p7D4qzuKPKVmGMAkDVHfALrnMIzTZqj_OI
137
138
  unique_toolkit/chat/__init__.py,sha256=uP7P6YPeOjEOvpX3bhcU6ND_m0QLr4wMklcrnAKK0q4,804
138
139
  unique_toolkit/chat/constants.py,sha256=05kq6zjqUVB2d6_P7s-90nbljpB3ryxwCI-CAz0r2O4,83
139
140
  unique_toolkit/chat/deprecated/service.py,sha256=CYwzXi7OB0RjHd73CO2jq8SlpdBmDYLatzPFkb5sA0k,6529
140
- unique_toolkit/chat/functions.py,sha256=C7Iioow2RtiKNfU9isar83DYlwYxKtdp2hp7lajXUvw,46063
141
+ unique_toolkit/chat/functions.py,sha256=VdehD26NnsEhBsV5tD0tgUFD4LuEqIYgz8wWSsxYFgA,46078
141
142
  unique_toolkit/chat/rendering.py,sha256=c8YiV9oADRrJQ5A_QBJ4_UFc0NZ-2vVaa7tupoMusso,880
142
143
  unique_toolkit/chat/responses_api.py,sha256=MCI1MR_4wlo9xY1ifH2daNz4JvjX18uPdryQlemaeLw,14488
143
144
  unique_toolkit/chat/schemas.py,sha256=Zh903Xt9PhugvwzoC_k5KXQWAqllWHvj9DmqDVom2yk,6890
@@ -146,22 +147,23 @@ unique_toolkit/chat/state.py,sha256=Cjgwv_2vhDFbV69xxsn7SefhaoIAEqLx3ferdVFCnOg,
146
147
  unique_toolkit/chat/utils.py,sha256=ihm-wQykBWhB4liR3LnwPVPt_qGW6ETq21Mw4HY0THE,854
147
148
  unique_toolkit/content/__init__.py,sha256=EdJg_A_7loEtCQf4cah3QARQreJx6pdz89Rm96YbMVg,940
148
149
  unique_toolkit/content/constants.py,sha256=1iy4Y67xobl5VTnJB6SxSyuoBWbdLl9244xfVMUZi5o,60
149
- unique_toolkit/content/functions.py,sha256=uD9XHkKOD28N-4OLFjcbs6NOdKNSGFBUob79UnGrE0k,28780
150
+ unique_toolkit/content/functions.py,sha256=6QIgPTShF7VF9N55Np5vIlNibbDgPDfJr4-IBaRu6n8,28830
150
151
  unique_toolkit/content/schemas.py,sha256=uuS1UsuWK6eC7cP4dTC1q3DJ39xl6zenN2zL4ghFmzk,6424
151
- unique_toolkit/content/service.py,sha256=hyD-CdlQLvddbcZMC7fbitdcSVauoyxec21q1auXW24,24908
152
+ unique_toolkit/content/service.py,sha256=hwycIbxtLn1p0IgNQMVIxN2NUhy_4AVsTfatytGi-gY,24919
152
153
  unique_toolkit/content/smart_rules.py,sha256=z2gHToPrdyj3HqO8Uu-JE5G2ClvJPuhR2XERmmkgoug,9668
153
154
  unique_toolkit/content/utils.py,sha256=ITCJHDIXPyFkxR5M-6k-PhHOLbnkVq_RGrGA7i5s1WM,8001
154
155
  unique_toolkit/embedding/__init__.py,sha256=uUyzjonPvuDCYsvXCIt7ErQXopLggpzX-MEQd3_e2kE,250
155
156
  unique_toolkit/embedding/constants.py,sha256=Lj8-Lcy1FvuC31PM9Exq7vaFuxQV4pEI1huUMFX-J2M,52
156
157
  unique_toolkit/embedding/functions.py,sha256=3qp-BfuMAbnp8YB04rh3xH8vsJuCBPizoy-JeaBFtoQ,1944
157
158
  unique_toolkit/embedding/schemas.py,sha256=1GvKCaSk4jixzVQ2PKq8yDqwGEVY_hWclYtoAr6CC2g,96
158
- unique_toolkit/embedding/service.py,sha256=XViQCbjG9yznIFhWjgBkla_4s7zM2lO12Cqob1FU63k,5283
159
+ unique_toolkit/embedding/service.py,sha256=Ujx4oy7yUsQot8ATyl-3x88LulgruPThEteSArwzuvA,5291
159
160
  unique_toolkit/embedding/utils.py,sha256=v86lo__bCJbxZBQ3OcLu5SuwT6NbFfWlcq8iyk6BuzQ,279
160
161
  unique_toolkit/framework_utilities/__init__.py,sha256=fvAn9y4MRL1JgoO14ufQtLVRPRHn4jP07XRqt-TItCA,68
162
+ unique_toolkit/framework_utilities/langchain/__init__.py,sha256=CjihxDQqCeSiRpTy1fSRRMYXXKrvhNuChR8nbUlIm84,362
161
163
  unique_toolkit/framework_utilities/langchain/client.py,sha256=NxOLnzVMJds2JFMm5ZqTVTz1WMntrmWsLPU4-kwlzqE,2065
162
164
  unique_toolkit/framework_utilities/langchain/history.py,sha256=R9RuCeSFNaUO3OZ0G_LmIC4gmOCIANcl91MfyWLnZ1c,650
163
165
  unique_toolkit/framework_utilities/openai/__init__.py,sha256=CrHYoC7lv2pBscitLerAFweqy5jh1R671LA_jZQ4lWo,232
164
- unique_toolkit/framework_utilities/openai/client.py,sha256=XtueQu_4tiar7lvu0yOOgU5QSE_cIXGiiHStesW4iXs,2317
166
+ unique_toolkit/framework_utilities/openai/client.py,sha256=Ft-oLiS6BTaMiuCdObdaMLkiEPE3RYjtc9VPnqQk3W8,2449
165
167
  unique_toolkit/framework_utilities/openai/message_builder.py,sha256=RT1pZjxH42TFZlAxQ5zlqdKPvHKVTjc5t3JDUy58I7Q,6887
166
168
  unique_toolkit/framework_utilities/utils.py,sha256=JK7g2yMfEx3eMprug26769xqNpS5WJcizf8n2zWMBng,789
167
169
  unique_toolkit/language_model/__init__.py,sha256=lRQyLlbwHbNFf4-0foBU13UGb09lwEeodbVsfsSgaCk,1971
@@ -179,7 +181,7 @@ unique_toolkit/language_model/utils.py,sha256=bPQ4l6_YO71w-zaIPanUUmtbXC1_hCvLK0
179
181
  unique_toolkit/protocols/support.py,sha256=ZEnbQL5w2-T_1AeM8OHycZJ3qbdfVI1nXe0nL9esQEw,5544
180
182
  unique_toolkit/services/__init__.py,sha256=90-IT5FjMcnlqxjp5kme9Fqgp_on46rggctIqHMdqsw,195
181
183
  unique_toolkit/services/chat_service.py,sha256=vA97DO4iiXMeTNuwS-bYeskX0DnqO_8vL4yzJAfsGtw,60111
182
- unique_toolkit/services/knowledge_base.py,sha256=PhXqDkHvePY-dW_XRirONy--0naOEnZG6gVyBR6KFBY,36342
184
+ unique_toolkit/services/knowledge_base.py,sha256=uc89GL_NZXeFkJKkdHSSh2y1Wx0tmgasWk6uyGi4G_M,36210
183
185
  unique_toolkit/short_term_memory/__init__.py,sha256=2mI3AUrffgH7Yt-xS57EGqnHf7jnn6xquoKEhJqk3Wg,185
184
186
  unique_toolkit/short_term_memory/constants.py,sha256=698CL6-wjup2MvU19RxSmQk3gX7aqW_OOpZB7sbz_Xg,34
185
187
  unique_toolkit/short_term_memory/functions.py,sha256=3WiK-xatY5nh4Dr5zlDUye1k3E6kr41RiscwtTplw5k,4484
@@ -188,7 +190,7 @@ unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBu
188
190
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
189
191
  unique_toolkit/smart_rules/compile.py,sha256=Ozhh70qCn2yOzRWr9d8WmJeTo7AQurwd3tStgBMPFLA,1246
190
192
  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,,
193
+ unique_toolkit-1.32.1.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
194
+ unique_toolkit-1.32.1.dist-info/METADATA,sha256=ar50wEkaXnpqXhlgJHki4YZIJG6SQFjzXMcfH4w-_W0,45088
195
+ unique_toolkit-1.32.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
196
+ unique_toolkit-1.32.1.dist-info/RECORD,,