unique_toolkit 1.29.4__py3-none-any.whl → 1.31.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.
@@ -1,5 +1,5 @@
1
1
  import re
2
- from typing import Literal
2
+ from typing import Literal, NamedTuple
3
3
 
4
4
  from unique_toolkit.agentic.tools.a2a.postprocessing.config import (
5
5
  SubAgentDisplayConfig,
@@ -126,7 +126,7 @@ def _get_display_template(
126
126
  if add_block_border:
127
127
  template = _wrap_with_block_border(template)
128
128
 
129
- return template
129
+ return template.strip()
130
130
 
131
131
 
132
132
  def _get_display_removal_re(
@@ -150,10 +150,40 @@ def _get_display_removal_re(
150
150
  return re.compile(pattern, flags=re.DOTALL)
151
151
 
152
152
 
153
+ class SubAgentAnswerPart(NamedTuple):
154
+ matching_text: str # Matching text as found in the answer
155
+ formatted_text: str # Formatted text to be displayed
156
+
157
+
158
+ def get_sub_agent_answer_parts(
159
+ answer: str,
160
+ display_config: SubAgentDisplayConfig,
161
+ ) -> list[SubAgentAnswerPart]:
162
+ if display_config.mode == SubAgentResponseDisplayMode.HIDDEN:
163
+ return []
164
+
165
+ if len(display_config.answer_substrings_config) == 0:
166
+ return [SubAgentAnswerPart(matching_text=answer, formatted_text=answer)]
167
+
168
+ substrings = []
169
+ for config in display_config.answer_substrings_config:
170
+ match = re.search(config.regexp, answer)
171
+ if match is not None:
172
+ text = match.group(0)
173
+ substrings.append(
174
+ SubAgentAnswerPart(
175
+ matching_text=text,
176
+ formatted_text=config.display_template.format(text),
177
+ )
178
+ )
179
+
180
+ return substrings
181
+
182
+
153
183
  def get_sub_agent_answer_display(
154
184
  display_name: str,
155
185
  display_config: SubAgentDisplayConfig,
156
- answer: str,
186
+ answer: str | list[str],
157
187
  assistant_id: str,
158
188
  ) -> str:
159
189
  template = _get_display_template(
@@ -162,6 +192,10 @@ def get_sub_agent_answer_display(
162
192
  add_block_border=display_config.add_block_border,
163
193
  display_title_template=display_config.display_title_template,
164
194
  )
195
+
196
+ if isinstance(answer, list):
197
+ answer = display_config.answer_substrings_separator.join(answer)
198
+
165
199
  return template.format(
166
200
  display_name=display_name, answer=answer, assistant_id=assistant_id
167
201
  )
@@ -13,6 +13,18 @@ class SubAgentResponseDisplayMode(StrEnum):
13
13
  PLAIN = "plain"
14
14
 
15
15
 
16
+ class SubAgentAnswerSubstringConfig(BaseModel):
17
+ model_config = get_configuration_dict()
18
+
19
+ regexp: str = Field(
20
+ description="The regular expression to use to extract the substring. The first capture group will always be used.",
21
+ )
22
+ display_template: str = Field(
23
+ default="{}",
24
+ description="The template to use to display the substring. It should contain exactly one empty placeholder '{}' for the substring.",
25
+ )
26
+
27
+
16
28
  class SubAgentDisplayConfig(BaseModel):
17
29
  model_config = get_configuration_dict()
18
30
 
@@ -47,3 +59,12 @@ class SubAgentDisplayConfig(BaseModel):
47
59
  default=False,
48
60
  description="If set, the sub agent references will be added to the main agent response references even in not mentioned in the main agent response text.",
49
61
  )
62
+
63
+ answer_substrings_config: list[SubAgentAnswerSubstringConfig] = Field(
64
+ default=[],
65
+ description="If set, only parts of the answer matching the provided regular expressions will be displayed.",
66
+ )
67
+ answer_substrings_separator: str = Field(
68
+ default="\n",
69
+ description="The separator to use between the substrings.",
70
+ )
@@ -10,6 +10,7 @@ from unique_toolkit._common.pydantic_helpers import get_configuration_dict
10
10
  from unique_toolkit.agentic.postprocessor.postprocessor_manager import Postprocessor
11
11
  from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
12
12
  get_sub_agent_answer_display,
13
+ get_sub_agent_answer_parts,
13
14
  remove_sub_agent_answer_from_text,
14
15
  )
15
16
  from unique_toolkit.agentic.tools.a2a.postprocessing._ref_utils import (
@@ -44,6 +45,15 @@ class SubAgentResponsesPostprocessorConfig(BaseModel):
44
45
  default=1, description="Time to sleep before updating the main agent message."
45
46
  )
46
47
 
48
+ remove_duplicate_answers: bool = Field(
49
+ default=False,
50
+ description="If set, duplicate answers will only be displayed once. If sub agent is configured to display only substrings, this will remove duplicate substrings across different responses.",
51
+ )
52
+ answer_separator: str = Field(
53
+ default="",
54
+ description="The separator to use between the different sub agent answers.",
55
+ )
56
+
47
57
 
48
58
  class SubAgentResponsesDisplayPostprocessor(Postprocessor):
49
59
  def __init__(
@@ -96,6 +106,8 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
96
106
  answers_displayed_before = []
97
107
  answers_displayed_after = []
98
108
 
109
+ all_answers_displayed = set()
110
+
99
111
  for assistant_id, responses in displayed_sub_agent_responses.items():
100
112
  for response in responses:
101
113
  message = response.message
@@ -105,6 +117,9 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
105
117
  loop_response=loop_response, response=message
106
118
  )
107
119
 
120
+ if tool_info.display_config.mode == SubAgentResponseDisplayMode.HIDDEN:
121
+ continue
122
+
108
123
  display_name = tool_info.display_name
109
124
  if len(responses) > 1:
110
125
  display_name += f" {response.sequence_number}"
@@ -116,13 +131,33 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
116
131
  response.sequence_number,
117
132
  )
118
133
 
119
- if tool_info.display_config.mode == SubAgentResponseDisplayMode.HIDDEN:
134
+ message_text = message["text"] or ""
135
+
136
+ answer_parts = get_sub_agent_answer_parts(
137
+ answer=message_text,
138
+ display_config=tool_info.display_config,
139
+ )
140
+
141
+ if len(answer_parts) == 0:
120
142
  continue
121
143
 
144
+ answer_display_texts = []
145
+ if self._config.remove_duplicate_answers:
146
+ for answer_part in answer_parts:
147
+ if answer_part.matching_text in all_answers_displayed:
148
+ continue
149
+
150
+ all_answers_displayed.add(answer_part.matching_text)
151
+ answer_display_texts.append(answer_part.formatted_text)
152
+ else:
153
+ answer_display_texts = [
154
+ answer_part.formatted_text for answer_part in answer_parts
155
+ ]
156
+
122
157
  answer = get_sub_agent_answer_display(
123
158
  display_name=display_name,
124
159
  display_config=tool_info.display_config,
125
- answer=message["text"] or "",
160
+ answer=answer_display_texts,
126
161
  assistant_id=assistant_id,
127
162
  )
128
163
 
@@ -135,6 +170,7 @@ class SubAgentResponsesDisplayPostprocessor(Postprocessor):
135
170
  text=loop_response.message.text,
136
171
  answers_before=answers_displayed_before,
137
172
  answers_after=answers_displayed_after,
173
+ sep=self._config.answer_separator,
138
174
  )
139
175
 
140
176
  return True
@@ -182,4 +218,5 @@ def _get_final_answer_display(
182
218
 
183
219
  if len(answers_after) > 0:
184
220
  text = text + sep + sep.join(answers_after)
185
- return text
221
+
222
+ return text.strip()
@@ -16,9 +16,11 @@ from unique_toolkit.agentic.tools.a2a.postprocessing._display_utils import (
16
16
  _wrap_with_details_tag,
17
17
  _wrap_with_quote_border,
18
18
  get_sub_agent_answer_display,
19
+ get_sub_agent_answer_parts,
19
20
  remove_sub_agent_answer_from_text,
20
21
  )
21
22
  from unique_toolkit.agentic.tools.a2a.postprocessing.config import (
23
+ SubAgentAnswerSubstringConfig,
22
24
  SubAgentDisplayConfig,
23
25
  SubAgentResponseDisplayMode,
24
26
  )
@@ -1333,3 +1335,389 @@ def test_remove_sub_agent_answer__no_op_when_assistant_not_found() -> None:
1333
1335
  assert result == original_text
1334
1336
  assert "Present answer" in result
1335
1337
  assert "Present Agent" in result
1338
+
1339
+
1340
+ # Test get_sub_agent_answer_parts
1341
+
1342
+
1343
+ @pytest.mark.ai
1344
+ def test_get_sub_agent_answer_parts__returns_empty__when_hidden_mode() -> None:
1345
+ """
1346
+ Purpose: Verify empty list returned for HIDDEN display mode.
1347
+ Why this matters: Hidden mode should not extract any answer parts.
1348
+ Setup summary: Set mode to HIDDEN, assert empty list.
1349
+ """
1350
+ # Arrange
1351
+ answer = "Some answer text"
1352
+ config = SubAgentDisplayConfig(
1353
+ mode=SubAgentResponseDisplayMode.HIDDEN,
1354
+ )
1355
+
1356
+ # Act
1357
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1358
+
1359
+ # Assert
1360
+ assert result == []
1361
+
1362
+
1363
+ @pytest.mark.ai
1364
+ def test_get_sub_agent_answer_parts__returns_full_answer__when_no_config() -> None:
1365
+ """
1366
+ Purpose: Verify full answer returned when no substring config provided.
1367
+ Why this matters: Default behavior should return entire answer.
1368
+ Setup summary: Provide answer without substring config, assert full answer.
1369
+ """
1370
+ # Arrange
1371
+ answer = "This is the complete answer"
1372
+ config = SubAgentDisplayConfig(
1373
+ mode=SubAgentResponseDisplayMode.PLAIN,
1374
+ answer_substrings_config=[],
1375
+ )
1376
+
1377
+ # Act
1378
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1379
+
1380
+ # Assert
1381
+ assert len(result) == 1
1382
+ assert result[0].matching_text == answer
1383
+ assert result[0].formatted_text == answer
1384
+
1385
+
1386
+ @pytest.mark.ai
1387
+ def test_get_sub_agent_answer_parts__extracts_single_match__with_one_regexp() -> None:
1388
+ """
1389
+ Purpose: Verify single substring extracted with one regexp config.
1390
+ Why this matters: Core functionality for extracting specific answer parts.
1391
+ Setup summary: Provide answer with single regexp config, assert match extracted.
1392
+ """
1393
+ # Arrange
1394
+ answer = "The price is $42.99 for the item"
1395
+ config = SubAgentDisplayConfig(
1396
+ mode=SubAgentResponseDisplayMode.PLAIN,
1397
+ answer_substrings_config=[
1398
+ SubAgentAnswerSubstringConfig(regexp=r"\$\d+\.\d+"),
1399
+ ],
1400
+ )
1401
+
1402
+ # Act
1403
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1404
+
1405
+ # Assert
1406
+ assert len(result) == 1
1407
+ assert result[0].matching_text == "$42.99"
1408
+ assert result[0].formatted_text == "$42.99"
1409
+
1410
+
1411
+ @pytest.mark.ai
1412
+ def test_get_sub_agent_answer_parts__extracts_multiple_matches__with_multiple_regexps() -> (
1413
+ None
1414
+ ):
1415
+ """
1416
+ Purpose: Verify multiple substrings extracted with multiple regexp configs.
1417
+ Why this matters: Supports extracting different types of information.
1418
+ Setup summary: Provide answer with multiple regexp configs, assert all matches.
1419
+ """
1420
+ # Arrange
1421
+ answer = "Contact John at john@example.com or call 555-1234"
1422
+ config = SubAgentDisplayConfig(
1423
+ mode=SubAgentResponseDisplayMode.PLAIN,
1424
+ answer_substrings_config=[
1425
+ SubAgentAnswerSubstringConfig(
1426
+ regexp=r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
1427
+ ),
1428
+ SubAgentAnswerSubstringConfig(regexp=r"\d{3}-\d{4}"),
1429
+ ],
1430
+ )
1431
+
1432
+ # Act
1433
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1434
+
1435
+ # Assert
1436
+ assert len(result) == 2
1437
+ assert result[0].matching_text == "john@example.com"
1438
+ assert result[1].matching_text == "555-1234"
1439
+
1440
+
1441
+ @pytest.mark.ai
1442
+ def test_get_sub_agent_answer_parts__applies_display_template__to_matched_text() -> (
1443
+ None
1444
+ ):
1445
+ """
1446
+ Purpose: Verify display template is applied to format matched text.
1447
+ Why this matters: Allows customization of how extracted parts are displayed.
1448
+ Setup summary: Provide template with placeholder, assert formatted output.
1449
+ """
1450
+ # Arrange
1451
+ answer = "The temperature is 72 degrees"
1452
+ config = SubAgentDisplayConfig(
1453
+ mode=SubAgentResponseDisplayMode.PLAIN,
1454
+ answer_substrings_config=[
1455
+ SubAgentAnswerSubstringConfig(
1456
+ regexp=r"\d+",
1457
+ display_template="Temperature: {}°F",
1458
+ ),
1459
+ ],
1460
+ )
1461
+
1462
+ # Act
1463
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1464
+
1465
+ # Assert
1466
+ assert len(result) == 1
1467
+ assert result[0].matching_text == "72"
1468
+ assert result[0].formatted_text == "Temperature: 72°F"
1469
+
1470
+
1471
+ @pytest.mark.ai
1472
+ def test_get_sub_agent_answer_parts__returns_empty_list__when_no_matches() -> None:
1473
+ """
1474
+ Purpose: Verify empty list returned when regexp doesn't match answer.
1475
+ Why this matters: Handles cases where expected pattern not present.
1476
+ Setup summary: Provide regexp that doesn't match, assert empty list.
1477
+ """
1478
+ # Arrange
1479
+ answer = "This is plain text without numbers"
1480
+ config = SubAgentDisplayConfig(
1481
+ mode=SubAgentResponseDisplayMode.PLAIN,
1482
+ answer_substrings_config=[
1483
+ SubAgentAnswerSubstringConfig(regexp=r"\d+"),
1484
+ ],
1485
+ )
1486
+
1487
+ # Act
1488
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1489
+
1490
+ # Assert
1491
+ assert result == []
1492
+
1493
+
1494
+ @pytest.mark.ai
1495
+ def test_get_sub_agent_answer_parts__extracts_first_match_only__for_each_regexp() -> (
1496
+ None
1497
+ ):
1498
+ """
1499
+ Purpose: Verify only first match per regexp is extracted.
1500
+ Why this matters: Function uses re.search which finds first occurrence.
1501
+ Setup summary: Provide answer with multiple numbers, assert only first extracted.
1502
+ """
1503
+ # Arrange
1504
+ answer = "First number is 42 and second is 99"
1505
+ config = SubAgentDisplayConfig(
1506
+ mode=SubAgentResponseDisplayMode.PLAIN,
1507
+ answer_substrings_config=[
1508
+ SubAgentAnswerSubstringConfig(regexp=r"\d+"),
1509
+ ],
1510
+ )
1511
+
1512
+ # Act
1513
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1514
+
1515
+ # Assert
1516
+ assert len(result) == 1
1517
+ assert result[0].matching_text == "42"
1518
+
1519
+
1520
+ @pytest.mark.ai
1521
+ def test_get_sub_agent_answer_parts__handles_empty_answer__with_no_config() -> None:
1522
+ """
1523
+ Purpose: Verify empty answer returned as single part when no config.
1524
+ Why this matters: Edge case handling for empty content.
1525
+ Setup summary: Provide empty answer, assert single empty part.
1526
+ """
1527
+ # Arrange
1528
+ answer = ""
1529
+ config = SubAgentDisplayConfig(
1530
+ mode=SubAgentResponseDisplayMode.PLAIN,
1531
+ answer_substrings_config=[],
1532
+ )
1533
+
1534
+ # Act
1535
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1536
+
1537
+ # Assert
1538
+ assert len(result) == 1
1539
+ assert result[0].matching_text == ""
1540
+ assert result[0].formatted_text == ""
1541
+
1542
+
1543
+ @pytest.mark.ai
1544
+ def test_get_sub_agent_answer_parts__handles_empty_answer__with_regexp_config() -> None:
1545
+ """
1546
+ Purpose: Verify empty list returned for empty answer with regexp config.
1547
+ Why this matters: No matches possible in empty string.
1548
+ Setup summary: Provide empty answer with regexp, assert empty list.
1549
+ """
1550
+ # Arrange
1551
+ answer = ""
1552
+ config = SubAgentDisplayConfig(
1553
+ mode=SubAgentResponseDisplayMode.PLAIN,
1554
+ answer_substrings_config=[
1555
+ SubAgentAnswerSubstringConfig(regexp=r"\d+"),
1556
+ ],
1557
+ )
1558
+
1559
+ # Act
1560
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1561
+
1562
+ # Assert
1563
+ assert result == []
1564
+
1565
+
1566
+ @pytest.mark.ai
1567
+ def test_get_sub_agent_answer_parts__handles_multiline_answer__with_regexp() -> None:
1568
+ """
1569
+ Purpose: Verify regexp matching works across multiple lines.
1570
+ Why this matters: Answers can span multiple lines.
1571
+ Setup summary: Provide multiline answer with pattern, assert match found.
1572
+ """
1573
+ # Arrange
1574
+ answer = "Line 1\nThe code is ABC123\nLine 3"
1575
+ config = SubAgentDisplayConfig(
1576
+ mode=SubAgentResponseDisplayMode.PLAIN,
1577
+ answer_substrings_config=[
1578
+ SubAgentAnswerSubstringConfig(regexp=r"[A-Z]{3}\d{3}"),
1579
+ ],
1580
+ )
1581
+
1582
+ # Act
1583
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1584
+
1585
+ # Assert
1586
+ assert len(result) == 1
1587
+ assert result[0].matching_text == "ABC123"
1588
+
1589
+
1590
+ @pytest.mark.ai
1591
+ def test_get_sub_agent_answer_parts__handles_special_regex_chars__in_answer() -> None:
1592
+ """
1593
+ Purpose: Verify regexp can match content with special regex characters.
1594
+ Why this matters: Answers may contain special characters.
1595
+ Setup summary: Provide answer with special chars, use proper escaping in regexp.
1596
+ """
1597
+ # Arrange
1598
+ answer = "The expression is: [test] (value)"
1599
+ config = SubAgentDisplayConfig(
1600
+ mode=SubAgentResponseDisplayMode.PLAIN,
1601
+ answer_substrings_config=[
1602
+ SubAgentAnswerSubstringConfig(regexp=r"\[test\]"),
1603
+ ],
1604
+ )
1605
+
1606
+ # Act
1607
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1608
+
1609
+ # Assert
1610
+ assert len(result) == 1
1611
+ assert result[0].matching_text == "[test]"
1612
+
1613
+
1614
+ @pytest.mark.ai
1615
+ def test_get_sub_agent_answer_parts__skips_non_matching_configs__returns_matches_only() -> (
1616
+ None
1617
+ ):
1618
+ """
1619
+ Purpose: Verify only matching regexp configs produce results.
1620
+ Why this matters: Should not fail on partial matches, only return what matches.
1621
+ Setup summary: Provide multiple configs where only some match, assert partial results.
1622
+ """
1623
+ # Arrange
1624
+ answer = "Value is 42"
1625
+ config = SubAgentDisplayConfig(
1626
+ mode=SubAgentResponseDisplayMode.PLAIN,
1627
+ answer_substrings_config=[
1628
+ SubAgentAnswerSubstringConfig(regexp=r"\d+"), # Matches
1629
+ SubAgentAnswerSubstringConfig(regexp=r"[A-Z]{3}"), # Doesn't match
1630
+ SubAgentAnswerSubstringConfig(regexp=r"Value"), # Matches
1631
+ ],
1632
+ )
1633
+
1634
+ # Act
1635
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1636
+
1637
+ # Assert
1638
+ assert len(result) == 2
1639
+ assert result[0].matching_text == "42"
1640
+ assert result[1].matching_text == "Value"
1641
+
1642
+
1643
+ @pytest.mark.ai
1644
+ def test_get_sub_agent_answer_parts__preserves_order__of_configs_not_matches() -> None:
1645
+ """
1646
+ Purpose: Verify results follow config order, not match order in text.
1647
+ Why this matters: Predictable output order based on configuration.
1648
+ Setup summary: Provide configs in specific order, assert results match config order.
1649
+ """
1650
+ # Arrange
1651
+ answer = "first 123 then abc"
1652
+ config = SubAgentDisplayConfig(
1653
+ mode=SubAgentResponseDisplayMode.PLAIN,
1654
+ answer_substrings_config=[
1655
+ SubAgentAnswerSubstringConfig(regexp=r"[a-z]{3,}"), # Matches "first"
1656
+ SubAgentAnswerSubstringConfig(regexp=r"\d+"), # Matches "123"
1657
+ ],
1658
+ )
1659
+
1660
+ # Act
1661
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1662
+
1663
+ # Assert
1664
+ assert len(result) == 2
1665
+ # Results follow config order, not text order
1666
+ assert result[0].matching_text == "first"
1667
+ assert result[1].matching_text == "123"
1668
+
1669
+
1670
+ @pytest.mark.ai
1671
+ def test_get_sub_agent_answer_parts__handles_complex_template__with_multiple_placeholders() -> (
1672
+ None
1673
+ ):
1674
+ """
1675
+ Purpose: Verify complex display templates with formatting work correctly.
1676
+ Why this matters: Supports rich formatting of extracted content.
1677
+ Setup summary: Provide template with additional text, assert formatted correctly.
1678
+ """
1679
+ # Arrange
1680
+ answer = "User score: 95"
1681
+ config = SubAgentDisplayConfig(
1682
+ mode=SubAgentResponseDisplayMode.PLAIN,
1683
+ answer_substrings_config=[
1684
+ SubAgentAnswerSubstringConfig(
1685
+ regexp=r"\d+",
1686
+ display_template="**Score: {}%**",
1687
+ ),
1688
+ ],
1689
+ )
1690
+
1691
+ # Act
1692
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1693
+
1694
+ # Assert
1695
+ assert len(result) == 1
1696
+ assert result[0].matching_text == "95"
1697
+ assert result[0].formatted_text == "**Score: 95%**"
1698
+
1699
+
1700
+ @pytest.mark.ai
1701
+ def test_get_sub_agent_answer_parts__works_with_details_modes__extracts_normally() -> (
1702
+ None
1703
+ ):
1704
+ """
1705
+ Purpose: Verify extraction works regardless of display mode (except HIDDEN).
1706
+ Why this matters: Substring extraction independent of display mode.
1707
+ Setup summary: Use DETAILS modes, assert extraction still works.
1708
+ """
1709
+ # Arrange
1710
+ answer = "Result: SUCCESS"
1711
+ config = SubAgentDisplayConfig(
1712
+ mode=SubAgentResponseDisplayMode.DETAILS_CLOSED,
1713
+ answer_substrings_config=[
1714
+ SubAgentAnswerSubstringConfig(regexp=r"SUCCESS"),
1715
+ ],
1716
+ )
1717
+
1718
+ # Act
1719
+ result = get_sub_agent_answer_parts(answer=answer, display_config=config)
1720
+
1721
+ # Assert
1722
+ assert len(result) == 1
1723
+ assert result[0].matching_text == "SUCCESS"
@@ -47,5 +47,8 @@ from .schemas import (
47
47
  from .verification import (
48
48
  verify_signature_and_construct_event as verify_signature_and_construct_event,
49
49
  )
50
+ from .webhook import (
51
+ is_webhook_signature_valid as is_webhook_signature_valid,
52
+ )
50
53
 
51
54
  DOMAIN_NAME = "app"
@@ -0,0 +1,131 @@
1
+ import json
2
+ from logging import getLogger
3
+ from typing import TYPE_CHECKING, Any, Callable, TypeVar
4
+
5
+ from pydantic import ValidationError
6
+
7
+ if TYPE_CHECKING:
8
+ from fastapi import BackgroundTasks, FastAPI, Request, status
9
+ from fastapi.responses import JSONResponse
10
+ else:
11
+ try:
12
+ from fastapi import BackgroundTasks, FastAPI, Request, status
13
+ from fastapi.responses import JSONResponse
14
+ except ImportError:
15
+ FastAPI = None # type: ignore[assignment, misc]
16
+ Request = None # type: ignore[assignment, misc]
17
+ status = None # type: ignore[assignment, misc]
18
+ JSONResponse = None # type: ignore[assignment, misc]
19
+ BackgroundTasks = None # type: ignore[assignment, misc]
20
+
21
+ from unique_toolkit.app.schemas import BaseEvent, ChatEvent, EventName
22
+ from unique_toolkit.app.unique_settings import UniqueSettings
23
+
24
+ logger = getLogger(__name__)
25
+
26
+
27
+ def default_event_handler(event: Any) -> int:
28
+ logger.info("Event received at event handler")
29
+ if status is not None:
30
+ return status.HTTP_200_OK
31
+ else:
32
+ # No fastapi installed
33
+ return 200
34
+
35
+
36
+ T = TypeVar("T", bound=BaseEvent)
37
+
38
+
39
+ def build_unique_custom_app(
40
+ *,
41
+ title: str = "Unique Chat App",
42
+ webhook_path: str = "/webhook",
43
+ settings: UniqueSettings,
44
+ event_handler: Callable[[T], int] = default_event_handler,
45
+ event_constructor: Callable[..., T] = ChatEvent,
46
+ subscribed_event_names: list[str] | None = None,
47
+ ) -> "FastAPI":
48
+ """Factory class for creating FastAPI apps with Unique webhook handling."""
49
+ if FastAPI is None:
50
+ raise ImportError(
51
+ "FastAPI is not installed. Install it with: poetry install --with fastapi"
52
+ )
53
+
54
+ app = FastAPI(title=title)
55
+
56
+ if subscribed_event_names is None:
57
+ subscribed_event_names = [EventName.EXTERNAL_MODULE_CHOSEN]
58
+
59
+ @app.get(path="/")
60
+ async def health_check() -> JSONResponse:
61
+ """Health check endpoint."""
62
+ return JSONResponse(content={"status": "healthy", "service": title})
63
+
64
+ @app.post(path=webhook_path)
65
+ async def webhook_handler(
66
+ request: Request, background_tasks: BackgroundTasks
67
+ ) -> JSONResponse:
68
+ """
69
+ Webhook endpoint for receiving events from Unique platform.
70
+
71
+ This endpoint:
72
+ 1. Verifies the webhook signature
73
+ 2. Constructs an event from the payload
74
+ 3. Calls the configured event handler
75
+ """
76
+ # Get raw body and headers
77
+ body = await request.body()
78
+ headers = dict(request.headers)
79
+
80
+ from unique_toolkit.app.webhook import is_webhook_signature_valid
81
+
82
+ if not is_webhook_signature_valid(
83
+ headers=headers,
84
+ payload=body,
85
+ endpoint_secret=settings.app.endpoint_secret.get_secret_value(),
86
+ ):
87
+ return JSONResponse(
88
+ status_code=status.HTTP_401_UNAUTHORIZED,
89
+ content={"error": "Invalid webhook signature"},
90
+ )
91
+
92
+ try:
93
+ event_data = json.loads(body.decode(encoding="utf-8"))
94
+ except json.JSONDecodeError as e:
95
+ logger.error(f"Error parsing event: {e}", exc_info=True)
96
+ return JSONResponse(
97
+ status_code=status.HTTP_400_BAD_REQUEST,
98
+ content={"error": f"Invalid event format: {str(e)}"},
99
+ )
100
+
101
+ if event_data["event"] not in subscribed_event_names:
102
+ return JSONResponse(
103
+ status_code=status.HTTP_400_BAD_REQUEST,
104
+ content={"error": "Not subscribed event"},
105
+ )
106
+
107
+ try:
108
+ event = event_constructor(**event_data)
109
+ if event.filter_event(filter_options=settings.chat_event_filter_options):
110
+ return JSONResponse(
111
+ status_code=status.HTTP_200_OK,
112
+ content={"error": "Event filtered out"},
113
+ )
114
+ except ValidationError as e:
115
+ # pydantic errors https://docs.pydantic.dev/2.10/errors/errors/
116
+ logger.error(f"Validation error with model: {e.json()}", exc_info=True)
117
+ raise e
118
+ except ValueError as e:
119
+ logger.error(f"Error deserializing event: {e}", exc_info=True)
120
+ return JSONResponse(
121
+ status_code=status.HTTP_400_BAD_REQUEST,
122
+ content={"error": "Invalid event"},
123
+ )
124
+
125
+ # Run the task in background so that we don't block for long running tasks
126
+ background_tasks.add_task(event_handler, event)
127
+ return JSONResponse(
128
+ status_code=status.HTTP_200_OK, content={"message": "Event received"}
129
+ )
130
+
131
+ return app
@@ -0,0 +1,77 @@
1
+ """
2
+ Webhook signature verification for Unique platform.
3
+
4
+ Extracted from unique_sdk to provide standalone verification without event construction.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import logging
10
+ import time
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def is_webhook_signature_valid(
16
+ headers: dict[str, str],
17
+ payload: bytes,
18
+ endpoint_secret: str,
19
+ tolerance: int = 300,
20
+ ) -> bool:
21
+ """
22
+ Verify webhook signature from Unique platform.
23
+
24
+ Args:
25
+ headers: Request headers with X-Unique-Signature and X-Unique-Created-At
26
+ payload: Raw request body bytes
27
+ endpoint_secret: App endpoint secret from Unique platform
28
+ tolerance: Max seconds between timestamp and now (default: 300)
29
+
30
+ Returns:
31
+ True if signature is valid, False otherwise
32
+ """
33
+ # Extract headers
34
+ signature = headers.get("X-Unique-Signature") or headers.get("x-unique-signature")
35
+ timestamp_str = headers.get("X-Unique-Created-At") or headers.get(
36
+ "x-unique-created-at"
37
+ )
38
+
39
+ if not signature:
40
+ _LOGGER.error("Missing X-Unique-Signature header")
41
+ return False
42
+
43
+ if not timestamp_str:
44
+ _LOGGER.error("Missing X-Unique-Created-At header")
45
+ return False
46
+
47
+ # Convert timestamp to int
48
+ try:
49
+ timestamp = int(timestamp_str)
50
+ except ValueError:
51
+ _LOGGER.error(f"Invalid timestamp: {timestamp_str}")
52
+ return False
53
+
54
+ # Decode payload if bytes
55
+ message = payload.decode("utf-8") if isinstance(payload, bytes) else payload
56
+
57
+ # Compute expected signature: HMAC-SHA256(message, secret)
58
+ expected_signature = hmac.new(
59
+ endpoint_secret.encode("utf-8"),
60
+ msg=message.encode("utf-8"),
61
+ digestmod=hashlib.sha256,
62
+ ).hexdigest()
63
+
64
+ # Compare signatures (constant-time to prevent timing attacks)
65
+ if not hmac.compare_digest(expected_signature, signature):
66
+ _LOGGER.error("Signature mismatch. Ensure you're using the raw request body.")
67
+ return False
68
+
69
+ # Check timestamp tolerance (prevent replay attacks)
70
+ if tolerance and timestamp < time.time() - tolerance:
71
+ _LOGGER.error(
72
+ f"Timestamp outside tolerance ({tolerance}s). Possible replay attack."
73
+ )
74
+ return False
75
+
76
+ _LOGGER.debug("✅ Webhook signature verified successfully")
77
+ return True
@@ -47,6 +47,7 @@ class LanguageModelName(StrEnum):
47
47
  ANTHROPIC_CLAUDE_SONNET_4_5 = "litellm:anthropic-claude-sonnet-4-5"
48
48
  ANTHROPIC_CLAUDE_OPUS_4 = "litellm:anthropic-claude-opus-4"
49
49
  ANTHROPIC_CLAUDE_OPUS_4_1 = "litellm:anthropic-claude-opus-4-1"
50
+ ANTHROPIC_CLAUDE_OPUS_4_5 = "litellm:anthropic-claude-opus-4-5"
50
51
  GEMINI_2_0_FLASH = "litellm:gemini-2-0-flash"
51
52
  GEMINI_2_5_FLASH = "litellm:gemini-2-5-flash"
52
53
  GEMINI_2_5_FLASH_LITE = "litellm:gemini-2-5-flash-lite"
@@ -946,7 +947,7 @@ class LanguageModelInfo(BaseModel):
946
947
  ModelCapabilities.REASONING,
947
948
  ],
948
949
  provider=LanguageModelProvider.LITELLM,
949
- version="claude-opus-4",
950
+ version="claude-opus-4-1",
950
951
  encoder_name=EncoderName.O200K_BASE, # TODO: Update encoder with litellm
951
952
  token_limits=LanguageModelTokenLimits(
952
953
  # Input limit is 200_000, we leave 20_000 tokens as buffer due to tokenizer mismatch
@@ -956,6 +957,26 @@ class LanguageModelInfo(BaseModel):
956
957
  info_cutoff_at=date(2025, 3, 1),
957
958
  published_at=date(2025, 5, 1),
958
959
  )
960
+ case LanguageModelName.ANTHROPIC_CLAUDE_OPUS_4_5:
961
+ return cls(
962
+ name=model_name,
963
+ capabilities=[
964
+ ModelCapabilities.FUNCTION_CALLING,
965
+ ModelCapabilities.STREAMING,
966
+ ModelCapabilities.VISION,
967
+ ModelCapabilities.REASONING,
968
+ ],
969
+ provider=LanguageModelProvider.LITELLM,
970
+ version="claude-opus-4-5",
971
+ encoder_name=EncoderName.O200K_BASE, # TODO: Update encoder with litellm
972
+ token_limits=LanguageModelTokenLimits(
973
+ # Input limit is 200_000, we leave 20_000 tokens as buffer due to tokenizer mismatch
974
+ token_limit_input=180_000,
975
+ token_limit_output=64_000,
976
+ ),
977
+ info_cutoff_at=date(2025, 8, 1),
978
+ published_at=date(2025, 11, 13),
979
+ )
959
980
  case LanguageModelName.GEMINI_2_0_FLASH:
960
981
  return cls(
961
982
  name=model_name,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 1.29.4
3
+ Version: 1.31.0
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.31.0] - 2025-11-20
125
+ - Adding model `litellm:anthropic-claude-opus-4-5` to `language_model/info.py`
126
+
127
+ ## [1.30.0] - 2025-11-26
128
+ - Add option to only display parts of sub agent responses.
129
+
124
130
  ## [1.29.4] - 2025-11-25
125
131
  - Add display name to openai builtin tools
126
132
 
@@ -82,12 +82,12 @@ 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=iIuddL5qJlPiPHLcMXVXzZfBtYsZTzMfaSk1et5LWUY,5461
85
+ unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py,sha256=lpcyCTm3KKhaYMF4Wh7DHrkP_WAOkbXha5QJc7zy2nc,6539
86
86
  unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py,sha256=E3KybH9SX5oOkh14sSaIaLnht4DhKVrHAiUkoACNBsg,2430
87
- unique_toolkit/agentic/tools/a2a/postprocessing/config.py,sha256=XVzZru7T-lULRsFKMJsBPY30XNSTj_MxEyGRCgBUnEk,1818
88
- unique_toolkit/agentic/tools/a2a/postprocessing/display.py,sha256=WbT4fXo3aPiUoHGkPon_rdB86wLTmxavhfQ6AzmNexU,6398
87
+ unique_toolkit/agentic/tools/a2a/postprocessing/config.py,sha256=U5N2y6ok14daNf_u-E1SXnV9v9DwpW4jL_U_3o8oJ7Q,2633
88
+ unique_toolkit/agentic/tools/a2a/postprocessing/display.py,sha256=9t6zsVAYCyGXMHkxyH_XHdIbU0w0WBY1O08wjJvjoig,7850
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=5fIwvSQI6J_YIHyQgME5WfxgfJOg_ad6JHcsV43jOgY,39642
90
+ unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py,sha256=mj2kR96ZzVIcgW3x-70oAjOLgYl5QreCIWBum1eQE_I,51989
91
91
  unique_toolkit/agentic/tools/a2a/postprocessing/test/test_ref_utils.py,sha256=u-eNrHjsRFcu4TmzkD5XfFrxvaIToB42YGyXZ-RpsR0,17830
92
92
  unique_toolkit/agentic/tools/a2a/prompts.py,sha256=0ILHL_RAcT04gFm2d470j4Gho7PoJXdCJy-bkZgf_wk,2401
93
93
  unique_toolkit/agentic/tools/a2a/response_watcher/__init__.py,sha256=fS8Lq49DZo5spMcP8QGTMWwSg80rYSr2pTfYDbssGYs,184
@@ -124,8 +124,9 @@ unique_toolkit/agentic/tools/utils/source_handling/__init__.py,sha256=47DEQpj8HB
124
124
  unique_toolkit/agentic/tools/utils/source_handling/schema.py,sha256=iHBKuks6tUy8tvian4Pd0B6_-8__SehVVNcxIUAUjEA,882
125
125
  unique_toolkit/agentic/tools/utils/source_handling/source_formatting.py,sha256=uZ0QXqrPWgId3ZA67dvjHQ6xrW491LK1xxx_sVJmFHg,9160
126
126
  unique_toolkit/agentic/tools/utils/source_handling/tests/test_source_formatting.py,sha256=EA8iVvb3L91OFk2XMbGcFuhe2etqm3Sx9QCYDGiOSOM,6995
127
- unique_toolkit/app/__init__.py,sha256=ETxYDpEizg_PKmi4JPX_P76ySq-us-xypfAIdKQ1QZU,1284
127
+ unique_toolkit/app/__init__.py,sha256=OaylhLwxeRlsHlcFGSlR5R7oREFsjv9wRdxuVZBYM_8,1371
128
128
  unique_toolkit/app/dev_util.py,sha256=J20peCvrSQKfMGdYPYwCirs3Yq2v_e33GzNBzNKbWN4,5531
129
+ unique_toolkit/app/fast_api_factory.py,sha256=l6AZoR8RAh4IJxAHZR_ajAL5ojcGdVh7Z8pfSpDSEKM,4634
129
130
  unique_toolkit/app/init_logging.py,sha256=Sh26SRxOj8i8dzobKhYha2lLrkrMTHfB1V4jR3h23gQ,678
130
131
  unique_toolkit/app/init_sdk.py,sha256=5_oDoETr6akwYyBCb0ivTdMNu3SVgPSkrXcDS6ELyY8,2269
131
132
  unique_toolkit/app/performance/async_tasks.py,sha256=H0l3OAcosLwNHZ8d2pd-Di4wHIXfclEvagi5kfqLFPA,1941
@@ -133,6 +134,7 @@ unique_toolkit/app/performance/async_wrapper.py,sha256=yVVcRDkcdyfjsxro-N29SBvi-
133
134
  unique_toolkit/app/schemas.py,sha256=1KziC9FzPtjOJ1R2ZoeJqVV9PNjGuKJ9uIMvPpA2yus,10159
134
135
  unique_toolkit/app/unique_settings.py,sha256=NTfa3a8wWzBDx4_4Irqyhy4mpXyPU6Munqs41ozPFnE,12366
135
136
  unique_toolkit/app/verification.py,sha256=GxFFwcJMy25fCA_Xe89wKW7bgqOu8PAs5y8QpHF0GSc,3861
137
+ unique_toolkit/app/webhook.py,sha256=k7DP1UTR3p7D4qzuKPKVmGMAkDVHfALrnMIzTZqj_OI,2320
136
138
  unique_toolkit/chat/__init__.py,sha256=uP7P6YPeOjEOvpX3bhcU6ND_m0QLr4wMklcrnAKK0q4,804
137
139
  unique_toolkit/chat/constants.py,sha256=05kq6zjqUVB2d6_P7s-90nbljpB3ryxwCI-CAz0r2O4,83
138
140
  unique_toolkit/chat/deprecated/service.py,sha256=CYwzXi7OB0RjHd73CO2jq8SlpdBmDYLatzPFkb5sA0k,6529
@@ -169,7 +171,7 @@ unique_toolkit/language_model/builder.py,sha256=4OKfwJfj3TrgO1ezc_ewIue6W7BCQ2ZY
169
171
  unique_toolkit/language_model/constants.py,sha256=B-topqW0r83dkC_25DeQfnPk3n53qzIHUCBS7YJ0-1U,119
170
172
  unique_toolkit/language_model/default_language_model.py,sha256=-_DBsJhLCsFdaU4ynAkyW0jYIl2lhrPybZm1K-GgVJs,125
171
173
  unique_toolkit/language_model/functions.py,sha256=nGxlV4OO70jdH_7AgRWDMpbzmmKLZ-5Tk4gu5nxB2ko,17735
172
- unique_toolkit/language_model/infos.py,sha256=fPUTwDJk30V_uqrlc5EXXysqYOCI7nAXTYWEOJggfxc,78788
174
+ unique_toolkit/language_model/infos.py,sha256=sZJOOij-dfReDxJWfd7ZwP3qx4KcN1LVqNchRafKmrY,79877
173
175
  unique_toolkit/language_model/prompt.py,sha256=JSawaLjQg3VR-E2fK8engFyJnNdk21zaO8pPIodzN4Q,3991
174
176
  unique_toolkit/language_model/reference.py,sha256=nkX2VFz-IrUz8yqyc3G5jUMNwrNpxITBrMEKkbqqYoI,8583
175
177
  unique_toolkit/language_model/schemas.py,sha256=ATiHjhfGxoubS332XuhL9PKSoFewcWvPTUVBaNGWlJo,23994
@@ -187,7 +189,7 @@ unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBu
187
189
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
188
190
  unique_toolkit/smart_rules/compile.py,sha256=Ozhh70qCn2yOzRWr9d8WmJeTo7AQurwd3tStgBMPFLA,1246
189
191
  unique_toolkit/test_utilities/events.py,sha256=_mwV2bs5iLjxS1ynDCjaIq-gjjKhXYCK-iy3dRfvO3g,6410
190
- unique_toolkit-1.29.4.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
191
- unique_toolkit-1.29.4.dist-info/METADATA,sha256=WeO7rETdLnEj6Gapt5HFrJbYSg_0TXGNNKcMn2VPtZ0,44462
192
- unique_toolkit-1.29.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
193
- unique_toolkit-1.29.4.dist-info/RECORD,,
192
+ unique_toolkit-1.31.0.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
193
+ unique_toolkit-1.31.0.dist-info/METADATA,sha256=fCVcgrZkBcRqyYl21qWou_M1kuGb7qngixPIdR9sjAo,44652
194
+ unique_toolkit-1.31.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
195
+ unique_toolkit-1.31.0.dist-info/RECORD,,