unique_orchestrator 2026.30.0.dev6__tar.gz → 2026.30.0.dev8__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.dev6 → unique_orchestrator-2026.30.0.dev8}/PKG-INFO +3 -3
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/pyproject.toml +3 -3
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_async_modify.py +29 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +6 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +29 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_reference_order.py +1 -0
- unique_orchestrator-2026.30.0.dev8/unique_orchestrator/tests/test_unique_ai_token_usage.py +480 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai.py +63 -2
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/README.md +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/history_manager.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/skill_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/config.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/settings.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai_builder.py +0 -0
- {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/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.dev8
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Andreas Hauri
|
|
6
6
|
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
@@ -9,9 +9,9 @@ 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.dev20,<2026.30.0rc0
|
|
13
13
|
Requires-Dist: unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0
|
|
14
|
-
Requires-Dist: unique-follow-up-questions>=2026.30.0.
|
|
14
|
+
Requires-Dist: unique-follow-up-questions>=2026.30.0.dev1,<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
17
|
Requires-Dist: unique-web-search>=2026.30.0.dev3,<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.dev8"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -13,9 +13,9 @@ 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.dev20,<2026.30.0rc0",
|
|
17
17
|
"unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0",
|
|
18
|
-
"unique-follow-up-questions>=2026.30.0.
|
|
18
|
+
"unique-follow-up-questions>=2026.30.0.dev1,<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
21
|
"unique-web-search>=2026.30.0.dev3,<2026.30.0rc0",
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
from unittest.mock import AsyncMock, MagicMock
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
+
from unique_toolkit.agentic.tools.openai_builtin.code_interpreter import (
|
|
7
|
+
DisplayCodeInterpreterFilesPostProcessor,
|
|
8
|
+
)
|
|
6
9
|
|
|
7
10
|
from unique_orchestrator.unique_ai import UniqueAI
|
|
8
11
|
|
|
@@ -53,10 +56,12 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
|
|
|
53
56
|
mock_postprocessor_manager = MagicMock()
|
|
54
57
|
mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
|
|
55
58
|
mock_postprocessor_manager.get_execution_times.return_value = {}
|
|
59
|
+
mock_postprocessor_manager.get_invocation_stats.return_value = []
|
|
56
60
|
|
|
57
61
|
mock_evaluation_manager = MagicMock()
|
|
58
62
|
mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
59
63
|
mock_evaluation_manager.get_execution_times.return_value = {}
|
|
64
|
+
mock_evaluation_manager.get_invocation_stats.return_value = []
|
|
60
65
|
|
|
61
66
|
dummy_event = MagicMock()
|
|
62
67
|
dummy_event.payload.assistant_message.id = "assist_1"
|
|
@@ -85,8 +90,11 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
|
|
|
85
90
|
ua._mcp_servers = []
|
|
86
91
|
ua._execution_times = []
|
|
87
92
|
ua._current_loop_timing = {}
|
|
93
|
+
ua._generated_files_info = None
|
|
94
|
+
ua._invocation_stats = []
|
|
88
95
|
ua.current_iteration_index = 0
|
|
89
96
|
ua._skill_choices = []
|
|
97
|
+
ua._loop_iteration_runner = MagicMock()
|
|
90
98
|
|
|
91
99
|
return ua
|
|
92
100
|
|
|
@@ -103,6 +111,7 @@ async def test_run_calls_modify_async_when_feature_flag_disabled(monkeypatch):
|
|
|
103
111
|
empty_response.message.references = []
|
|
104
112
|
empty_response.tool_calls = None
|
|
105
113
|
empty_response.is_empty.return_value = True
|
|
114
|
+
empty_response.usage = None
|
|
106
115
|
|
|
107
116
|
ua._plan_or_execute = AsyncMock(return_value=empty_response)
|
|
108
117
|
|
|
@@ -126,3 +135,23 @@ async def test_process_plan_calls_modify_async_on_empty_response(monkeypatch):
|
|
|
126
135
|
|
|
127
136
|
assert result is True
|
|
128
137
|
ua._chat_service.modify_assistant_message_async.assert_called()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.ai
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_handle_no_tool_calls_stores_returned_artifacts_for_analytics(
|
|
143
|
+
monkeypatch,
|
|
144
|
+
):
|
|
145
|
+
ua = _make_ua(monkeypatch)
|
|
146
|
+
artifacts = {"count": 1, "filetypes": ["csv"]}
|
|
147
|
+
ua._postprocessor_manager.run_postprocessors.return_value = {
|
|
148
|
+
DisplayCodeInterpreterFilesPostProcessor.__name__: artifacts
|
|
149
|
+
}
|
|
150
|
+
ua._current_loop_timing = {"post_processing": {}, "evaluation": {}}
|
|
151
|
+
|
|
152
|
+
response = MagicMock()
|
|
153
|
+
response.model_copy.return_value = response
|
|
154
|
+
|
|
155
|
+
await ua._handle_no_tool_calls(response)
|
|
156
|
+
|
|
157
|
+
assert ua._generated_files_info == artifacts
|
|
@@ -65,6 +65,7 @@ def _make_tool_response(name: str, execution_time_s: float | None = None) -> Mag
|
|
|
65
65
|
response.debug_info = (
|
|
66
66
|
{"execution_time_s": execution_time_s} if execution_time_s is not None else None
|
|
67
67
|
)
|
|
68
|
+
response.invocation_stats = []
|
|
68
69
|
return response
|
|
69
70
|
|
|
70
71
|
|
|
@@ -75,6 +76,7 @@ def _make_loop_response(tool_calls: list | None = None) -> MagicMock:
|
|
|
75
76
|
response.message.references = []
|
|
76
77
|
response.message.text = "response text"
|
|
77
78
|
response.is_empty.return_value = False
|
|
79
|
+
response.usage = None
|
|
78
80
|
return response
|
|
79
81
|
|
|
80
82
|
|
|
@@ -397,9 +399,11 @@ class TestHandleNoToolCallsTiming:
|
|
|
397
399
|
"evaluation": {},
|
|
398
400
|
}
|
|
399
401
|
instance._evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
402
|
+
instance._evaluation_manager.get_invocation_stats.return_value = []
|
|
400
403
|
instance._postprocessor_manager.run_postprocessors = AsyncMock(
|
|
401
404
|
return_value=None
|
|
402
405
|
)
|
|
406
|
+
instance._postprocessor_manager.get_invocation_stats.return_value = []
|
|
403
407
|
return instance
|
|
404
408
|
|
|
405
409
|
@pytest.mark.ai
|
|
@@ -666,10 +670,12 @@ class TestRunExecutionTimingIntegration:
|
|
|
666
670
|
mock_postprocessor_manager = MagicMock()
|
|
667
671
|
mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
|
|
668
672
|
mock_postprocessor_manager.get_execution_times.return_value = {}
|
|
673
|
+
mock_postprocessor_manager.get_invocation_stats.return_value = []
|
|
669
674
|
|
|
670
675
|
mock_evaluation_manager = MagicMock()
|
|
671
676
|
mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
672
677
|
mock_evaluation_manager.get_execution_times.return_value = {}
|
|
678
|
+
mock_evaluation_manager.get_invocation_stats.return_value = []
|
|
673
679
|
|
|
674
680
|
dummy_event = MagicMock()
|
|
675
681
|
dummy_event.payload.assistant_message.id = "assist_1"
|
|
@@ -78,6 +78,7 @@ def _make_loop_response() -> MagicMock:
|
|
|
78
78
|
response.message.text = "response text"
|
|
79
79
|
response.message.original_text = "original response text"
|
|
80
80
|
response.is_empty.return_value = False
|
|
81
|
+
response.usage = None
|
|
81
82
|
return response
|
|
82
83
|
|
|
83
84
|
|
|
@@ -478,6 +479,34 @@ class TestRunLoopDebugParams:
|
|
|
478
479
|
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
479
480
|
assert analytics_call.kwargs["total_time_to_answer_ms"] is None
|
|
480
481
|
|
|
482
|
+
@pytest.mark.ai
|
|
483
|
+
@pytest.mark.asyncio
|
|
484
|
+
async def test_run__generated_files_info__reset_at_start_prevents_stale_artifacts(
|
|
485
|
+
self, monkeypatch
|
|
486
|
+
) -> None:
|
|
487
|
+
"""
|
|
488
|
+
Purpose: Verify run() resets _generated_files_info at the start, so a rerun
|
|
489
|
+
that exits without reaching _handle_no_tool_calls does not report the previous
|
|
490
|
+
run's artifacts.
|
|
491
|
+
Why this matters: _generated_files_info persists on the UniqueAI instance;
|
|
492
|
+
without a reset it leaks a prior run's artifact count/filetypes into the
|
|
493
|
+
current answer's analytics (Cursor bot, UN-22110).
|
|
494
|
+
Setup summary: Seed a stale value, force an early loop exit via _process_plan
|
|
495
|
+
returning True (a tool took control — _handle_no_tool_calls never runs), and
|
|
496
|
+
assert add_analytics received artifacts=None.
|
|
497
|
+
"""
|
|
498
|
+
ua = self._build_run_ua(monkeypatch)
|
|
499
|
+
# Leftover from a hypothetical previous run on the same instance.
|
|
500
|
+
ua._generated_files_info = {"count": 99, "filetypes": ["stale"]}
|
|
501
|
+
# Exit the loop as if a tool took control: _handle_no_tool_calls (which sets
|
|
502
|
+
# _generated_files_info) is skipped, but finalization still calls add_analytics.
|
|
503
|
+
ua._process_plan = AsyncMock(return_value=True) # type: ignore[method-assign]
|
|
504
|
+
|
|
505
|
+
await ua.run()
|
|
506
|
+
|
|
507
|
+
analytics_call = ua._debug_info_manager.add_analytics.call_args
|
|
508
|
+
assert analytics_call.kwargs["artifacts"] is None
|
|
509
|
+
|
|
481
510
|
@pytest.mark.ai
|
|
482
511
|
@pytest.mark.asyncio
|
|
483
512
|
async def test_run__loop_params__contains_one_entry_per_iteration(
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unique_toolkit.agentic.loop_runner import SupportsInvocationStats
|
|
5
|
+
from unique_toolkit.agentic.tools.schemas import ToolCallResponse
|
|
6
|
+
from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
|
|
7
|
+
from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
|
|
8
|
+
|
|
9
|
+
from unique_orchestrator.unique_ai import UniqueAI
|
|
10
|
+
|
|
11
|
+
MODEL_NAME = "gpt-4"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_loop_response(usage: LanguageModelTokenUsage | None) -> MagicMock:
|
|
15
|
+
response = MagicMock()
|
|
16
|
+
response.tool_calls = None
|
|
17
|
+
response.message = MagicMock()
|
|
18
|
+
response.message.references = []
|
|
19
|
+
response.message.text = "response text"
|
|
20
|
+
response.is_empty.return_value = False
|
|
21
|
+
response.usage = usage
|
|
22
|
+
return response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_run_ua(loop_responses: list[MagicMock], max_iterations: int):
|
|
26
|
+
mock_cancellation = MagicMock()
|
|
27
|
+
mock_cancellation.is_cancelled = False
|
|
28
|
+
mock_cancellation.on_cancellation.subscribe = MagicMock(return_value=MagicMock())
|
|
29
|
+
mock_cancellation.check_cancellation_async = AsyncMock(return_value=False)
|
|
30
|
+
|
|
31
|
+
mock_chat_service = MagicMock()
|
|
32
|
+
mock_chat_service.cancellation = mock_cancellation
|
|
33
|
+
mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
|
|
34
|
+
mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
|
|
35
|
+
mock_chat_service.modify_assistant_message_async = AsyncMock(return_value=None)
|
|
36
|
+
mock_chat_service.create_assistant_message_async = AsyncMock(
|
|
37
|
+
return_value=MagicMock(id="assist_new")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
mock_tool_manager = MagicMock()
|
|
41
|
+
mock_tool_manager.available_tools = []
|
|
42
|
+
mock_tool_manager.get_forced_tools.return_value = []
|
|
43
|
+
mock_tool_manager.get_tool_definitions.return_value = []
|
|
44
|
+
mock_tool_manager.filter_duplicate_tool_calls.side_effect = lambda x: x
|
|
45
|
+
mock_tool_manager.filter_tool_calls_by_max_tool_calls_allowed.side_effect = (
|
|
46
|
+
lambda x: x
|
|
47
|
+
)
|
|
48
|
+
mock_tool_manager.does_a_tool_take_control.return_value = False
|
|
49
|
+
mock_tool_manager.should_stop_after_tool_calls.return_value = False
|
|
50
|
+
mock_tool_manager.get_evaluation_check_list.return_value = []
|
|
51
|
+
|
|
52
|
+
mock_debug_info_manager = MagicMock()
|
|
53
|
+
mock_debug_info_manager.get.return_value = {"tools": []}
|
|
54
|
+
|
|
55
|
+
mock_config = MagicMock()
|
|
56
|
+
mock_config.effective_max_loop_iterations = max_iterations
|
|
57
|
+
mock_config.agent.prompt_config.user_metadata = []
|
|
58
|
+
mock_config.space.language_model.name = MODEL_NAME
|
|
59
|
+
|
|
60
|
+
mock_history_manager = MagicMock()
|
|
61
|
+
mock_history_manager.get_history_for_model_call = AsyncMock(
|
|
62
|
+
return_value=MagicMock()
|
|
63
|
+
)
|
|
64
|
+
mock_history_manager._append_tool_calls_to_history = MagicMock()
|
|
65
|
+
mock_history_manager.add_tool_call_results = MagicMock()
|
|
66
|
+
mock_history_manager.extract_message_tools.return_value = []
|
|
67
|
+
|
|
68
|
+
mock_postprocessor_manager = MagicMock()
|
|
69
|
+
mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
|
|
70
|
+
mock_postprocessor_manager.get_execution_times.return_value = {}
|
|
71
|
+
mock_postprocessor_manager.get_invocation_stats.return_value = []
|
|
72
|
+
|
|
73
|
+
mock_evaluation_manager = MagicMock()
|
|
74
|
+
mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
75
|
+
mock_evaluation_manager.get_execution_times.return_value = {}
|
|
76
|
+
mock_evaluation_manager.get_invocation_stats.return_value = []
|
|
77
|
+
|
|
78
|
+
dummy_event = MagicMock()
|
|
79
|
+
dummy_event.payload.assistant_message.id = "assist_1"
|
|
80
|
+
dummy_event.payload.user_message.text = "query"
|
|
81
|
+
dummy_event.payload.assistant_id = "assistant_123"
|
|
82
|
+
dummy_event.payload.name = "TestAssistant"
|
|
83
|
+
dummy_event.payload.user_metadata = {}
|
|
84
|
+
dummy_event.payload.tool_parameters = {}
|
|
85
|
+
dummy_event.company_id = "company_1"
|
|
86
|
+
|
|
87
|
+
ua = UniqueAI(
|
|
88
|
+
logger=MagicMock(),
|
|
89
|
+
event=dummy_event,
|
|
90
|
+
config=mock_config,
|
|
91
|
+
chat_service=mock_chat_service,
|
|
92
|
+
content_service=MagicMock(),
|
|
93
|
+
debug_info_manager=mock_debug_info_manager,
|
|
94
|
+
streaming_handler=MagicMock(),
|
|
95
|
+
reference_manager=MagicMock(),
|
|
96
|
+
thinking_manager=MagicMock(),
|
|
97
|
+
tool_manager=mock_tool_manager,
|
|
98
|
+
history_manager=mock_history_manager,
|
|
99
|
+
evaluation_manager=mock_evaluation_manager,
|
|
100
|
+
postprocessor_manager=mock_postprocessor_manager,
|
|
101
|
+
message_step_logger=MagicMock(),
|
|
102
|
+
mcp_servers=[],
|
|
103
|
+
loop_iteration_runner=AsyncMock(side_effect=loop_responses),
|
|
104
|
+
)
|
|
105
|
+
ua._render_user_prompt = AsyncMock(return_value="user") # type: ignore[method-assign]
|
|
106
|
+
ua._render_system_prompt = AsyncMock(return_value="system") # type: ignore[method-assign]
|
|
107
|
+
ua._thinking_manager.thinking_is_displayed = MagicMock(return_value=True)
|
|
108
|
+
ua._thinking_manager.update_start_text = MagicMock(return_value="")
|
|
109
|
+
return ua
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestAccumulateUsage:
|
|
113
|
+
"""Tests for how UniqueAI._invocation_stats is populated from the main loop."""
|
|
114
|
+
|
|
115
|
+
def test_main_loop__none_usage__is_noop(self):
|
|
116
|
+
ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
|
|
117
|
+
assert ua._invocation_stats == []
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_main_loop__single_call__appends_one_entry(self):
|
|
121
|
+
loop_response = _make_loop_response(
|
|
122
|
+
LanguageModelTokenUsage(
|
|
123
|
+
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
ua = _build_run_ua([loop_response], max_iterations=1)
|
|
127
|
+
|
|
128
|
+
await ua.run()
|
|
129
|
+
|
|
130
|
+
assert len(ua._invocation_stats) == 1
|
|
131
|
+
entry = ua._invocation_stats[0]
|
|
132
|
+
assert entry.model_name == MODEL_NAME
|
|
133
|
+
assert entry.source == "main_loop[1]"
|
|
134
|
+
assert entry.token_usage == LanguageModelTokenUsage(
|
|
135
|
+
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_main_loop__multiple_iterations__appends_one_entry_per_call(self):
|
|
140
|
+
tool_call = MagicMock()
|
|
141
|
+
tool_call.name = "WebSearch"
|
|
142
|
+
|
|
143
|
+
first = _make_loop_response(
|
|
144
|
+
LanguageModelTokenUsage(
|
|
145
|
+
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
first.tool_calls = [tool_call]
|
|
149
|
+
second = _make_loop_response(
|
|
150
|
+
LanguageModelTokenUsage(
|
|
151
|
+
completion_tokens=1, prompt_tokens=2, total_tokens=3
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
ua = _build_run_ua([first, second], max_iterations=2)
|
|
155
|
+
ua._tool_manager.execute_selected_tools = AsyncMock(
|
|
156
|
+
return_value=[
|
|
157
|
+
ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
await ua.run()
|
|
162
|
+
|
|
163
|
+
assert len(ua._invocation_stats) == 2
|
|
164
|
+
assert [entry.token_usage.total_tokens for entry in ua._invocation_stats] == [
|
|
165
|
+
30,
|
|
166
|
+
3,
|
|
167
|
+
]
|
|
168
|
+
assert [entry.source for entry in ua._invocation_stats] == [
|
|
169
|
+
"main_loop[1]",
|
|
170
|
+
"main_loop[2]",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_main_loop__none_mixed_with_real__none_is_skipped(self):
|
|
175
|
+
tool_call = MagicMock()
|
|
176
|
+
tool_call.name = "WebSearch"
|
|
177
|
+
|
|
178
|
+
first = _make_loop_response(
|
|
179
|
+
LanguageModelTokenUsage(
|
|
180
|
+
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
first.tool_calls = [tool_call]
|
|
184
|
+
second = _make_loop_response(None)
|
|
185
|
+
ua = _build_run_ua([first, second], max_iterations=2)
|
|
186
|
+
ua._tool_manager.execute_selected_tools = AsyncMock(
|
|
187
|
+
return_value=[
|
|
188
|
+
ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await ua.run()
|
|
193
|
+
|
|
194
|
+
assert len(ua._invocation_stats) == 1
|
|
195
|
+
assert ua._invocation_stats[0].token_usage == LanguageModelTokenUsage(
|
|
196
|
+
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestRunTokenUsageIntegration:
|
|
201
|
+
@pytest.mark.ai
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_run__debug_info_manager__receives_llm_invocations_key(self):
|
|
204
|
+
"""A single-iteration run with real usage on the loop response must
|
|
205
|
+
surface it under debug_info's 'llm_invocations' key."""
|
|
206
|
+
loop_response = _make_loop_response(
|
|
207
|
+
LanguageModelTokenUsage(
|
|
208
|
+
completion_tokens=12, prompt_tokens=34, total_tokens=46
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
ua = _build_run_ua([loop_response], max_iterations=1)
|
|
212
|
+
|
|
213
|
+
await ua.run()
|
|
214
|
+
|
|
215
|
+
calls_by_key = {
|
|
216
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
217
|
+
}
|
|
218
|
+
assert "llm_invocations" in calls_by_key
|
|
219
|
+
payload = calls_by_key["llm_invocations"]
|
|
220
|
+
assert len(payload) == 1
|
|
221
|
+
assert payload[0]["modelName"] == MODEL_NAME
|
|
222
|
+
assert payload[0]["source"] == "main_loop[1]"
|
|
223
|
+
assert payload[0]["tokenUsage"] == {
|
|
224
|
+
"completionTokens": 12,
|
|
225
|
+
"promptTokens": 34,
|
|
226
|
+
"totalTokens": 46,
|
|
227
|
+
"reasoningTokens": None,
|
|
228
|
+
"cachedTokens": None,
|
|
229
|
+
"cacheWriteTokens": None,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@pytest.mark.ai
|
|
233
|
+
@pytest.mark.asyncio
|
|
234
|
+
async def test_run__no_usage_on_any_response__llm_invocations_is_empty(
|
|
235
|
+
self,
|
|
236
|
+
):
|
|
237
|
+
"""When usage is never populated (e.g. no supporting provider),
|
|
238
|
+
the 'llm_invocations' key must still be present as an empty list,
|
|
239
|
+
not absent/silent."""
|
|
240
|
+
ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
|
|
241
|
+
|
|
242
|
+
await ua.run()
|
|
243
|
+
|
|
244
|
+
calls_by_key = {
|
|
245
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
246
|
+
}
|
|
247
|
+
assert "llm_invocations" in calls_by_key
|
|
248
|
+
assert calls_by_key["llm_invocations"] == []
|
|
249
|
+
|
|
250
|
+
@pytest.mark.ai
|
|
251
|
+
@pytest.mark.asyncio
|
|
252
|
+
async def test_run__postprocessor_usage__added_to_loop_usage(self):
|
|
253
|
+
"""FollowUpPostprocessor (and any postprocessor) makes its own LLM
|
|
254
|
+
call outside _plan_or_execute() -- its usage must appear as its own
|
|
255
|
+
entry in the list, not silently dropped."""
|
|
256
|
+
loop_response = _make_loop_response(
|
|
257
|
+
LanguageModelTokenUsage(
|
|
258
|
+
completion_tokens=12, prompt_tokens=34, total_tokens=46
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
ua = _build_run_ua([loop_response], max_iterations=1)
|
|
262
|
+
ua._postprocessor_manager.get_invocation_stats.return_value = [
|
|
263
|
+
LanguageModelInvocationStats.from_usage(
|
|
264
|
+
MODEL_NAME,
|
|
265
|
+
LanguageModelTokenUsage(
|
|
266
|
+
completion_tokens=3, prompt_tokens=7, total_tokens=10
|
|
267
|
+
),
|
|
268
|
+
source="FollowUpPostprocessor",
|
|
269
|
+
)
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
await ua.run()
|
|
273
|
+
|
|
274
|
+
calls_by_key = {
|
|
275
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
276
|
+
}
|
|
277
|
+
payload = calls_by_key["llm_invocations"]
|
|
278
|
+
sources = [inv["source"] for inv in payload]
|
|
279
|
+
assert sources == ["main_loop[1]", "FollowUpPostprocessor"]
|
|
280
|
+
assert payload[0]["tokenUsage"]["totalTokens"] == 46
|
|
281
|
+
assert payload[1]["tokenUsage"]["totalTokens"] == 10
|
|
282
|
+
|
|
283
|
+
@pytest.mark.ai
|
|
284
|
+
@pytest.mark.asyncio
|
|
285
|
+
async def test_run__loop_runner_reports_invocation_stats__added_to_loop_usage(
|
|
286
|
+
self,
|
|
287
|
+
):
|
|
288
|
+
"""PlanningMiddleware (and any loop-runner wrapper implementing
|
|
289
|
+
SupportsInvocationStats) makes its own LLM call outside the main
|
|
290
|
+
completion -- e.g. a planning step before the iteration -- and its
|
|
291
|
+
usage must appear as its own entry in the list, not silently dropped."""
|
|
292
|
+
loop_response = _make_loop_response(
|
|
293
|
+
LanguageModelTokenUsage(
|
|
294
|
+
completion_tokens=12, prompt_tokens=34, total_tokens=46
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
ua = _build_run_ua([loop_response], max_iterations=1)
|
|
298
|
+
# A bare AsyncMock() satisfies hasattr() for any name but does NOT
|
|
299
|
+
# satisfy isinstance() against a runtime_checkable Protocol -- only
|
|
300
|
+
# spec=<the protocol> does, matching what real PlanningMiddleware
|
|
301
|
+
# instances trigger via isinstance() in production.
|
|
302
|
+
runner = AsyncMock(spec=SupportsInvocationStats)
|
|
303
|
+
runner.side_effect = [loop_response]
|
|
304
|
+
runner.get_invocation_stats.return_value = [
|
|
305
|
+
LanguageModelInvocationStats.from_usage(
|
|
306
|
+
MODEL_NAME,
|
|
307
|
+
LanguageModelTokenUsage(
|
|
308
|
+
completion_tokens=2, prompt_tokens=8, total_tokens=10
|
|
309
|
+
),
|
|
310
|
+
source="planning",
|
|
311
|
+
)
|
|
312
|
+
]
|
|
313
|
+
ua._loop_iteration_runner = runner
|
|
314
|
+
|
|
315
|
+
await ua.run()
|
|
316
|
+
|
|
317
|
+
calls_by_key = {
|
|
318
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
319
|
+
}
|
|
320
|
+
payload = calls_by_key["llm_invocations"]
|
|
321
|
+
sources = [inv["source"] for inv in payload]
|
|
322
|
+
assert sources == ["main_loop[1]", "planning"]
|
|
323
|
+
assert payload[0]["tokenUsage"]["totalTokens"] == 46
|
|
324
|
+
assert payload[1]["tokenUsage"]["totalTokens"] == 10
|
|
325
|
+
|
|
326
|
+
@pytest.mark.ai
|
|
327
|
+
@pytest.mark.asyncio
|
|
328
|
+
async def test_run__loop_runner_without_invocation_stats__is_a_noop(self):
|
|
329
|
+
"""A plain runner that doesn't implement SupportsInvocationStats
|
|
330
|
+
(the common case: Basic/Qwen/Mistral runners with no planning
|
|
331
|
+
middleware) must not contribute spurious entries."""
|
|
332
|
+
loop_response = _make_loop_response(
|
|
333
|
+
LanguageModelTokenUsage(
|
|
334
|
+
completion_tokens=12, prompt_tokens=34, total_tokens=46
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
ua = _build_run_ua([loop_response], max_iterations=1)
|
|
338
|
+
# spec restricts this mock to only __call__ -- no get_invocation_stats
|
|
339
|
+
# attribute exists, so isinstance(..., SupportsInvocationStats) is False,
|
|
340
|
+
# mirroring a real runner (Basic/Qwen/Mistral) that doesn't implement it.
|
|
341
|
+
ua._loop_iteration_runner = AsyncMock(
|
|
342
|
+
side_effect=[loop_response], spec=["__call__"]
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
await ua.run()
|
|
346
|
+
|
|
347
|
+
calls_by_key = {
|
|
348
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
349
|
+
}
|
|
350
|
+
payload = calls_by_key["llm_invocations"]
|
|
351
|
+
sources = [inv["source"] for inv in payload]
|
|
352
|
+
assert sources == ["main_loop[1]"]
|
|
353
|
+
|
|
354
|
+
@pytest.mark.ai
|
|
355
|
+
@pytest.mark.asyncio
|
|
356
|
+
async def test_run__tool_usage__added_to_loop_usage(self):
|
|
357
|
+
"""A tool's own internal LLM calls (e.g. web search argument screening)
|
|
358
|
+
report usage via ToolCallResponse.invocation_stats -- it must be folded
|
|
359
|
+
into the same total as the main loop, per tool-call iteration."""
|
|
360
|
+
tool_call = MagicMock()
|
|
361
|
+
tool_call.name = "WebSearch"
|
|
362
|
+
|
|
363
|
+
loop_response_with_tools = _make_loop_response(
|
|
364
|
+
LanguageModelTokenUsage(
|
|
365
|
+
completion_tokens=12, prompt_tokens=34, total_tokens=46
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
loop_response_with_tools.tool_calls = [tool_call]
|
|
369
|
+
loop_response_with_tools.is_empty.return_value = False
|
|
370
|
+
|
|
371
|
+
final_response = _make_loop_response(None)
|
|
372
|
+
|
|
373
|
+
ua = _build_run_ua([loop_response_with_tools, final_response], max_iterations=2)
|
|
374
|
+
ua._tool_manager.execute_selected_tools = AsyncMock(
|
|
375
|
+
return_value=[
|
|
376
|
+
ToolCallResponse(
|
|
377
|
+
id="call_1",
|
|
378
|
+
name="WebSearch",
|
|
379
|
+
invocation_stats=[
|
|
380
|
+
LanguageModelInvocationStats.from_usage(
|
|
381
|
+
MODEL_NAME,
|
|
382
|
+
LanguageModelTokenUsage(
|
|
383
|
+
completion_tokens=1, prompt_tokens=2, total_tokens=3
|
|
384
|
+
),
|
|
385
|
+
source="WebSearch",
|
|
386
|
+
)
|
|
387
|
+
],
|
|
388
|
+
)
|
|
389
|
+
]
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
await ua.run()
|
|
393
|
+
|
|
394
|
+
calls_by_key = {
|
|
395
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
396
|
+
}
|
|
397
|
+
payload = calls_by_key["llm_invocations"]
|
|
398
|
+
sources = [inv["source"] for inv in payload]
|
|
399
|
+
assert sources == ["main_loop[1]", "WebSearch"]
|
|
400
|
+
assert payload[0]["tokenUsage"]["totalTokens"] == 46
|
|
401
|
+
assert payload[1]["tokenUsage"]["totalTokens"] == 3
|
|
402
|
+
|
|
403
|
+
@pytest.mark.ai
|
|
404
|
+
@pytest.mark.asyncio
|
|
405
|
+
async def test_run__multiple_tool_calls_in_one_iteration__usage_summed(self):
|
|
406
|
+
"""Two (or more) tool calls in the same iteration -- e.g. two parallel
|
|
407
|
+
web searches -- must each contribute their own usage, not overwrite
|
|
408
|
+
or drop one another."""
|
|
409
|
+
tool_call = MagicMock()
|
|
410
|
+
tool_call.name = "WebSearch"
|
|
411
|
+
|
|
412
|
+
loop_response_with_tools = _make_loop_response(None)
|
|
413
|
+
loop_response_with_tools.tool_calls = [tool_call, tool_call]
|
|
414
|
+
loop_response_with_tools.is_empty.return_value = False
|
|
415
|
+
|
|
416
|
+
final_response = _make_loop_response(None)
|
|
417
|
+
|
|
418
|
+
ua = _build_run_ua([loop_response_with_tools, final_response], max_iterations=2)
|
|
419
|
+
ua._tool_manager.execute_selected_tools = AsyncMock(
|
|
420
|
+
return_value=[
|
|
421
|
+
ToolCallResponse(
|
|
422
|
+
id="call_1",
|
|
423
|
+
name="WebSearch",
|
|
424
|
+
invocation_stats=[
|
|
425
|
+
LanguageModelInvocationStats.from_usage(
|
|
426
|
+
MODEL_NAME,
|
|
427
|
+
LanguageModelTokenUsage(
|
|
428
|
+
completion_tokens=1, prompt_tokens=2, total_tokens=3
|
|
429
|
+
),
|
|
430
|
+
source="WebSearch",
|
|
431
|
+
)
|
|
432
|
+
],
|
|
433
|
+
),
|
|
434
|
+
ToolCallResponse(
|
|
435
|
+
id="call_2",
|
|
436
|
+
name="WebSearch",
|
|
437
|
+
invocation_stats=[
|
|
438
|
+
LanguageModelInvocationStats.from_usage(
|
|
439
|
+
MODEL_NAME,
|
|
440
|
+
LanguageModelTokenUsage(
|
|
441
|
+
completion_tokens=4, prompt_tokens=5, total_tokens=9
|
|
442
|
+
),
|
|
443
|
+
source="WebSearch",
|
|
444
|
+
)
|
|
445
|
+
],
|
|
446
|
+
),
|
|
447
|
+
]
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
await ua.run()
|
|
451
|
+
|
|
452
|
+
calls_by_key = {
|
|
453
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
454
|
+
}
|
|
455
|
+
payload = calls_by_key["llm_invocations"]
|
|
456
|
+
assert len(payload) == 2
|
|
457
|
+
assert payload[0]["tokenUsage"]["totalTokens"] == 3
|
|
458
|
+
assert payload[1]["tokenUsage"]["totalTokens"] == 9
|
|
459
|
+
|
|
460
|
+
@pytest.mark.ai
|
|
461
|
+
@pytest.mark.asyncio
|
|
462
|
+
async def test_run__reset_between_runs__does_not_leak_across_calls(self):
|
|
463
|
+
"""_invocation_stats must reset at the start of run(), so a second run()
|
|
464
|
+
on the same UniqueAI instance doesn't inherit the prior run's entries."""
|
|
465
|
+
loop_response = _make_loop_response(
|
|
466
|
+
LanguageModelTokenUsage(
|
|
467
|
+
completion_tokens=5, prompt_tokens=5, total_tokens=10
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
ua = _build_run_ua([loop_response, loop_response], max_iterations=1)
|
|
471
|
+
|
|
472
|
+
await ua.run()
|
|
473
|
+
await ua.run()
|
|
474
|
+
|
|
475
|
+
calls_by_key = {
|
|
476
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
477
|
+
}
|
|
478
|
+
payload = calls_by_key["llm_invocations"]
|
|
479
|
+
assert len(payload) == 1
|
|
480
|
+
assert payload[0]["tokenUsage"]["totalTokens"] == 10
|
|
@@ -21,6 +21,7 @@ from unique_toolkit.agentic.history_manager.utils import (
|
|
|
21
21
|
from unique_toolkit.agentic.loop_runner import (
|
|
22
22
|
LoopIterationRunner,
|
|
23
23
|
ResponsesLoopIterationRunner,
|
|
24
|
+
SupportsInvocationStats,
|
|
24
25
|
)
|
|
25
26
|
from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
|
|
26
27
|
from unique_toolkit.agentic.postprocessor.postprocessor_manager import (
|
|
@@ -32,6 +33,12 @@ from unique_toolkit.agentic.tools.experimental.open_file_tool import (
|
|
|
32
33
|
OpenFileToolRuntime,
|
|
33
34
|
OpenFileToolRuntimeConfig,
|
|
34
35
|
)
|
|
36
|
+
from unique_toolkit.agentic.tools.openai_builtin.code_interpreter import (
|
|
37
|
+
DisplayCodeInterpreterFilesPostProcessor,
|
|
38
|
+
)
|
|
39
|
+
from unique_toolkit.agentic.tools.openai_builtin.code_interpreter.postprocessors.generated_files import (
|
|
40
|
+
ArtifactsDebugInfo,
|
|
41
|
+
)
|
|
35
42
|
from unique_toolkit.agentic.tools.tool_manager import (
|
|
36
43
|
ResponsesApiToolManager,
|
|
37
44
|
SafeTaskExecutor,
|
|
@@ -43,6 +50,9 @@ from unique_toolkit.chat.service import ChatService
|
|
|
43
50
|
from unique_toolkit.content import Content
|
|
44
51
|
from unique_toolkit.content.service import ContentService
|
|
45
52
|
from unique_toolkit.language_model import LanguageModelAssistantMessage
|
|
53
|
+
from unique_toolkit.language_model.invocation_stats import (
|
|
54
|
+
LanguageModelInvocationStats,
|
|
55
|
+
)
|
|
46
56
|
from unique_toolkit.language_model.schemas import (
|
|
47
57
|
LanguageModelMessages,
|
|
48
58
|
LanguageModelStreamResponse,
|
|
@@ -197,6 +207,8 @@ class UniqueAI:
|
|
|
197
207
|
self._execution_times: list[dict[str, Any]] = []
|
|
198
208
|
self._current_loop_timing: dict[str, Any] = {}
|
|
199
209
|
self._loop_debug_params: list[dict[str, Any]] = []
|
|
210
|
+
self._generated_files_info: ArtifactsDebugInfo | None = None
|
|
211
|
+
self._invocation_stats: list[LanguageModelInvocationStats] = []
|
|
200
212
|
|
|
201
213
|
async def _on_cancellation(self, _event: CancellationEvent) -> None:
|
|
202
214
|
"""Subscriber called by the cancellation event bus."""
|
|
@@ -222,6 +234,11 @@ class UniqueAI:
|
|
|
222
234
|
|
|
223
235
|
self._execution_times = []
|
|
224
236
|
self._loop_debug_params = []
|
|
237
|
+
# Reset per-run artifact analytics too: a later run that exits without
|
|
238
|
+
# reaching _handle_no_tool_calls (tool takes control / empty response /
|
|
239
|
+
# cancellation) must not report the previous run's artifacts.
|
|
240
|
+
self._generated_files_info = None
|
|
241
|
+
self._invocation_stats = []
|
|
225
242
|
run_start = time.perf_counter()
|
|
226
243
|
|
|
227
244
|
await preload_invoked_skills(
|
|
@@ -257,6 +274,27 @@ class UniqueAI:
|
|
|
257
274
|
time.perf_counter() - planning_start, 3
|
|
258
275
|
)
|
|
259
276
|
self._logger.info("Done with _plan_or_execute")
|
|
277
|
+
if loop_response.usage is not None:
|
|
278
|
+
self._invocation_stats.append(
|
|
279
|
+
LanguageModelInvocationStats.from_usage(
|
|
280
|
+
self._config.space.language_model.name,
|
|
281
|
+
loop_response.usage,
|
|
282
|
+
source=f"main_loop[{i + 1}]",
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
# TODO(UN-20907): if _plan_or_execute() above raises an exception that
|
|
286
|
+
# propagates out of this loop uncaught, execution never reaches this
|
|
287
|
+
# drain call -- any planning-step usage recorded on the runner this
|
|
288
|
+
# iteration is lost. In practice this is not planning-specific: an
|
|
289
|
+
# uncaught abort here also skips the debug_info["llm_invocations"]
|
|
290
|
+
# write entirely (see the `finally` at the end of run()), so every
|
|
291
|
+
# usage source (main_loop, evaluations, postprocessors) is equally
|
|
292
|
+
# absent from that turn, not just planning. Revisit only if partial
|
|
293
|
+
# debug_info persistence on an aborted run becomes a requirement.
|
|
294
|
+
if isinstance(self._loop_iteration_runner, SupportsInvocationStats):
|
|
295
|
+
self._invocation_stats.extend(
|
|
296
|
+
self._loop_iteration_runner.get_invocation_stats()
|
|
297
|
+
)
|
|
260
298
|
|
|
261
299
|
if await self._chat_service.cancellation.check_cancellation_async():
|
|
262
300
|
self._finalize_loop_timing(loop_start)
|
|
@@ -318,6 +356,13 @@ class UniqueAI:
|
|
|
318
356
|
self._debug_info_manager.add("loop_params", self._loop_debug_params)
|
|
319
357
|
skills_debug_info = self._get_activated_skills_debug_info()
|
|
320
358
|
self._debug_info_manager.add("skills", skills_debug_info)
|
|
359
|
+
self._debug_info_manager.add(
|
|
360
|
+
"llm_invocations",
|
|
361
|
+
[
|
|
362
|
+
invocation.model_dump(by_alias=True)
|
|
363
|
+
for invocation in self._invocation_stats
|
|
364
|
+
],
|
|
365
|
+
)
|
|
321
366
|
reference_sources = {
|
|
322
367
|
("url", reference.url)
|
|
323
368
|
if reference.url is not None
|
|
@@ -363,6 +408,7 @@ class UniqueAI:
|
|
|
363
408
|
answer_length=len(self._last_assistant_text or ""),
|
|
364
409
|
loop_iteration_count=len(self._execution_times),
|
|
365
410
|
total_time_to_answer_ms=total_time_to_answer_ms,
|
|
411
|
+
artifacts=self._generated_files_info,
|
|
366
412
|
)
|
|
367
413
|
|
|
368
414
|
# Get current debug info from chat service and add debug info from run. Do not update if DeepResearch is in the tool names.
|
|
@@ -696,14 +742,25 @@ class UniqueAI:
|
|
|
696
742
|
loop_response.model_copy(deep=True),
|
|
697
743
|
)
|
|
698
744
|
|
|
699
|
-
|
|
745
|
+
postprocessor_result, evaluation_results = await asyncio.gather(
|
|
700
746
|
postprocessor_result,
|
|
701
747
|
evaluation_results,
|
|
702
748
|
)
|
|
703
|
-
|
|
749
|
+
postprocessor_outputs = postprocessor_result.unpack() or {}
|
|
750
|
+
# run_postprocessors erases each postprocessor's return type to
|
|
751
|
+
# `object | None` (generic channel), so re-narrow the one we own.
|
|
752
|
+
self._generated_files_info = cast(
|
|
753
|
+
"ArtifactsDebugInfo | None",
|
|
754
|
+
postprocessor_outputs.get(
|
|
755
|
+
DisplayCodeInterpreterFilesPostProcessor.__name__
|
|
756
|
+
),
|
|
757
|
+
)
|
|
704
758
|
self._current_loop_timing["post_processing"].update(
|
|
705
759
|
self._postprocessor_manager.get_execution_times()
|
|
706
760
|
)
|
|
761
|
+
self._invocation_stats.extend(
|
|
762
|
+
self._postprocessor_manager.get_invocation_stats()
|
|
763
|
+
)
|
|
707
764
|
|
|
708
765
|
evaluation_times = self._evaluation_manager.get_execution_times()
|
|
709
766
|
for name in selected_evaluation_names:
|
|
@@ -711,6 +768,7 @@ class UniqueAI:
|
|
|
711
768
|
self._current_loop_timing["evaluation"][name_str] = evaluation_times.get(
|
|
712
769
|
name_str, 0
|
|
713
770
|
)
|
|
771
|
+
self._invocation_stats.extend(self._evaluation_manager.get_invocation_stats())
|
|
714
772
|
|
|
715
773
|
if evaluation_results.success and not all(
|
|
716
774
|
result.is_positive for result in evaluation_results.unpack()
|
|
@@ -818,6 +876,9 @@ class UniqueAI:
|
|
|
818
876
|
)
|
|
819
877
|
execution_total = round(time.perf_counter() - execution_start, 3)
|
|
820
878
|
|
|
879
|
+
for response in tool_call_responses:
|
|
880
|
+
self._invocation_stats.extend(response.invocation_stats)
|
|
881
|
+
|
|
821
882
|
tool_times: dict[str, float] = {}
|
|
822
883
|
for response in tool_call_responses:
|
|
823
884
|
if response.debug_info and "execution_time_s" in response.debug_info:
|
|
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
|