unique_orchestrator 2026.30.0.dev7__tar.gz → 2026.30.0.dev9__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.dev7 → unique_orchestrator-2026.30.0.dev9}/PKG-INFO +5 -5
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/pyproject.toml +5 -5
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_async_modify.py +5 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +6 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +1 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_reference_order.py +1 -0
- unique_orchestrator-2026.30.0.dev9/unique_orchestrator/tests/test_unique_ai_token_usage.py +480 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/unique_ai.py +41 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/unique_ai_builder.py +1 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/README.md +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/__init__.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/history_manager.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/_builders/skill_setup.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/config.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/settings.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
- {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev9}/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.dev9
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Andreas Hauri
|
|
6
6
|
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
@@ -9,15 +9,15 @@ 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.dev21,<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
|
-
Requires-Dist: unique-web-search>=2026.30.0.
|
|
17
|
+
Requires-Dist: unique-web-search>=2026.30.0.dev4,<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
|
-
Requires-Dist: unique-user-memory>=2026.30.0.
|
|
20
|
+
Requires-Dist: unique-user-memory>=2026.30.0.dev1,<2026.30.0rc0
|
|
21
21
|
Requires-Dist: openai>=1.109.1,<3
|
|
22
22
|
Requires-Python: >=3.12, <4
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_orchestrator"
|
|
3
|
-
version = "2026.30.0.
|
|
3
|
+
version = "2026.30.0.dev9"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -13,15 +13,15 @@ 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.dev21,<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
|
-
"unique-web-search>=2026.30.0.
|
|
21
|
+
"unique-web-search>=2026.30.0.dev4,<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
|
-
"unique-user-memory>=2026.30.0.
|
|
24
|
+
"unique-user-memory>=2026.30.0.dev1,<2026.30.0rc0",
|
|
25
25
|
"openai>=1.109.1,<3",
|
|
26
26
|
]
|
|
27
27
|
|
|
@@ -56,10 +56,12 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
|
|
|
56
56
|
mock_postprocessor_manager = MagicMock()
|
|
57
57
|
mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
|
|
58
58
|
mock_postprocessor_manager.get_execution_times.return_value = {}
|
|
59
|
+
mock_postprocessor_manager.get_invocation_stats.return_value = []
|
|
59
60
|
|
|
60
61
|
mock_evaluation_manager = MagicMock()
|
|
61
62
|
mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
62
63
|
mock_evaluation_manager.get_execution_times.return_value = {}
|
|
64
|
+
mock_evaluation_manager.get_invocation_stats.return_value = []
|
|
63
65
|
|
|
64
66
|
dummy_event = MagicMock()
|
|
65
67
|
dummy_event.payload.assistant_message.id = "assist_1"
|
|
@@ -89,8 +91,10 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
|
|
|
89
91
|
ua._execution_times = []
|
|
90
92
|
ua._current_loop_timing = {}
|
|
91
93
|
ua._generated_files_info = None
|
|
94
|
+
ua._invocation_stats = []
|
|
92
95
|
ua.current_iteration_index = 0
|
|
93
96
|
ua._skill_choices = []
|
|
97
|
+
ua._loop_iteration_runner = MagicMock()
|
|
94
98
|
|
|
95
99
|
return ua
|
|
96
100
|
|
|
@@ -107,6 +111,7 @@ async def test_run_calls_modify_async_when_feature_flag_disabled(monkeypatch):
|
|
|
107
111
|
empty_response.message.references = []
|
|
108
112
|
empty_response.tool_calls = None
|
|
109
113
|
empty_response.is_empty.return_value = True
|
|
114
|
+
empty_response.usage = None
|
|
110
115
|
|
|
111
116
|
ua._plan_or_execute = AsyncMock(return_value=empty_response)
|
|
112
117
|
|
|
@@ -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"
|
|
@@ -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 (
|
|
@@ -49,6 +50,9 @@ from unique_toolkit.chat.service import ChatService
|
|
|
49
50
|
from unique_toolkit.content import Content
|
|
50
51
|
from unique_toolkit.content.service import ContentService
|
|
51
52
|
from unique_toolkit.language_model import LanguageModelAssistantMessage
|
|
53
|
+
from unique_toolkit.language_model.invocation_stats import (
|
|
54
|
+
LanguageModelInvocationStats,
|
|
55
|
+
)
|
|
52
56
|
from unique_toolkit.language_model.schemas import (
|
|
53
57
|
LanguageModelMessages,
|
|
54
58
|
LanguageModelStreamResponse,
|
|
@@ -204,6 +208,7 @@ class UniqueAI:
|
|
|
204
208
|
self._current_loop_timing: dict[str, Any] = {}
|
|
205
209
|
self._loop_debug_params: list[dict[str, Any]] = []
|
|
206
210
|
self._generated_files_info: ArtifactsDebugInfo | None = None
|
|
211
|
+
self._invocation_stats: list[LanguageModelInvocationStats] = []
|
|
207
212
|
|
|
208
213
|
async def _on_cancellation(self, _event: CancellationEvent) -> None:
|
|
209
214
|
"""Subscriber called by the cancellation event bus."""
|
|
@@ -233,6 +238,7 @@ class UniqueAI:
|
|
|
233
238
|
# reaching _handle_no_tool_calls (tool takes control / empty response /
|
|
234
239
|
# cancellation) must not report the previous run's artifacts.
|
|
235
240
|
self._generated_files_info = None
|
|
241
|
+
self._invocation_stats = []
|
|
236
242
|
run_start = time.perf_counter()
|
|
237
243
|
|
|
238
244
|
await preload_invoked_skills(
|
|
@@ -268,6 +274,27 @@ class UniqueAI:
|
|
|
268
274
|
time.perf_counter() - planning_start, 3
|
|
269
275
|
)
|
|
270
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
|
+
)
|
|
271
298
|
|
|
272
299
|
if await self._chat_service.cancellation.check_cancellation_async():
|
|
273
300
|
self._finalize_loop_timing(loop_start)
|
|
@@ -329,6 +356,13 @@ class UniqueAI:
|
|
|
329
356
|
self._debug_info_manager.add("loop_params", self._loop_debug_params)
|
|
330
357
|
skills_debug_info = self._get_activated_skills_debug_info()
|
|
331
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
|
+
)
|
|
332
366
|
reference_sources = {
|
|
333
367
|
("url", reference.url)
|
|
334
368
|
if reference.url is not None
|
|
@@ -724,6 +758,9 @@ class UniqueAI:
|
|
|
724
758
|
self._current_loop_timing["post_processing"].update(
|
|
725
759
|
self._postprocessor_manager.get_execution_times()
|
|
726
760
|
)
|
|
761
|
+
self._invocation_stats.extend(
|
|
762
|
+
self._postprocessor_manager.get_invocation_stats()
|
|
763
|
+
)
|
|
727
764
|
|
|
728
765
|
evaluation_times = self._evaluation_manager.get_execution_times()
|
|
729
766
|
for name in selected_evaluation_names:
|
|
@@ -731,6 +768,7 @@ class UniqueAI:
|
|
|
731
768
|
self._current_loop_timing["evaluation"][name_str] = evaluation_times.get(
|
|
732
769
|
name_str, 0
|
|
733
770
|
)
|
|
771
|
+
self._invocation_stats.extend(self._evaluation_manager.get_invocation_stats())
|
|
734
772
|
|
|
735
773
|
if evaluation_results.success and not all(
|
|
736
774
|
result.is_positive for result in evaluation_results.unpack()
|
|
@@ -838,6 +876,9 @@ class UniqueAI:
|
|
|
838
876
|
)
|
|
839
877
|
execution_total = round(time.perf_counter() - execution_start, 3)
|
|
840
878
|
|
|
879
|
+
for response in tool_call_responses:
|
|
880
|
+
self._invocation_stats.extend(response.invocation_stats)
|
|
881
|
+
|
|
841
882
|
tool_times: dict[str, float] = {}
|
|
842
883
|
for response in tool_call_responses:
|
|
843
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
|