unique_toolkit 0.7.7__py3-none-any.whl → 1.23.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.

Potentially problematic release.


This version of unique_toolkit might be problematic. Click here for more details.

Files changed (166) hide show
  1. unique_toolkit/__init__.py +28 -1
  2. unique_toolkit/_common/api_calling/human_verification_manager.py +343 -0
  3. unique_toolkit/_common/base_model_type_attribute.py +303 -0
  4. unique_toolkit/_common/chunk_relevancy_sorter/config.py +49 -0
  5. unique_toolkit/_common/chunk_relevancy_sorter/exception.py +5 -0
  6. unique_toolkit/_common/chunk_relevancy_sorter/schemas.py +46 -0
  7. unique_toolkit/_common/chunk_relevancy_sorter/service.py +374 -0
  8. unique_toolkit/_common/chunk_relevancy_sorter/tests/test_service.py +275 -0
  9. unique_toolkit/_common/default_language_model.py +12 -0
  10. unique_toolkit/_common/docx_generator/__init__.py +7 -0
  11. unique_toolkit/_common/docx_generator/config.py +12 -0
  12. unique_toolkit/_common/docx_generator/schemas.py +80 -0
  13. unique_toolkit/_common/docx_generator/service.py +252 -0
  14. unique_toolkit/_common/docx_generator/template/Doc Template.docx +0 -0
  15. unique_toolkit/_common/endpoint_builder.py +305 -0
  16. unique_toolkit/_common/endpoint_requestor.py +430 -0
  17. unique_toolkit/_common/exception.py +24 -0
  18. unique_toolkit/_common/feature_flags/schema.py +9 -0
  19. unique_toolkit/_common/pydantic/rjsf_tags.py +936 -0
  20. unique_toolkit/_common/pydantic_helpers.py +154 -0
  21. unique_toolkit/_common/referencing.py +53 -0
  22. unique_toolkit/_common/string_utilities.py +140 -0
  23. unique_toolkit/_common/tests/test_referencing.py +521 -0
  24. unique_toolkit/_common/tests/test_string_utilities.py +506 -0
  25. unique_toolkit/_common/token/image_token_counting.py +67 -0
  26. unique_toolkit/_common/token/token_counting.py +204 -0
  27. unique_toolkit/_common/utils/__init__.py +1 -0
  28. unique_toolkit/_common/utils/files.py +43 -0
  29. unique_toolkit/_common/utils/structured_output/__init__.py +1 -0
  30. unique_toolkit/_common/utils/structured_output/schema.py +5 -0
  31. unique_toolkit/_common/utils/write_configuration.py +51 -0
  32. unique_toolkit/_common/validators.py +101 -4
  33. unique_toolkit/agentic/__init__.py +1 -0
  34. unique_toolkit/agentic/debug_info_manager/debug_info_manager.py +28 -0
  35. unique_toolkit/agentic/debug_info_manager/test/test_debug_info_manager.py +278 -0
  36. unique_toolkit/agentic/evaluation/config.py +36 -0
  37. unique_toolkit/{evaluators → agentic/evaluation}/context_relevancy/prompts.py +25 -0
  38. unique_toolkit/agentic/evaluation/context_relevancy/schema.py +80 -0
  39. unique_toolkit/agentic/evaluation/context_relevancy/service.py +273 -0
  40. unique_toolkit/agentic/evaluation/evaluation_manager.py +218 -0
  41. unique_toolkit/agentic/evaluation/hallucination/constants.py +61 -0
  42. unique_toolkit/agentic/evaluation/hallucination/hallucination_evaluation.py +111 -0
  43. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/prompts.py +1 -1
  44. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/service.py +16 -15
  45. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/utils.py +30 -20
  46. unique_toolkit/{evaluators → agentic/evaluation}/output_parser.py +20 -2
  47. unique_toolkit/{evaluators → agentic/evaluation}/schemas.py +27 -7
  48. unique_toolkit/agentic/evaluation/tests/test_context_relevancy_service.py +253 -0
  49. unique_toolkit/agentic/evaluation/tests/test_output_parser.py +87 -0
  50. unique_toolkit/agentic/history_manager/history_construction_with_contents.py +297 -0
  51. unique_toolkit/agentic/history_manager/history_manager.py +242 -0
  52. unique_toolkit/agentic/history_manager/loop_token_reducer.py +484 -0
  53. unique_toolkit/agentic/history_manager/utils.py +96 -0
  54. unique_toolkit/agentic/postprocessor/postprocessor_manager.py +212 -0
  55. unique_toolkit/agentic/reference_manager/reference_manager.py +103 -0
  56. unique_toolkit/agentic/responses_api/__init__.py +19 -0
  57. unique_toolkit/agentic/responses_api/postprocessors/code_display.py +63 -0
  58. unique_toolkit/agentic/responses_api/postprocessors/generated_files.py +145 -0
  59. unique_toolkit/agentic/responses_api/stream_handler.py +15 -0
  60. unique_toolkit/agentic/short_term_memory_manager/persistent_short_term_memory_manager.py +141 -0
  61. unique_toolkit/agentic/thinking_manager/thinking_manager.py +103 -0
  62. unique_toolkit/agentic/tools/__init__.py +1 -0
  63. unique_toolkit/agentic/tools/a2a/__init__.py +36 -0
  64. unique_toolkit/agentic/tools/a2a/config.py +17 -0
  65. unique_toolkit/agentic/tools/a2a/evaluation/__init__.py +15 -0
  66. unique_toolkit/agentic/tools/a2a/evaluation/_utils.py +66 -0
  67. unique_toolkit/agentic/tools/a2a/evaluation/config.py +55 -0
  68. unique_toolkit/agentic/tools/a2a/evaluation/evaluator.py +260 -0
  69. unique_toolkit/agentic/tools/a2a/evaluation/summarization_user_message.j2 +9 -0
  70. unique_toolkit/agentic/tools/a2a/manager.py +55 -0
  71. unique_toolkit/agentic/tools/a2a/postprocessing/__init__.py +21 -0
  72. unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py +185 -0
  73. unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py +73 -0
  74. unique_toolkit/agentic/tools/a2a/postprocessing/config.py +45 -0
  75. unique_toolkit/agentic/tools/a2a/postprocessing/display.py +180 -0
  76. unique_toolkit/agentic/tools/a2a/postprocessing/references.py +101 -0
  77. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py +1335 -0
  78. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_ref_utils.py +603 -0
  79. unique_toolkit/agentic/tools/a2a/prompts.py +46 -0
  80. unique_toolkit/agentic/tools/a2a/response_watcher/__init__.py +6 -0
  81. unique_toolkit/agentic/tools/a2a/response_watcher/service.py +91 -0
  82. unique_toolkit/agentic/tools/a2a/tool/__init__.py +4 -0
  83. unique_toolkit/agentic/tools/a2a/tool/_memory.py +26 -0
  84. unique_toolkit/agentic/tools/a2a/tool/_schema.py +9 -0
  85. unique_toolkit/agentic/tools/a2a/tool/config.py +73 -0
  86. unique_toolkit/agentic/tools/a2a/tool/service.py +306 -0
  87. unique_toolkit/agentic/tools/agent_chunks_hanlder.py +65 -0
  88. unique_toolkit/agentic/tools/config.py +167 -0
  89. unique_toolkit/agentic/tools/factory.py +44 -0
  90. unique_toolkit/agentic/tools/mcp/__init__.py +4 -0
  91. unique_toolkit/agentic/tools/mcp/manager.py +71 -0
  92. unique_toolkit/agentic/tools/mcp/models.py +28 -0
  93. unique_toolkit/agentic/tools/mcp/tool_wrapper.py +234 -0
  94. unique_toolkit/agentic/tools/openai_builtin/__init__.py +11 -0
  95. unique_toolkit/agentic/tools/openai_builtin/base.py +30 -0
  96. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/__init__.py +8 -0
  97. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/config.py +57 -0
  98. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/service.py +230 -0
  99. unique_toolkit/agentic/tools/openai_builtin/manager.py +62 -0
  100. unique_toolkit/agentic/tools/schemas.py +141 -0
  101. unique_toolkit/agentic/tools/test/test_mcp_manager.py +536 -0
  102. unique_toolkit/agentic/tools/test/test_tool_progress_reporter.py +445 -0
  103. unique_toolkit/agentic/tools/tool.py +183 -0
  104. unique_toolkit/agentic/tools/tool_manager.py +523 -0
  105. unique_toolkit/agentic/tools/tool_progress_reporter.py +285 -0
  106. unique_toolkit/agentic/tools/utils/__init__.py +19 -0
  107. unique_toolkit/agentic/tools/utils/execution/__init__.py +1 -0
  108. unique_toolkit/agentic/tools/utils/execution/execution.py +286 -0
  109. unique_toolkit/agentic/tools/utils/source_handling/__init__.py +0 -0
  110. unique_toolkit/agentic/tools/utils/source_handling/schema.py +21 -0
  111. unique_toolkit/agentic/tools/utils/source_handling/source_formatting.py +207 -0
  112. unique_toolkit/agentic/tools/utils/source_handling/tests/test_source_formatting.py +216 -0
  113. unique_toolkit/app/__init__.py +6 -0
  114. unique_toolkit/app/dev_util.py +180 -0
  115. unique_toolkit/app/init_sdk.py +32 -1
  116. unique_toolkit/app/schemas.py +198 -31
  117. unique_toolkit/app/unique_settings.py +367 -0
  118. unique_toolkit/chat/__init__.py +8 -1
  119. unique_toolkit/chat/deprecated/service.py +232 -0
  120. unique_toolkit/chat/functions.py +642 -77
  121. unique_toolkit/chat/rendering.py +34 -0
  122. unique_toolkit/chat/responses_api.py +461 -0
  123. unique_toolkit/chat/schemas.py +133 -2
  124. unique_toolkit/chat/service.py +115 -767
  125. unique_toolkit/content/functions.py +153 -4
  126. unique_toolkit/content/schemas.py +122 -15
  127. unique_toolkit/content/service.py +278 -44
  128. unique_toolkit/content/smart_rules.py +301 -0
  129. unique_toolkit/content/utils.py +8 -3
  130. unique_toolkit/embedding/service.py +102 -11
  131. unique_toolkit/framework_utilities/__init__.py +1 -0
  132. unique_toolkit/framework_utilities/langchain/client.py +71 -0
  133. unique_toolkit/framework_utilities/langchain/history.py +19 -0
  134. unique_toolkit/framework_utilities/openai/__init__.py +6 -0
  135. unique_toolkit/framework_utilities/openai/client.py +83 -0
  136. unique_toolkit/framework_utilities/openai/message_builder.py +229 -0
  137. unique_toolkit/framework_utilities/utils.py +23 -0
  138. unique_toolkit/language_model/__init__.py +3 -0
  139. unique_toolkit/language_model/builder.py +27 -11
  140. unique_toolkit/language_model/default_language_model.py +3 -0
  141. unique_toolkit/language_model/functions.py +327 -43
  142. unique_toolkit/language_model/infos.py +992 -50
  143. unique_toolkit/language_model/reference.py +242 -0
  144. unique_toolkit/language_model/schemas.py +475 -48
  145. unique_toolkit/language_model/service.py +228 -27
  146. unique_toolkit/protocols/support.py +145 -0
  147. unique_toolkit/services/__init__.py +7 -0
  148. unique_toolkit/services/chat_service.py +1630 -0
  149. unique_toolkit/services/knowledge_base.py +861 -0
  150. unique_toolkit/short_term_memory/service.py +178 -41
  151. unique_toolkit/smart_rules/__init__.py +0 -0
  152. unique_toolkit/smart_rules/compile.py +56 -0
  153. unique_toolkit/test_utilities/events.py +197 -0
  154. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/METADATA +606 -7
  155. unique_toolkit-1.23.0.dist-info/RECORD +182 -0
  156. unique_toolkit/evaluators/__init__.py +0 -1
  157. unique_toolkit/evaluators/config.py +0 -35
  158. unique_toolkit/evaluators/constants.py +0 -1
  159. unique_toolkit/evaluators/context_relevancy/constants.py +0 -32
  160. unique_toolkit/evaluators/context_relevancy/service.py +0 -53
  161. unique_toolkit/evaluators/context_relevancy/utils.py +0 -142
  162. unique_toolkit/evaluators/hallucination/constants.py +0 -41
  163. unique_toolkit-0.7.7.dist-info/RECORD +0 -64
  164. /unique_toolkit/{evaluators → agentic/evaluation}/exception.py +0 -0
  165. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/LICENSE +0 -0
  166. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,445 @@
