unique_orchestrator 2026.30.0.dev3__tar.gz → 2026.30.0.dev5__tar.gz
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.
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/PKG-INFO +3 -3
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/pyproject.toml +3 -3
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/config.py +16 -9
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_config_services_validators.py +34 -19
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +28 -4
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +119 -3
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/unique_ai.py +68 -13
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/README.md +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/history_manager.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/skill_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/settings.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/unique_ai_builder.py +0 -0
- {unique_orchestrator-2026.30.0.dev3 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-orchestrator
|
|
3
|
-
Version: 2026.30.0.
|
|
3
|
+
Version: 2026.30.0.dev5
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Andreas Hauri
|
|
6
6
|
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
@@ -9,12 +9,12 @@ Requires-Dist: pydantic>=2.8.2,<3
|
|
|
9
9
|
Requires-Dist: pydantic-settings>=2.10.1,<3
|
|
10
10
|
Requires-Dist: typing-extensions>=4.9.0,<5
|
|
11
11
|
Requires-Dist: jinja2>=3.1.0,<4
|
|
12
|
-
Requires-Dist: unique-toolkit>=2026.30.0.
|
|
12
|
+
Requires-Dist: unique-toolkit>=2026.30.0.dev15,<2026.30.0rc0
|
|
13
13
|
Requires-Dist: unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0
|
|
14
14
|
Requires-Dist: unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0
|
|
15
15
|
Requires-Dist: unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0
|
|
16
16
|
Requires-Dist: unique-deep-research>=2026.30.0.dev0,<2026.30.0rc0
|
|
17
|
-
Requires-Dist: unique-web-search>=2026.30.0.
|
|
17
|
+
Requires-Dist: unique-web-search>=2026.30.0.dev3,<2026.30.0rc0
|
|
18
18
|
Requires-Dist: unique-swot>=2026.30.0.dev0,<2026.30.0rc0
|
|
19
19
|
Requires-Dist: unique-skill-tool>=2026.30.0.dev0,<2026.30.0rc0
|
|
20
20
|
Requires-Dist: unique-user-memory>=2026.30.0.dev0,<2026.30.0rc0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_orchestrator"
|
|
3
|
-
version = "2026.30.0.
|
|
3
|
+
version = "2026.30.0.dev5"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -13,12 +13,12 @@ dependencies = [
|
|
|
13
13
|
"pydantic-settings>=2.10.1,<3",
|
|
14
14
|
"typing-extensions>=4.9.0,<5",
|
|
15
15
|
"jinja2>=3.1.0,<4",
|
|
16
|
-
"unique-toolkit>=2026.30.0.
|
|
16
|
+
"unique-toolkit>=2026.30.0.dev15,<2026.30.0rc0",
|
|
17
17
|
"unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0",
|
|
18
18
|
"unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0",
|
|
19
19
|
"unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0",
|
|
20
20
|
"unique-deep-research>=2026.30.0.dev0,<2026.30.0rc0",
|
|
21
|
-
"unique-web-search>=2026.30.0.
|
|
21
|
+
"unique-web-search>=2026.30.0.dev3,<2026.30.0rc0",
|
|
22
22
|
"unique-swot>=2026.30.0.dev0,<2026.30.0rc0",
|
|
23
23
|
"unique-skill-tool>=2026.30.0.dev0,<2026.30.0rc0",
|
|
24
24
|
"unique-user-memory>=2026.30.0.dev0,<2026.30.0rc0",
|
|
@@ -546,20 +546,27 @@ class UniqueAIConfig(BaseToolConfig):
|
|
|
546
546
|
return self
|
|
547
547
|
|
|
548
548
|
@model_validator(mode="after")
|
|
549
|
-
def
|
|
550
|
-
"""Auto-enable the Responses API for GPT-5.5
|
|
549
|
+
def enable_responses_api_for_gpt_55_and_gpt_56_models(self) -> "UniqueAIConfig":
|
|
550
|
+
"""Auto-enable the Responses API for GPT-5.5 and GPT-5.6 models.
|
|
551
551
|
|
|
552
|
-
TEMP FIX:
|
|
553
|
-
`reasoning_effort` on /v1/chat/completions and
|
|
552
|
+
TEMP FIX: GPT-5.5 and GPT-5.6 reject requests that combine `tools` with
|
|
553
|
+
`reasoning_effort` on /v1/chat/completions and demand /v1/responses.
|
|
554
554
|
Forcing the Responses API here avoids the OpenAI 400 error until the
|
|
555
555
|
runner can pick the right transport based on model capabilities.
|
|
556
556
|
Tracked in Jira: UN-20123.
|
|
557
557
|
"""
|
|
558
|
-
if
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
558
|
+
if self.space.language_model.name in {
|
|
559
|
+
LanguageModelName.AZURE_GPT_55_2026_0424,
|
|
560
|
+
LanguageModelName.AZURE_GPT_55_PRO_2026_0424,
|
|
561
|
+
LanguageModelName.AZURE_GPT_56_SOL_2026_0709,
|
|
562
|
+
LanguageModelName.AZURE_GPT_56_TERRA_2026_0709,
|
|
563
|
+
LanguageModelName.AZURE_GPT_56_LUNA_2026_0709,
|
|
564
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55,
|
|
565
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55_PRO,
|
|
566
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_SOL,
|
|
567
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_TERRA,
|
|
568
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_LUNA,
|
|
569
|
+
}:
|
|
563
570
|
self.agent.experimental.responses_api_config.use_responses_api = True
|
|
564
571
|
return self
|
|
565
572
|
|
|
@@ -293,14 +293,12 @@ class TestUniqueAIConfigOpenFileValidator:
|
|
|
293
293
|
assert config.agent.experimental.responses_api_config.use_responses_api is True
|
|
294
294
|
|
|
295
295
|
|
|
296
|
-
class
|
|
297
|
-
"""
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
Responses API on when either of these models is selected, regardless of
|
|
303
|
-
which tools are configured.
|
|
296
|
+
class TestUniqueAIConfigGpt55AndGpt56ResponsesApiValidator:
|
|
297
|
+
"""Tests for the GPT-5.5 and GPT-5.6 Responses API validator.
|
|
298
|
+
|
|
299
|
+
These models reject requests that combine `tools` with `reasoning_effort`
|
|
300
|
+
on /v1/chat/completions and demand /v1/responses. The validator forces the
|
|
301
|
+
Responses API on regardless of which tools are configured.
|
|
304
302
|
Tracked in Jira: UN-20123.
|
|
305
303
|
"""
|
|
306
304
|
|
|
@@ -309,13 +307,20 @@ class TestUniqueAIConfigGpt55ResponsesApiValidator:
|
|
|
309
307
|
[
|
|
310
308
|
LanguageModelName.AZURE_GPT_55_2026_0424,
|
|
311
309
|
LanguageModelName.AZURE_GPT_55_PRO_2026_0424,
|
|
310
|
+
LanguageModelName.AZURE_GPT_56_SOL_2026_0709,
|
|
311
|
+
LanguageModelName.AZURE_GPT_56_TERRA_2026_0709,
|
|
312
|
+
LanguageModelName.AZURE_GPT_56_LUNA_2026_0709,
|
|
313
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55,
|
|
314
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55_PRO,
|
|
315
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_SOL,
|
|
316
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_TERRA,
|
|
317
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_LUNA,
|
|
312
318
|
],
|
|
313
319
|
)
|
|
314
320
|
def test_enables_responses_api_for_affected_models(
|
|
315
321
|
self, model_name: LanguageModelName
|
|
316
322
|
):
|
|
317
|
-
"""
|
|
318
|
-
auto-enable use_responses_api so the runner routes to /v1/responses."""
|
|
323
|
+
"""Affected models must route through /v1/responses."""
|
|
319
324
|
model = LanguageModelInfo.from_name(model_name)
|
|
320
325
|
|
|
321
326
|
config = UniqueAIConfig(
|
|
@@ -329,14 +334,20 @@ class TestUniqueAIConfigGpt55ResponsesApiValidator:
|
|
|
329
334
|
[
|
|
330
335
|
LanguageModelName.AZURE_GPT_55_2026_0424,
|
|
331
336
|
LanguageModelName.AZURE_GPT_55_PRO_2026_0424,
|
|
337
|
+
LanguageModelName.AZURE_GPT_56_SOL_2026_0709,
|
|
338
|
+
LanguageModelName.AZURE_GPT_56_TERRA_2026_0709,
|
|
339
|
+
LanguageModelName.AZURE_GPT_56_LUNA_2026_0709,
|
|
340
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55,
|
|
341
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55_PRO,
|
|
342
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_SOL,
|
|
343
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_TERRA,
|
|
344
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_LUNA,
|
|
332
345
|
],
|
|
333
346
|
)
|
|
334
347
|
def test_enables_responses_api_for_affected_models_with_tools(
|
|
335
348
|
self, model_name: LanguageModelName
|
|
336
349
|
):
|
|
337
|
-
"""The
|
|
338
|
-
are configured — the GPT-5.5(-Pro) transport requirement is independent
|
|
339
|
-
of the Code Interpreter validator above."""
|
|
350
|
+
"""The transport requirement is independent of configured tools."""
|
|
340
351
|
model = LanguageModelInfo.from_name(model_name)
|
|
341
352
|
|
|
342
353
|
config = UniqueAIConfig(
|
|
@@ -349,10 +360,7 @@ class TestUniqueAIConfigGpt55ResponsesApiValidator:
|
|
|
349
360
|
assert config.agent.experimental.responses_api_config.use_responses_api is True
|
|
350
361
|
|
|
351
362
|
def test_does_not_enable_responses_api_for_other_models(self):
|
|
352
|
-
"""
|
|
353
|
-
validator must be a no-op even if the model supports the Responses API.
|
|
354
|
-
Otherwise the TEMP FIX could mask real configuration issues for
|
|
355
|
-
unaffected models."""
|
|
363
|
+
"""The validator must not affect unrelated Responses-capable models."""
|
|
356
364
|
config = _make_config(tools=[], supports_responses_api=True)
|
|
357
365
|
|
|
358
366
|
assert config.agent.experimental.responses_api_config.use_responses_api is False
|
|
@@ -362,13 +370,20 @@ class TestUniqueAIConfigGpt55ResponsesApiValidator:
|
|
|
362
370
|
[
|
|
363
371
|
LanguageModelName.AZURE_GPT_55_2026_0424,
|
|
364
372
|
LanguageModelName.AZURE_GPT_55_PRO_2026_0424,
|
|
373
|
+
LanguageModelName.AZURE_GPT_56_SOL_2026_0709,
|
|
374
|
+
LanguageModelName.AZURE_GPT_56_TERRA_2026_0709,
|
|
375
|
+
LanguageModelName.AZURE_GPT_56_LUNA_2026_0709,
|
|
376
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55,
|
|
377
|
+
LanguageModelName.LITELLM_OPENAI_GPT_55_PRO,
|
|
378
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_SOL,
|
|
379
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_TERRA,
|
|
380
|
+
LanguageModelName.LITELLM_OPENAI_GPT_56_LUNA,
|
|
365
381
|
],
|
|
366
382
|
)
|
|
367
383
|
def test_keeps_responses_api_enabled_when_already_enabled(
|
|
368
384
|
self, model_name: LanguageModelName
|
|
369
385
|
):
|
|
370
|
-
"""
|
|
371
|
-
validator must leave it True (i.e. it is idempotent for those models)."""
|
|
386
|
+
"""The validator must remain idempotent."""
|
|
372
387
|
from unique_orchestrator.config import ExperimentalConfig, ResponsesApiConfig
|
|
373
388
|
|
|
374
389
|
model = LanguageModelInfo.from_name(model_name)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
+
from datetime import datetime, timezone
|
|
2
3
|
from unittest.mock import AsyncMock, MagicMock
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
@@ -735,6 +736,27 @@ class TestRunExecutionTimingIntegration:
|
|
|
735
736
|
assert "loop_iterations" in payload
|
|
736
737
|
assert "total_time" in payload
|
|
737
738
|
|
|
739
|
+
@pytest.mark.ai
|
|
740
|
+
@pytest.mark.asyncio
|
|
741
|
+
async def test_run__analytics__includes_total_time_to_answer_ms(
|
|
742
|
+
self, monkeypatch
|
|
743
|
+
) -> None:
|
|
744
|
+
"""
|
|
745
|
+
Purpose: Verify analytics measures from user creation through completion.
|
|
746
|
+
Why this matters: Internal loop timing excludes queueing and completion latency.
|
|
747
|
+
Setup summary: Return a known completion timestamp and assert the full delta.
|
|
748
|
+
"""
|
|
749
|
+
ua = self._build_run_ua(monkeypatch)
|
|
750
|
+
ua._event.payload.user_message.created_at = "2026-07-13T20:00:00Z"
|
|
751
|
+
ua._chat_service.modify_assistant_message_async.return_value = MagicMock(
|
|
752
|
+
completed_at=datetime(2026, 7, 13, 20, 0, 2, 345000, tzinfo=timezone.utc)
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
await ua.run()
|
|
756
|
+
|
|
757
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
758
|
+
assert analytics_call.kwargs["total_time_to_answer_ms"] == 2345
|
|
759
|
+
|
|
738
760
|
@pytest.mark.ai
|
|
739
761
|
@pytest.mark.asyncio
|
|
740
762
|
async def test_run__execution_times__contains_one_entry_after_single_iteration(
|
|
@@ -836,10 +858,10 @@ class TestRunExecutionTimingIntegration:
|
|
|
836
858
|
) -> None:
|
|
837
859
|
"""
|
|
838
860
|
Purpose: Verify that a failure in update_debug_info_async propagates to the caller.
|
|
839
|
-
Why this matters:
|
|
840
|
-
|
|
861
|
+
Why this matters: Debug failures remain visible without preventing answer
|
|
862
|
+
completion.
|
|
841
863
|
Setup summary: Make update_debug_info_async raise; run; assert RuntimeError
|
|
842
|
-
propagates
|
|
864
|
+
propagates after modify_assistant_message_async completes the answer.
|
|
843
865
|
"""
|
|
844
866
|
ua = self._build_run_ua(monkeypatch)
|
|
845
867
|
ua._chat_service.update_debug_info_async.side_effect = RuntimeError("boom")
|
|
@@ -847,7 +869,9 @@ class TestRunExecutionTimingIntegration:
|
|
|
847
869
|
with pytest.raises(RuntimeError, match="boom"):
|
|
848
870
|
await ua.run()
|
|
849
871
|
|
|
850
|
-
ua._chat_service.modify_assistant_message_async.
|
|
872
|
+
ua._chat_service.modify_assistant_message_async.assert_awaited_once_with(
|
|
873
|
+
set_completed_at=True
|
|
874
|
+
)
|
|
851
875
|
|
|
852
876
|
@pytest.mark.ai
|
|
853
877
|
@pytest.mark.asyncio
|
|
@@ -76,6 +76,7 @@ def _make_loop_response() -> MagicMock:
|
|
|
76
76
|
response.message = MagicMock()
|
|
77
77
|
response.message.references = []
|
|
78
78
|
response.message.text = "response text"
|
|
79
|
+
response.message.original_text = "original response text"
|
|
79
80
|
response.is_empty.return_value = False
|
|
80
81
|
return response
|
|
81
82
|
|
|
@@ -256,6 +257,9 @@ class TestRunLoopDebugParams:
|
|
|
256
257
|
mock_chat_service.cancellation = mock_cancellation
|
|
257
258
|
mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
|
|
258
259
|
mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
|
|
260
|
+
# Returns None by default → no completed_at, so total_time_to_answer_ms
|
|
261
|
+
# resolves to None. Tests that exercise timing override this return value
|
|
262
|
+
# with a MagicMock(completed_at=...).
|
|
259
263
|
mock_chat_service.modify_assistant_message_async = AsyncMock(return_value=None)
|
|
260
264
|
mock_chat_service.create_assistant_message_async = AsyncMock(
|
|
261
265
|
return_value=MagicMock(id="assist_new")
|
|
@@ -279,6 +283,9 @@ class TestRunLoopDebugParams:
|
|
|
279
283
|
mock_config = MagicMock()
|
|
280
284
|
mock_config.effective_max_loop_iterations = 1
|
|
281
285
|
mock_config.agent.prompt_config.user_metadata = []
|
|
286
|
+
mock_config.space.language_model.name = "AZURE_GPT_5_2025_0807"
|
|
287
|
+
mock_config.space.language_model.family = "openai"
|
|
288
|
+
mock_config.space.language_model.provider = "AZURE"
|
|
282
289
|
|
|
283
290
|
mock_history_manager = MagicMock()
|
|
284
291
|
mock_history_manager.get_history_for_model_call = AsyncMock(
|
|
@@ -345,6 +352,10 @@ class TestRunLoopDebugParams:
|
|
|
345
352
|
as the first argument.
|
|
346
353
|
"""
|
|
347
354
|
ua = self._build_run_ua(monkeypatch)
|
|
355
|
+
tool = MagicMock()
|
|
356
|
+
tool.name = "InternalSearch"
|
|
357
|
+
tool.display_name.return_value = "Knowledge Base Search"
|
|
358
|
+
ua._tool_manager.available_tools = [tool]
|
|
348
359
|
|
|
349
360
|
await ua.run()
|
|
350
361
|
|
|
@@ -353,14 +364,119 @@ class TestRunLoopDebugParams:
|
|
|
353
364
|
]
|
|
354
365
|
assert "loop_params" in keys_written
|
|
355
366
|
assert "skills" in keys_written
|
|
367
|
+
# This test owns one contract: run() forwards the same skills value it
|
|
368
|
+
# wrote under "skills" as the first positional arg to add_analytics.
|
|
369
|
+
# Per-field kwargs (references / timing / lengths) are covered by the
|
|
370
|
+
# focused sibling tests below, so we don't re-pin the whole call here.
|
|
356
371
|
skills_call = next(
|
|
357
372
|
c
|
|
358
373
|
for c in ua._debug_info_manager.add.call_args_list
|
|
359
374
|
if c.args[0] == "skills"
|
|
360
375
|
)
|
|
361
|
-
ua._debug_info_manager.add_analytics.
|
|
362
|
-
|
|
363
|
-
|
|
376
|
+
ua._debug_info_manager.add_analytics.assert_called_once()
|
|
377
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
378
|
+
assert analytics_call.args[0] == skills_call.args[1]
|
|
379
|
+
assert analytics_call.kwargs["tool_display_names"] == {
|
|
380
|
+
"InternalSearch": "Knowledge Base Search"
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
@pytest.mark.ai
|
|
384
|
+
@pytest.mark.asyncio
|
|
385
|
+
async def test_run__analytics__counts_unique_reference_sources(
|
|
386
|
+
self, monkeypatch
|
|
387
|
+
) -> None:
|
|
388
|
+
ua = self._build_run_ua(monkeypatch)
|
|
389
|
+
references = [
|
|
390
|
+
MagicMock(url="source_1"),
|
|
391
|
+
MagicMock(url="source_1"),
|
|
392
|
+
MagicMock(url="source_2"),
|
|
393
|
+
MagicMock(url="source_3"),
|
|
394
|
+
MagicMock(url="source_3"),
|
|
395
|
+
]
|
|
396
|
+
ua._loop_iteration_runner.return_value.message.references = references
|
|
397
|
+
ua._reference_manager.get_references.return_value = [references]
|
|
398
|
+
|
|
399
|
+
await ua.run()
|
|
400
|
+
|
|
401
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
402
|
+
assert analytics_call.kwargs["references"] == 3
|
|
403
|
+
|
|
404
|
+
@pytest.mark.ai
|
|
405
|
+
@pytest.mark.asyncio
|
|
406
|
+
async def test_run__analytics__counts_references_across_iterations(
|
|
407
|
+
self, monkeypatch
|
|
408
|
+
) -> None:
|
|
409
|
+
"""
|
|
410
|
+
Purpose: Verify analytics includes reference sources from every loop iteration.
|
|
411
|
+
Why this matters: Earlier citations belong to the same run and must not be
|
|
412
|
+
omitted when a later iteration appends another reference batch.
|
|
413
|
+
Setup summary: Return two stored batches with one duplicate source and assert
|
|
414
|
+
that all three distinct sources are counted.
|
|
415
|
+
"""
|
|
416
|
+
ua = self._build_run_ua(monkeypatch)
|
|
417
|
+
first_iteration_references = [
|
|
418
|
+
MagicMock(url="source_1"),
|
|
419
|
+
MagicMock(url="source_2"),
|
|
420
|
+
]
|
|
421
|
+
latest_iteration_references = [
|
|
422
|
+
MagicMock(url="source_2"),
|
|
423
|
+
MagicMock(url="source_3"),
|
|
424
|
+
]
|
|
425
|
+
ua._reference_manager.get_references.return_value = [
|
|
426
|
+
first_iteration_references,
|
|
427
|
+
latest_iteration_references,
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
await ua.run()
|
|
431
|
+
|
|
432
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
433
|
+
assert analytics_call.kwargs["references"] == 3
|
|
434
|
+
|
|
435
|
+
@pytest.mark.ai
|
|
436
|
+
@pytest.mark.asyncio
|
|
437
|
+
async def test_run__analytics__counts_distinct_references_without_urls(
|
|
438
|
+
self, monkeypatch
|
|
439
|
+
) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Purpose: Verify URL-less references are identified by source and source ID.
|
|
442
|
+
Why this matters: MCP citations can omit URLs and must not collapse into one
|
|
443
|
+
analytics source.
|
|
444
|
+
Setup summary: Run with two distinct URL-less references and assert both count.
|
|
445
|
+
"""
|
|
446
|
+
ua = self._build_run_ua(monkeypatch)
|
|
447
|
+
references = [
|
|
448
|
+
MagicMock(url=None, source="mcp", source_id="resource_1"),
|
|
449
|
+
MagicMock(url=None, source="mcp", source_id="resource_2"),
|
|
450
|
+
]
|
|
451
|
+
ua._loop_iteration_runner.return_value.message.references = references
|
|
452
|
+
ua._reference_manager.get_references.return_value = [references]
|
|
453
|
+
|
|
454
|
+
await ua.run()
|
|
455
|
+
|
|
456
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
457
|
+
assert analytics_call.kwargs["references"] == 2
|
|
458
|
+
|
|
459
|
+
@pytest.mark.ai
|
|
460
|
+
@pytest.mark.asyncio
|
|
461
|
+
async def test_run__analytics__total_time_to_answer_ms_none_without_completed_at(
|
|
462
|
+
self, monkeypatch
|
|
463
|
+
) -> None:
|
|
464
|
+
"""
|
|
465
|
+
Purpose: Verify total_time_to_answer_ms is None when the completed message
|
|
466
|
+
carries no completed_at.
|
|
467
|
+
Why this matters: Makes the None case deliberate — run() must degrade to
|
|
468
|
+
None (not crash) when completion time is unavailable, e.g. a tool took
|
|
469
|
+
control. Pairs with test_run__analytics__includes_total_time_to_answer_ms
|
|
470
|
+
(execution-timing suite) which covers the populated case.
|
|
471
|
+
Setup summary: modify_assistant_message_async returns None (fixture default);
|
|
472
|
+
assert the kwarg is present and None.
|
|
473
|
+
"""
|
|
474
|
+
ua = self._build_run_ua(monkeypatch)
|
|
475
|
+
|
|
476
|
+
await ua.run()
|
|
477
|
+
|
|
478
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
479
|
+
assert analytics_call.kwargs["total_time_to_answer_ms"] is None
|
|
364
480
|
|
|
365
481
|
@pytest.mark.ai
|
|
366
482
|
@pytest.mark.asyncio
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
4
|
from logging import Logger
|
|
5
5
|
from typing import Any, cast, overload
|
|
6
6
|
|
|
@@ -8,6 +8,7 @@ import jinja2
|
|
|
8
8
|
from typing_extensions import deprecated
|
|
9
9
|
from unique_skill_tool.service import SkillTool
|
|
10
10
|
from unique_toolkit.agentic.debug_info_manager.debug_info_manager import (
|
|
11
|
+
AnalyticsLanguageModel,
|
|
11
12
|
DebugInfoManager,
|
|
12
13
|
)
|
|
13
14
|
from unique_toolkit.agentic.evaluation.evaluation_manager import EvaluationManager
|
|
@@ -261,9 +262,8 @@ class UniqueAI:
|
|
|
261
262
|
self._finalize_loop_timing(loop_start)
|
|
262
263
|
break
|
|
263
264
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
265
|
+
references = loop_response.message.references or []
|
|
266
|
+
self._reference_manager.add_references(references)
|
|
267
267
|
self._last_assistant_text = (
|
|
268
268
|
loop_response.message.original_text or loop_response.message.text
|
|
269
269
|
)
|
|
@@ -318,12 +318,53 @@ class UniqueAI:
|
|
|
318
318
|
self._debug_info_manager.add("loop_params", self._loop_debug_params)
|
|
319
319
|
skills_debug_info = self._get_activated_skills_debug_info()
|
|
320
320
|
self._debug_info_manager.add("skills", skills_debug_info)
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
reference_sources = {
|
|
322
|
+
("url", reference.url)
|
|
323
|
+
if reference.url is not None
|
|
324
|
+
else ("source", reference.source, reference.source_id)
|
|
325
|
+
for references in self._reference_manager.get_references()
|
|
326
|
+
for reference in references
|
|
327
|
+
}
|
|
328
|
+
language_model = self._config.space.language_model
|
|
329
|
+
tool_display_names = {
|
|
330
|
+
str(tool.name): tool.display_name() or str(tool.name)
|
|
331
|
+
for tool in self._tool_manager.available_tools
|
|
332
|
+
}
|
|
323
333
|
tool_names = [
|
|
324
334
|
tool["name"] for tool in self._debug_info_manager.get()["tools"]
|
|
325
335
|
]
|
|
326
336
|
|
|
337
|
+
total_time_to_answer_ms: int | None = None
|
|
338
|
+
if not self._chat_service.cancellation.is_cancelled:
|
|
339
|
+
if self._config.agent.input_token_distribution.enable_tool_call_persistence:
|
|
340
|
+
await self._persist_tool_calls()
|
|
341
|
+
completed_message = (
|
|
342
|
+
await self._chat_service.modify_assistant_message_async(
|
|
343
|
+
set_completed_at=not self._tool_took_control,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
total_time_to_answer_ms = self._calculate_total_time_to_answer_ms(
|
|
347
|
+
user_message_created_at=self._event.payload.user_message.created_at,
|
|
348
|
+
assistant_completed_at=getattr(
|
|
349
|
+
completed_message, "completed_at", None
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
self._debug_info_manager.add_analytics(
|
|
354
|
+
skills_debug_info,
|
|
355
|
+
language_model=AnalyticsLanguageModel(
|
|
356
|
+
name=str(language_model.name),
|
|
357
|
+
family=str(language_model.family),
|
|
358
|
+
provider=str(language_model.provider),
|
|
359
|
+
),
|
|
360
|
+
tool_display_names=tool_display_names,
|
|
361
|
+
references=len(reference_sources),
|
|
362
|
+
user_prompt_length=len(self._event.payload.user_message.text),
|
|
363
|
+
answer_length=len(self._last_assistant_text or ""),
|
|
364
|
+
loop_iteration_count=len(self._execution_times),
|
|
365
|
+
total_time_to_answer_ms=total_time_to_answer_ms,
|
|
366
|
+
)
|
|
367
|
+
|
|
327
368
|
# Get current debug info from chat service and add debug info from run. Do not update if DeepResearch is in the tool names.
|
|
328
369
|
if "DeepResearch" not in tool_names:
|
|
329
370
|
debug_info = {
|
|
@@ -331,16 +372,30 @@ class UniqueAI:
|
|
|
331
372
|
**self._debug_info_manager.get(),
|
|
332
373
|
}
|
|
333
374
|
await self._chat_service.update_debug_info_async(debug_info=debug_info)
|
|
334
|
-
|
|
335
|
-
if not self._chat_service.cancellation.is_cancelled:
|
|
336
|
-
if self._config.agent.input_token_distribution.enable_tool_call_persistence:
|
|
337
|
-
await self._persist_tool_calls()
|
|
338
|
-
await self._chat_service.modify_assistant_message_async(
|
|
339
|
-
set_completed_at=not self._tool_took_control,
|
|
340
|
-
)
|
|
341
375
|
finally:
|
|
342
376
|
sub.cancel()
|
|
343
377
|
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _calculate_total_time_to_answer_ms(
|
|
380
|
+
user_message_created_at: str,
|
|
381
|
+
assistant_completed_at: datetime | None,
|
|
382
|
+
) -> int | None:
|
|
383
|
+
if assistant_completed_at is None:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
user_created_at = datetime.fromisoformat(user_message_created_at)
|
|
388
|
+
except (ValueError, TypeError):
|
|
389
|
+
# ValueError: malformed timestamp; TypeError: created_at not a str.
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
if user_created_at.tzinfo is None:
|
|
393
|
+
user_created_at = user_created_at.replace(tzinfo=timezone.utc)
|
|
394
|
+
if assistant_completed_at.tzinfo is None:
|
|
395
|
+
assistant_completed_at = assistant_completed_at.replace(tzinfo=timezone.utc)
|
|
396
|
+
|
|
397
|
+
return round((assistant_completed_at - user_created_at).total_seconds() * 1000)
|
|
398
|
+
|
|
344
399
|
def _record_loop_debug_params(self, other_options: dict) -> None:
|
|
345
400
|
reasoning = other_options.get("reasoning")
|
|
346
401
|
thinking_level: str = (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|