1
+ from unittest.mock import AsyncMock
2
+
3
+ import pytest
4
+
5
+ from unique_toolkit.agentic.tools.tool_progress_reporter import (
6
+ DUMMY_REFERENCE_PLACEHOLDER,
7
+ ProgressState,
8
+ ToolExecutionStatus,
9
+ ToolProgressReporter,
10
+ ToolProgressReporterConfig,
11
+ ToolWithToolProgressReporter,
12
+ track_tool_progress,
13
+ )
14
+ from unique_toolkit.chat.service import ChatService
15
+ from unique_toolkit.content.schemas import ContentReference
16
+ from unique_toolkit.language_model.schemas import LanguageModelFunction
17
+
18
+
19
+ @pytest.fixture
20
+ def chat_service():
21
+ return AsyncMock(spec=ChatService)
22
+
23
+
24
+ @pytest.fixture
25
+ def tool_progress_reporter(chat_service):
26
+ return ToolProgressReporter(chat_service)
27
+
28
+
29
+ @pytest.fixture
30
+ def tool_call():
31
+ return LanguageModelFunction(id="test_id", name="test_tool")
32
+
33
+
34
+ class TestToolProgressReporter:
35
+ @pytest.mark.asyncio
36
+ async def test_notify_from_tool_call(self, tool_progress_reporter, tool_call):
37
+ # Arrange
38
+ name = "Test Tool"
39
+ message = "Processing..."
40
+ state = ProgressState.RUNNING
41
+ references = [
42
+ ContentReference(
43
+ sequence_number=1,
44
+ id="1",
45
+ message_id="1",
46
+ name="1",
47
+ source="1",
48
+ source_id="1",
49
+ url="1",
50
+ )
51
+ ]
52
+
53
+ # Act
54
+ await tool_progress_reporter.notify_from_tool_call(
55
+ tool_call=tool_call,
56
+ name=name,
57
+ message=message,
58
+ state=state,
59
+ references=references,
60
+ requires_new_assistant_message=True,
61
+ )
62
+
63
+ # Assert
64
+ assert tool_call.id in tool_progress_reporter.tool_statuses
65
+ status = tool_progress_reporter.tool_statuses[tool_call.id]
66
+ assert status.name == name
67
+ assert status.message == message
68
+ assert status.state == state
69
+ assert status.references == references
70
+ assert tool_progress_reporter.requires_new_assistant_message is True
71
+
72
+ def test_replace_placeholders(self, tool_progress_reporter):
73
+ # Arrange
74
+ message = (
75
+ f"Test{DUMMY_REFERENCE_PLACEHOLDER}message{DUMMY_REFERENCE_PLACEHOLDER}"
76
+ )
77
+
78
+ # Act
79
+ result = tool_progress_reporter._replace_placeholders(message, start_number=1)
80
+
81
+ # Assert
82
+ assert result == "Test<sup>1</sup>message<sup>2</sup>"
83
+
84
+ def test_correct_reference_sequence(self, tool_progress_reporter):
85
+ # Arrange
86
+ references = [
87
+ ContentReference(
88
+ sequence_number=0,
89
+ id="1",
90
+ message_id="1",
91
+ name="1",
92
+ source="1",
93
+ source_id="1",
94
+ url="1",
95
+ ),
96
+ ContentReference(
97
+ sequence_number=0,
98
+ id="2",
99
+ message_id="2",
100
+ name="2",
101
+ source="2",
102
+ source_id="2",
103
+ url="2",
104
+ ),
105
+ ]
106
+
107
+ # Act
108
+ result = tool_progress_reporter._correct_reference_sequence(
109
+ references, start_number=1
110
+ )
111
+
112
+ # Assert
113
+ assert len(result) == 2
114
+ assert result[0].sequence_number == 1
115
+ assert result[1].sequence_number == 2
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_publish_updates_chat_service(
119
+ self, tool_progress_reporter, tool_call
120
+ ):
121
+ # Arrange
122
+ status = ToolExecutionStatus(
123
+ name="Test Tool",
124
+ message="Test message",
125
+ state=ProgressState.FINISHED,
126
+ references=[
127
+ ContentReference(
128
+ sequence_number=1,
129
+ id="1",
130
+ message_id="1",
131
+ name="1",
132
+ source="1",
133
+ source_id="1",
134
+ url="1",
135
+ )
136
+ ],
137
+ )
138
+ tool_progress_reporter.tool_statuses[tool_call.id] = status
139
+
140
+ # Act
141
+ await tool_progress_reporter.publish()
142
+
143
+ # Assert
144
+ tool_progress_reporter.chat_service.modify_assistant_message_async.assert_called_once()
145
+
146
+
147
+ class TestToolProgressDecorator:
148
+ class DummyTool(ToolWithToolProgressReporter):
149
+ def __init__(self, tool_progress_reporter):
150
+ self.tool_progress_reporter = tool_progress_reporter
151
+
152
+ @track_tool_progress(
153
+ message="Processing",
154
+ on_start_state=ProgressState.STARTED,
155
+ on_success_state=ProgressState.FINISHED,
156
+ on_success_message="Completed",
157
+ requires_new_assistant_message=True,
158
+ )
159
+ async def execute(self, tool_call, notification_tool_name):
160
+ return {
161
+ "references": [
162
+ ContentReference(
163
+ sequence_number=1,
164
+ id="1",
165
+ message_id="1",
166
+ name="1",
167
+ source="1",
168
+ source_id="1",
169
+ url="1",
170
+ )
171
+ ]
172
+ }
173
+
174
+ @pytest.mark.asyncio
175
+ async def test_decorator_success_flow(self, tool_progress_reporter, tool_call):
176
+ # Arrange
177
+ tool = self.DummyTool(tool_progress_reporter)
178
+
179
+ # Act
180
+ await tool.execute(tool_call, "Test Tool")
181
+
182
+ # Assert
183
+ assert len(tool_progress_reporter.tool_statuses) == 1
184
+ status = tool_progress_reporter.tool_statuses[tool_call.id]
185
+ assert status.state == ProgressState.FINISHED
186
+ assert status.message == "Completed"
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_decorator_error_flow(self, tool_progress_reporter, tool_call):
190
+ # Arrange
191
+ class ErrorTool(ToolWithToolProgressReporter):
192
+ def __init__(self, tool_progress_reporter):
193
+ self.tool_progress_reporter = tool_progress_reporter
194
+
195
+ @track_tool_progress(message="Processing")
196
+ async def execute(self, tool_call, notification_tool_name):
197
+ raise ValueError("Test error")
198
+
199
+ tool = ErrorTool(tool_progress_reporter)
200
+
201
+ # Act & Assert
202
+ with pytest.raises(ValueError):
203
+ await tool.execute(tool_call, "Test Tool")
204
+
205
+ status = tool_progress_reporter.tool_statuses[tool_call.id]
206
+ assert status.state == ProgressState.FAILED
207
+
208
+
209
+ class TestToolProgressReporterConfig:
210
+ """Tests for ToolProgressReporterConfig and custom display configuration."""
211
+
212
+ @pytest.mark.ai
213
+ @pytest.mark.asyncio
214
+ async def test_config__uses_default_templates__when_no_config_provided(
215
+ self, chat_service, tool_call
216
+ ) -> None:
217
+ """
218
+ Purpose: Verify that default state-to-display templates are used when no config is provided.
219
+ Why this matters: Ensures backward compatibility and default behavior.
220
+ Setup summary: Create reporter without config, add status, verify default template is used.
221
+ """
222
+ # Arrange
223
+ reporter = ToolProgressReporter(chat_service)
224
+
225
+ # Act
226
+ await reporter.notify_from_tool_call(
227
+ tool_call=tool_call,
228
+ name="Test Tool",
229
+ message="Processing data",
230
+ state=ProgressState.RUNNING,
231
+ )
232
+
233
+ # Assert
234
+ assert tool_call.id in reporter.tool_statuses
235
+ chat_service.modify_assistant_message_async.assert_called()
236
+ call_args = chat_service.modify_assistant_message_async.call_args
237
+ content = call_args.kwargs["content"]
238
+ assert "Test Tool" in content
239
+ assert "🟡" in content # Default emoji for RUNNING state
240
+ assert "Processing data" in content
241
+
242
+ @pytest.mark.ai
243
+ @pytest.mark.asyncio
244
+ async def test_config__uses_custom_templates__when_config_provided(
245
+ self, chat_service, tool_call
246
+ ) -> None:
247
+ """
248
+ Purpose: Verify that custom templates are used when provided via config.
249
+ Why this matters: Enables customization of progress display format.
250
+ Setup summary: Create reporter with custom template, verify custom format is used.
251
+ """
252
+ # Arrange
253
+ custom_config = ToolProgressReporterConfig(
254
+ state_to_display_template={
255
+ "started": "⚪ {tool_name}: {message}",
256
+ "running": "⏳ {tool_name}: {message}",
257
+ "finished": "✅ {tool_name}: {message}",
258
+ "failed": "❌ {tool_name}: {message}",
259
+ }
260
+ )
261
+ reporter = ToolProgressReporter(chat_service, config=custom_config)
262
+
263
+ # Act
264
+ await reporter.notify_from_tool_call(
265
+ tool_call=tool_call,
266
+ name="My Tool",
267
+ message="Working on it",
268
+ state=ProgressState.RUNNING,
269
+ )
270
+
271
+ # Assert
272
+ chat_service.modify_assistant_message_async.assert_called()
273
+ call_args = chat_service.modify_assistant_message_async.call_args
274
+ content = call_args.kwargs["content"]
275
+ assert "⏳ My Tool: Working on it" in content
276
+ assert "🟡" not in content # Default emoji should not appear
277
+
278
+ @pytest.mark.ai
279
+ @pytest.mark.asyncio
280
+ async def test_config__skips_states_with_empty_template__when_state_hidden(
281
+ self, chat_service, tool_call
282
+ ) -> None:
283
+ """
284
+ Purpose: Verify that states with empty string templates are not displayed.
285
+ Why this matters: Allows selective display of only certain states (e.g., hide STARTED).
286
+ Setup summary: Create config with empty string for RUNNING state, verify message is not displayed.
287
+ """
288
+ # Arrange
289
+ custom_config = ToolProgressReporterConfig(
290
+ state_to_display_template={
291
+ "started": "",
292
+ "running": "", # Empty string hides RUNNING state
293
+ "finished": "✅ {tool_name}: {message}",
294
+ "failed": "❌ {tool_name}: {message}",
295
+ }
296
+ )
297
+ reporter = ToolProgressReporter(chat_service, config=custom_config)
298
+
299
+ # Act
300
+ await reporter.notify_from_tool_call(
301
+ tool_call=tool_call,
302
+ name="Test Tool",
303
+ message="Processing",
304
+ state=ProgressState.RUNNING,
305
+ )
306
+
307
+ # Assert
308
+ chat_service.modify_assistant_message_async.assert_called()
309
+ call_args = chat_service.modify_assistant_message_async.call_args
310
+ content = call_args.kwargs["content"]
311
+ # Content should not contain the message since RUNNING template is empty
312
+ assert "Processing" not in content
313
+ assert "Test Tool" not in content
314
+
315
+ @pytest.mark.ai
316
+ @pytest.mark.asyncio
317
+ async def test_config__formats_placeholders_correctly__with_multiple_tools(
318
+ self, chat_service
319
+ ) -> None:
320
+ """
321
+ Purpose: Verify that {tool_name} and {message} placeholders are replaced correctly for multiple tools.
322
+ Why this matters: Ensures template formatting works correctly in multi-tool scenarios.
323
+ Setup summary: Add multiple tool statuses with different names/messages, verify formatting.
324
+ """
325
+ # Arrange
326
+ custom_config = ToolProgressReporterConfig(
327
+ state_to_display_template={
328
+ "started": "○ {tool_name} - {message}",
329
+ "running": "▶️ {tool_name} - {message}",
330
+ "finished": "✓ {tool_name} - {message}",
331
+ "failed": "✗ {tool_name} - {message}",
332
+ }
333
+ )
334
+ reporter = ToolProgressReporter(chat_service, config=custom_config)
335
+ tool_call_1 = LanguageModelFunction(id="tool_1", name="search")
336
+ tool_call_2 = LanguageModelFunction(id="tool_2", name="analyze")
337
+
338
+ # Act
339
+ await reporter.notify_from_tool_call(
340
+ tool_call=tool_call_1,
341
+ name="Search Tool",
342
+ message="Searching database",
343
+ state=ProgressState.RUNNING,
344
+ )
345
+ await reporter.notify_from_tool_call(
346
+ tool_call=tool_call_2,
347
+ name="Analysis Tool",
348
+ message="Analyzing results",
349
+ state=ProgressState.FINISHED,
350
+ )
351
+
352
+ # Assert
353
+ call_args = chat_service.modify_assistant_message_async.call_args
354
+ content = call_args.kwargs["content"]
355
+ assert "▶️ Search Tool - Searching database" in content
356
+ assert "✓ Analysis Tool - Analyzing results" in content
357
+
358
+ @pytest.mark.ai
359
+ @pytest.mark.asyncio
360
+ async def test_config__shows_only_finished_state__when_only_finished_configured(
361
+ self, chat_service, tool_call
362
+ ) -> None:
363
+ """
364
+ Purpose: Verify selective state display shows only FINISHED when other states use empty templates.
365
+ Why this matters: Use case where user only wants final results, not intermediate steps.
366
+ Setup summary: Configure only FINISHED with content, send STARTED and FINISHED, verify only FINISHED appears.
367
+ """
368
+ # Arrange
369
+ custom_config = ToolProgressReporterConfig(
370
+ state_to_display_template={
371
+ "started": "", # Empty template hides STARTED
372
+ "running": "", # Empty template hides RUNNING
373
+ "finished": "Done: {tool_name} - {message}",
374
+ "failed": "Failed: {tool_name} - {message}",
375
+ }
376
+ )
377
+ reporter = ToolProgressReporter(chat_service, config=custom_config)
378
+
379
+ # Act - Send STARTED state (should not appear)
380
+ await reporter.notify_from_tool_call(
381
+ tool_call=tool_call,
382
+ name="Test Tool",
383
+ message="Starting",
384
+ state=ProgressState.STARTED,
385
+ )
386
+
387
+ # Get first call content
388
+ first_call_args = chat_service.modify_assistant_message_async.call_args
389
+ first_content = first_call_args.kwargs["content"]
390
+
391
+ # Act - Update to FINISHED state (should appear)
392
+ await reporter.notify_from_tool_call(
393
+ tool_call=tool_call,
394
+ name="Test Tool",
395
+ message="Completed successfully",
396
+ state=ProgressState.FINISHED,
397
+ )
398
+
399
+ # Assert
400
+ final_call_args = chat_service.modify_assistant_message_async.call_args
401
+ final_content = final_call_args.kwargs["content"]
402
+
403
+ # STARTED state should not appear in first call
404
+ assert "Starting" not in first_content
405
+
406
+ # FINISHED state should appear in final call
407
+ assert "Done: Test Tool - Completed successfully" in final_content
408
+
409
+ @pytest.mark.ai
410
+ @pytest.mark.asyncio
411
+ async def test_config__handles_all_empty_templates__when_all_states_hidden(
412
+ self, chat_service, tool_call
413
+ ) -> None:
414
+ """
415
+ Purpose: Verify that all empty string templates result in no messages being displayed.
416
+ Why this matters: Edge case handling and allows disabling all progress display.
417
+ Setup summary: Create config with all empty templates, verify no tool messages appear.
418
+ """
419
+ # Arrange
420
+ custom_config = ToolProgressReporterConfig(
421
+ state_to_display_template={
422
+ "started": "",
423
+ "running": "",
424
+ "finished": "",
425
+ "failed": "",
426
+ }
427
+ )
428
+ reporter = ToolProgressReporter(chat_service, config=custom_config)
429
+
430
+ # Act
431
+ await reporter.notify_from_tool_call(
432
+ tool_call=tool_call,
433
+ name="Test Tool",
434
+ message="Processing",
435
+ state=ProgressState.RUNNING,
436
+ )
437
+
438
+ # Assert
439
+ chat_service.modify_assistant_message_async.assert_called()
440
+ call_args = chat_service.modify_assistant_message_async.call_args
441
+ content = call_args.kwargs["content"]
442
+
443
+ # Should only have the progress start text and newlines, no actual messages
444
+ assert "Test Tool" not in content
445
+ assert "Processing" not in content
@@ -0,0 +1,183 @@
1
+ from abc import ABC, abstractmethod
2
+ from logging import getLogger
3
+ from typing import Any, Generic, TypeVar, cast
4
+
5
+ from typing_extensions import deprecated
6
+
7
+ from unique_toolkit.agentic.evaluation.schemas import EvaluationMetricName
8
+ from unique_toolkit.agentic.tools.config import ToolBuildConfig, ToolSelectionPolicy
9
+ from unique_toolkit.agentic.tools.schemas import (
10
+ BaseToolConfig,
11
+ ToolCallResponse,
12
+ ToolPrompts,
13
+ )
14
+ from unique_toolkit.agentic.tools.tool_progress_reporter import ToolProgressReporter
15
+ from unique_toolkit.app.schemas import ChatEvent
16
+ from unique_toolkit.chat.service import (
17
+ ChatService,
18
+ )
19
+ from unique_toolkit.language_model import LanguageModelToolDescription
20
+ from unique_toolkit.language_model.schemas import (
21
+ LanguageModelFunction,
22
+ )
23
+ from unique_toolkit.language_model.service import LanguageModelService
24
+
25
+ ConfigType = TypeVar("ConfigType", bound=BaseToolConfig)
26
+
27
+ ToolBuildConfig.model_rebuild()
28
+
29
+
30
+ class Tool(ABC, Generic[ConfigType]):
31
+ name: str
32
+ settings: ToolBuildConfig
33
+
34
+ def display_name(self) -> str:
35
+ """The display name of the tool."""
36
+ return self.settings.display_name
37
+
38
+ def icon(self) -> str:
39
+ """The icon of the tool."""
40
+ return self.settings.icon
41
+
42
+ def selection_policy(self) -> ToolSelectionPolicy:
43
+ """The selection policy of the tool."""
44
+ return self.settings.selection_policy
45
+
46
+ def is_exclusive(self) -> bool:
47
+ """Whether the tool is exclusive or not."""
48
+ return self.settings.is_exclusive
49
+
50
+ def is_enabled(self) -> bool:
51
+ """Whether the tool is enabled or not."""
52
+ return self.settings.is_enabled
53
+
54
+ def takes_control(self):
55
+ """
56
+ Some tools require to take control of the conversation with the user and do not want the orchestrator to intervene.
57
+ this function indicates whether the tool takes control or not. It yanks the control away from the orchestrator.
58
+ A typical use-case is deep-research.
59
+ """
60
+ return False
61
+
62
+ @property
63
+ def configuration(self) -> BaseToolConfig:
64
+ """The configuration of the tool."""
65
+ return self.settings.configuration
66
+
67
+ @abstractmethod
68
+ def tool_description(self) -> LanguageModelToolDescription:
69
+ raise NotImplementedError
70
+
71
+ def tool_description_as_json(self) -> dict[str, Any]:
72
+ parameters = self.tool_description().parameters
73
+ if not isinstance(parameters, dict):
74
+ return parameters.model_json_schema()
75
+ else:
76
+ return cast("dict[str, Any]", parameters)
77
+
78
+ # TODO: This method should be a property
79
+ def tool_description_for_system_prompt(self) -> str:
80
+ return ""
81
+
82
+ # TODO: This method should be a property
83
+ def tool_format_information_for_system_prompt(self) -> str:
84
+ return ""
85
+
86
+ # TODO: This method should be a property
87
+ def tool_description_for_user_prompt(self) -> str:
88
+ return ""
89
+
90
+ # TODO: This method should be a property
91
+ def tool_format_information_for_user_prompt(self) -> str:
92
+ return ""
93
+
94
+ # TODO: This method should be a property
95
+ def tool_format_reminder_for_user_prompt(self) -> str:
96
+ """A short reminder for the user prompt for formatting rules for the tool.
97
+ You can use this if the LLM fails to follow the formatting rules.
98
+ """
99
+ return ""
100
+
101
+ @deprecated(
102
+ "Do not use. The tool should not determine how"
103
+ "it is checked. This should be defined by the user"
104
+ "of the tool."
105
+ )
106
+ @abstractmethod
107
+ def evaluation_check_list(self) -> list[EvaluationMetricName]:
108
+ raise NotImplementedError
109
+
110
+ @abstractmethod
111
+ async def run(self, tool_call: LanguageModelFunction) -> ToolCallResponse:
112
+ raise NotImplementedError
113
+
114
+ @deprecated(
115
+ "Do not use as the evaluation checks should not be determined by\n"
116
+ "the tool. The decision on what check should be done is up to the\n"
117
+ "user of the tool or the dev.",
118
+ )
119
+ @abstractmethod
120
+ def get_evaluation_checks_based_on_tool_response(
121
+ self,
122
+ tool_response: ToolCallResponse,
123
+ ) -> list[EvaluationMetricName]:
124
+ raise NotImplementedError
125
+
126
+ def get_tool_prompts(self) -> ToolPrompts:
127
+ return ToolPrompts(
128
+ name=self.name,
129
+ display_name=self.display_name(),
130
+ tool_description=self.tool_description().description,
131
+ tool_system_prompt=self.tool_description_for_system_prompt(),
132
+ tool_format_information_for_system_prompt=self.tool_format_information_for_system_prompt(),
133
+ input_model=self.tool_description_as_json(),
134
+ tool_user_prompt=self.tool_description_for_user_prompt(),
135
+ tool_format_information_for_user_prompt=self.tool_format_information_for_user_prompt(),
136
+ )
137
+
138
+ # Properties that we should soon deprecate
139
+
140
+ @property
141
+ @deprecated("Never reuse event. Dangerous")
142
+ def event(self) -> ChatEvent:
143
+ return self._event
144
+
145
+ @property
146
+ @deprecated("Do not use this property as directly tied to chat frontend")
147
+ def chat_service(self) -> ChatService:
148
+ return self._chat_service
149
+
150
+ @property
151
+ @deprecated("Do not use this property as directly tied to chat frontend")
152
+ def language_model_service(self) -> LanguageModelService:
153
+ return self._language_model_service
154
+
155
+ @property
156
+ @deprecated("Do not use this as directly tied to chat frontend")
157
+ def tool_progress_reporter(self) -> ToolProgressReporter | None:
158
+ return self._tool_progress_reporter
159
+
160
+ def __init__(
161
+ self,
162
+ config: ConfigType,
163
+ event: ChatEvent,
164
+ tool_progress_reporter: ToolProgressReporter | None = None,
165
+ ):
166
+ self.settings = ToolBuildConfig(
167
+ name=self.name,
168
+ configuration=config,
169
+ )
170
+
171
+ self.config = config
172
+ module_name = "default overwrite for module name"
173
+ self.logger = getLogger(f"{module_name}.{__name__}")
174
+ self.debug_info: dict = {}
175
+
176
+ # TODO: Remove these properties as soon as possible
177
+ self._event: ChatEvent = event
178
+ self._tool_progress_reporter: ToolProgressReporter | None = (
179
+ tool_progress_reporter
180
+ )
181
+
182
+ self._chat_service = ChatService(event)
183
+ self._language_model_service = LanguageModelService(event)