unique_orchestrator 2026.22.0.dev6__tar.gz → 2026.22.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.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/PKG-INFO +3 -4
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/pyproject.toml +3 -4
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/_builders/skill_setup.py +18 -82
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +5 -3
- unique_orchestrator-2026.22.0.dev8/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +424 -0
- unique_orchestrator-2026.22.0.dev8/unique_orchestrator/tests/test_utils_resolve_other_options.py +261 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/unique_ai.py +24 -1
- unique_orchestrator-2026.22.0.dev8/unique_orchestrator/utils.py +133 -0
- unique_orchestrator-2026.22.0.dev6/unique_orchestrator/utils.py +0 -26
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/README.md +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/_builders/__init__.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/config.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/settings.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
- {unique_orchestrator-2026.22.0.dev6 → unique_orchestrator-2026.22.0.dev8}/unique_orchestrator/unique_ai_builder.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-orchestrator
|
|
3
|
-
Version: 2026.22.0.
|
|
3
|
+
Version: 2026.22.0.dev8
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Andreas Hauri
|
|
6
6
|
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
@@ -9,16 +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.22.0.
|
|
12
|
+
Requires-Dist: unique-toolkit>=2026.22.0.dev11,<2026.22.0rc0
|
|
13
13
|
Requires-Dist: unique-stock-ticker>=2026.22.0.dev2,<2026.22.0rc0
|
|
14
14
|
Requires-Dist: unique-follow-up-questions>=2026.22.0.dev2,<2026.22.0rc0
|
|
15
15
|
Requires-Dist: unique-internal-search>=2026.22.0.dev3,<2026.22.0rc0
|
|
16
16
|
Requires-Dist: unique-deep-research>=2026.22.0.dev2,<2026.22.0rc0
|
|
17
17
|
Requires-Dist: unique-web-search>=2026.22.0.dev4,<2026.22.0rc0
|
|
18
18
|
Requires-Dist: unique-swot>=2026.22.0.dev2,<2026.22.0rc0
|
|
19
|
-
Requires-Dist: unique-skill-tool>=2026.22.0.
|
|
19
|
+
Requires-Dist: unique-skill-tool>=2026.22.0.dev6,<2026.22.0rc0
|
|
20
20
|
Requires-Dist: openai>=1.109.1,<3
|
|
21
|
-
Requires-Dist: python-frontmatter>=1.1.0,<2
|
|
22
21
|
Requires-Python: >=3.12, <4
|
|
23
22
|
Description-Content-Type: text/markdown
|
|
24
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_orchestrator"
|
|
3
|
-
version = "2026.22.0.
|
|
3
|
+
version = "2026.22.0.dev8"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -13,16 +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.22.0.
|
|
16
|
+
"unique-toolkit>=2026.22.0.dev11,<2026.22.0rc0",
|
|
17
17
|
"unique-stock-ticker>=2026.22.0.dev2,<2026.22.0rc0",
|
|
18
18
|
"unique-follow-up-questions>=2026.22.0.dev2,<2026.22.0rc0",
|
|
19
19
|
"unique-internal-search>=2026.22.0.dev3,<2026.22.0rc0",
|
|
20
20
|
"unique-deep-research>=2026.22.0.dev2,<2026.22.0rc0",
|
|
21
21
|
"unique-web-search>=2026.22.0.dev4,<2026.22.0rc0",
|
|
22
22
|
"unique-swot>=2026.22.0.dev2,<2026.22.0rc0",
|
|
23
|
-
"unique-skill-tool>=2026.22.0.
|
|
23
|
+
"unique-skill-tool>=2026.22.0.dev6,<2026.22.0rc0",
|
|
24
24
|
"openai>=1.109.1,<3",
|
|
25
|
-
"python-frontmatter>=1.1.0,<2",
|
|
26
25
|
]
|
|
27
26
|
|
|
28
27
|
[build-system]
|
|
@@ -43,11 +43,10 @@ from __future__ import annotations
|
|
|
43
43
|
|
|
44
44
|
import asyncio
|
|
45
45
|
from logging import Logger
|
|
46
|
-
from typing import TYPE_CHECKING
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
47
|
|
|
48
|
-
import frontmatter
|
|
49
|
-
from pydantic import ValidationError
|
|
50
48
|
from unique_skill_tool.config import SkillToolConfig
|
|
49
|
+
from unique_skill_tool.loader import parse_skill_file
|
|
51
50
|
from unique_skill_tool.schemas import SkillDefinition
|
|
52
51
|
from unique_skill_tool.service import SkillTool
|
|
53
52
|
from unique_toolkit.agentic.tools.config import ToolBuildConfig
|
|
@@ -76,29 +75,6 @@ def _find_skill_tool_build_config(
|
|
|
76
75
|
return None
|
|
77
76
|
|
|
78
77
|
|
|
79
|
-
def _parse_frontmatter(*, text: str) -> tuple[dict[str, Any], str]:
|
|
80
|
-
"""Split YAML frontmatter from the markdown body.
|
|
81
|
-
|
|
82
|
-
Thin wrapper around ``python-frontmatter``. On a YAML parse error,
|
|
83
|
-
or when the frontmatter parses to a non-mapping (e.g. a top-level
|
|
84
|
-
YAML list), we fall back to ``({}, <stripped body>)`` so a broken
|
|
85
|
-
skill file never leaks its raw ``---\\nname: ...\\n---`` block into
|
|
86
|
-
the LLM prompt, but also never crashes ``_build_skill`` with an
|
|
87
|
-
unhandled ``TypeError``/``ValueError`` from ``dict(...)``.
|
|
88
|
-
"""
|
|
89
|
-
try:
|
|
90
|
-
post = frontmatter.loads(text)
|
|
91
|
-
metadata = post.metadata
|
|
92
|
-
body = post.content
|
|
93
|
-
except Exception:
|
|
94
|
-
return {}, text
|
|
95
|
-
|
|
96
|
-
if not isinstance(metadata, dict):
|
|
97
|
-
return {}, body
|
|
98
|
-
|
|
99
|
-
return dict(metadata), body
|
|
100
|
-
|
|
101
|
-
|
|
102
78
|
def normalize_available_skills_for_tool(
|
|
103
79
|
available_skills: list[SkillReference],
|
|
104
80
|
) -> list[SkillReference]:
|
|
@@ -125,56 +101,6 @@ def normalize_available_skills_for_tool(
|
|
|
125
101
|
return out
|
|
126
102
|
|
|
127
103
|
|
|
128
|
-
def _build_skill(
|
|
129
|
-
*,
|
|
130
|
-
content_id: str,
|
|
131
|
-
content_key: str,
|
|
132
|
-
file_text: str,
|
|
133
|
-
logger: Logger,
|
|
134
|
-
) -> SkillDefinition | None:
|
|
135
|
-
"""Build a ``SkillDefinition`` from the raw file text of a KB document.
|
|
136
|
-
|
|
137
|
-
Parses YAML frontmatter for ``name`` and ``description``. A malformed
|
|
138
|
-
``name`` (non-kebab-case, contains whitespace/punctuation, too long)
|
|
139
|
-
is rejected by ``SkillDefinition`` rather than silently flowing into
|
|
140
|
-
the OpenAI tool enum where the model could never emit it verbatim.
|
|
141
|
-
|
|
142
|
-
``content_id`` is stored on the returned definition so it can be used
|
|
143
|
-
as a lookup key to load the SKILL.md.
|
|
144
|
-
"""
|
|
145
|
-
if not file_text.strip():
|
|
146
|
-
return None
|
|
147
|
-
|
|
148
|
-
metadata, body = _parse_frontmatter(text=file_text)
|
|
149
|
-
|
|
150
|
-
name = metadata.get("name")
|
|
151
|
-
description = metadata.get("description")
|
|
152
|
-
|
|
153
|
-
if not name or not description:
|
|
154
|
-
logger.warning(
|
|
155
|
-
"Skipping '%s' (%s): wrong skill format.",
|
|
156
|
-
content_key,
|
|
157
|
-
content_id,
|
|
158
|
-
)
|
|
159
|
-
return None
|
|
160
|
-
|
|
161
|
-
try:
|
|
162
|
-
return SkillDefinition(
|
|
163
|
-
name=name,
|
|
164
|
-
description=description,
|
|
165
|
-
content=body,
|
|
166
|
-
content_id=content_id,
|
|
167
|
-
)
|
|
168
|
-
except ValidationError as exc:
|
|
169
|
-
logger.warning(
|
|
170
|
-
"Skipping '%s' (%s): invalid skill definition: %s",
|
|
171
|
-
content_key,
|
|
172
|
-
content_id,
|
|
173
|
-
exc.errors(include_url=False),
|
|
174
|
-
)
|
|
175
|
-
return None
|
|
176
|
-
|
|
177
|
-
|
|
178
104
|
async def load_selectable_skills(
|
|
179
105
|
*,
|
|
180
106
|
content_service: ContentService,
|
|
@@ -224,12 +150,22 @@ async def load_selectable_skills(
|
|
|
224
150
|
)
|
|
225
151
|
continue
|
|
226
152
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
153
|
+
try:
|
|
154
|
+
skill = parse_skill_file(
|
|
155
|
+
file_text=file_text,
|
|
156
|
+
content_id=entry.content_id,
|
|
157
|
+
source_label=label,
|
|
158
|
+
logger=logger,
|
|
159
|
+
)
|
|
160
|
+
except Exception:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"Unexpected error building selectable skill '%s' (%s) — skipping.",
|
|
163
|
+
label,
|
|
164
|
+
entry.content_id,
|
|
165
|
+
exc_info=True,
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
|
|
233
169
|
if skill is None:
|
|
234
170
|
logger.debug(
|
|
235
171
|
"Skipping selectable skill '%s' (%s): empty or invalid file.",
|
|
@@ -727,9 +727,11 @@ class TestRunExecutionTimingIntegration:
|
|
|
727
727
|
|
|
728
728
|
await ua.run()
|
|
729
729
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
730
|
+
calls_by_key = {
|
|
731
|
+
args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
|
|
732
|
+
}
|
|
733
|
+
assert "execution_time" in calls_by_key
|
|
734
|
+
payload = calls_by_key["execution_time"]
|
|
733
735
|
assert "loop_iterations" in payload
|
|
734
736
|
assert "total_time" in payload
|
|
735
737
|
|
unique_orchestrator-2026.22.0.dev8/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Tests for _loop_debug_params — per-iteration debug info tracking.
|
|
2
|
+
|
|
3
|
+
Covers initialisation, per-call accumulation in _plan_or_execute (both
|
|
4
|
+
completions-API and responses-API thinking-level extraction), and the final
|
|
5
|
+
write of ``loop_params`` to the debug info manager in run().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Shared helpers
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_unique_ai(**overrides):
|
|
18
|
+
"""Return a minimal UniqueAI with all external dependencies mocked."""
|
|
19
|
+
from unique_orchestrator.unique_ai import UniqueAI
|
|
20
|
+
|
|
21
|
+
mock_tool_manager = MagicMock()
|
|
22
|
+
mock_tool_manager.available_tools = []
|
|
23
|
+
mock_tool_manager.get_forced_tools.return_value = []
|
|
24
|
+
mock_tool_manager.get_tool_definitions.return_value = []
|
|
25
|
+
mock_tool_manager.filter_duplicate_tool_calls.side_effect = lambda x: x
|
|
26
|
+
mock_tool_manager.filter_tool_calls_by_max_tool_calls_allowed.side_effect = (
|
|
27
|
+
lambda x: x
|
|
28
|
+
)
|
|
29
|
+
mock_tool_manager.does_a_tool_take_control.return_value = False
|
|
30
|
+
mock_tool_manager.should_stop_after_tool_calls.return_value = False
|
|
31
|
+
mock_tool_manager.get_evaluation_check_list.return_value = []
|
|
32
|
+
|
|
33
|
+
mock_chat_service = MagicMock()
|
|
34
|
+
mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
|
|
35
|
+
mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
|
|
36
|
+
|
|
37
|
+
dummy_event = MagicMock()
|
|
38
|
+
dummy_event.payload.assistant_message.id = "assist_1"
|
|
39
|
+
dummy_event.payload.user_message.text = "query"
|
|
40
|
+
dummy_event.payload.assistant_id = "assistant_123"
|
|
41
|
+
dummy_event.payload.name = "TestAssistant"
|
|
42
|
+
dummy_event.payload.user_metadata = {}
|
|
43
|
+
dummy_event.payload.tool_parameters = {}
|
|
44
|
+
|
|
45
|
+
mock_config = MagicMock()
|
|
46
|
+
mock_config.agent.prompt_config.user_metadata = []
|
|
47
|
+
|
|
48
|
+
mock_debug_info_manager = MagicMock()
|
|
49
|
+
mock_debug_info_manager.get.return_value = {}
|
|
50
|
+
|
|
51
|
+
defaults = dict(
|
|
52
|
+
logger=MagicMock(),
|
|
53
|
+
event=dummy_event,
|
|
54
|
+
config=mock_config,
|
|
55
|
+
chat_service=mock_chat_service,
|
|
56
|
+
content_service=MagicMock(),
|
|
57
|
+
debug_info_manager=mock_debug_info_manager,
|
|
58
|
+
streaming_handler=MagicMock(),
|
|
59
|
+
reference_manager=MagicMock(),
|
|
60
|
+
thinking_manager=MagicMock(),
|
|
61
|
+
tool_manager=mock_tool_manager,
|
|
62
|
+
history_manager=MagicMock(),
|
|
63
|
+
evaluation_manager=MagicMock(),
|
|
64
|
+
postprocessor_manager=MagicMock(),
|
|
65
|
+
message_step_logger=MagicMock(),
|
|
66
|
+
mcp_servers=[],
|
|
67
|
+
loop_iteration_runner=MagicMock(),
|
|
68
|
+
)
|
|
69
|
+
defaults.update(overrides)
|
|
70
|
+
return UniqueAI(**defaults)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _make_loop_response() -> MagicMock:
|
|
74
|
+
response = MagicMock()
|
|
75
|
+
response.tool_calls = None
|
|
76
|
+
response.message = MagicMock()
|
|
77
|
+
response.message.references = []
|
|
78
|
+
response.message.text = "response text"
|
|
79
|
+
response.is_empty.return_value = False
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _call_plan_or_execute(ua, monkeypatch, other_options: dict) -> None:
|
|
84
|
+
"""Call _plan_or_execute with a patched resolve_other_options."""
|
|
85
|
+
monkeypatch.setattr(
|
|
86
|
+
"unique_orchestrator.unique_ai.resolve_other_options",
|
|
87
|
+
lambda *_args, **_kwargs: other_options,
|
|
88
|
+
)
|
|
89
|
+
ua._compose_message_plan_execution = AsyncMock(return_value=[]) # type: ignore[method-assign]
|
|
90
|
+
ua._loop_iteration_runner = AsyncMock(return_value=_make_loop_response())
|
|
91
|
+
await ua._plan_or_execute()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Initialisation
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestLoopDebugParamsInit:
|
|
100
|
+
@pytest.mark.ai
|
|
101
|
+
def test_init__loop_debug_params__is_empty_list(self) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Purpose: Verify that _loop_debug_params is initialised as an empty list.
|
|
104
|
+
Why this matters: An uninitialised attribute causes AttributeError on the
|
|
105
|
+
first append inside _plan_or_execute.
|
|
106
|
+
Setup summary: Freshly constructed UniqueAI; assert _loop_debug_params == [].
|
|
107
|
+
"""
|
|
108
|
+
ua = _build_unique_ai()
|
|
109
|
+
assert ua._loop_debug_params == []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# _plan_or_execute — thinking-level extraction
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestPlanOrExecuteLoopDebugParams:
|
|
118
|
+
@pytest.mark.ai
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_plan_or_execute__completions_api__reasoning_effort_stored(
|
|
121
|
+
self, monkeypatch
|
|
122
|
+
) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Purpose: Verify that a ``reasoning_effort`` value from the completions API
|
|
125
|
+
is stored as ``thinking_level`` in the appended entry.
|
|
126
|
+
Why this matters: The completions API uses a flat ``reasoning_effort`` key;
|
|
127
|
+
the extractor must read from there, not from ``reasoning.effort``.
|
|
128
|
+
Setup summary: Patch resolve_other_options to return
|
|
129
|
+
``{'reasoning_effort': 'high'}``; call _plan_or_execute; assert
|
|
130
|
+
_loop_debug_params[0]['thinking_level'] == 'high'.
|
|
131
|
+
"""
|
|
132
|
+
ua = _build_unique_ai()
|
|
133
|
+
await _call_plan_or_execute(ua, monkeypatch, {"reasoning_effort": "high"})
|
|
134
|
+
|
|
135
|
+
assert len(ua._loop_debug_params) == 1
|
|
136
|
+
assert ua._loop_debug_params[0]["thinking_level"] == "high"
|
|
137
|
+
|
|
138
|
+
@pytest.mark.ai
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_plan_or_execute__responses_api__reasoning_effort_stored(
|
|
141
|
+
self, monkeypatch
|
|
142
|
+
) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Purpose: Verify that ``reasoning.effort`` from the responses API is stored
|
|
145
|
+
as ``thinking_level``.
|
|
146
|
+
Why this matters: The responses API nests the effort inside a ``reasoning``
|
|
147
|
+
dict; failing to look there would always produce None for responses-API calls.
|
|
148
|
+
Setup summary: Patch resolve_other_options to return
|
|
149
|
+
``{'reasoning': {'effort': 'low'}}``; assert thinking_level == 'low'.
|
|
150
|
+
"""
|
|
151
|
+
ua = _build_unique_ai()
|
|
152
|
+
await _call_plan_or_execute(ua, monkeypatch, {"reasoning": {"effort": "low"}})
|
|
153
|
+
|
|
154
|
+
assert len(ua._loop_debug_params) == 1
|
|
155
|
+
assert ua._loop_debug_params[0]["thinking_level"] == "low"
|
|
156
|
+
|
|
157
|
+
@pytest.mark.ai
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_plan_or_execute__no_thinking_level__none_string_stored(
|
|
160
|
+
self, monkeypatch
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Purpose: Verify that when resolve_other_options returns no reasoning-related
|
|
164
|
+
keys, thinking_level is stored as the string "None".
|
|
165
|
+
Why this matters: Using the string "None" instead of Python None keeps the
|
|
166
|
+
debug payload consistently typed as str, which serialises cleanly to JSON
|
|
167
|
+
and is unambiguous in log output.
|
|
168
|
+
Setup summary: Patch resolve_other_options to return {}; assert
|
|
169
|
+
thinking_level == "None".
|
|
170
|
+
"""
|
|
171
|
+
ua = _build_unique_ai()
|
|
172
|
+
await _call_plan_or_execute(ua, monkeypatch, {})
|
|
173
|
+
|
|
174
|
+
assert len(ua._loop_debug_params) == 1
|
|
175
|
+
assert ua._loop_debug_params[0]["thinking_level"] == "None"
|
|
176
|
+
|
|
177
|
+
@pytest.mark.ai
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_plan_or_execute__loop_number__matches_current_iteration_index(
|
|
180
|
+
self, monkeypatch
|
|
181
|
+
) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Purpose: Verify that loop_number in the appended entry equals
|
|
184
|
+
current_iteration_index at call time.
|
|
185
|
+
Why this matters: Consumers use loop_number to correlate debug params with
|
|
186
|
+
execution timing entries; a wrong value breaks that correlation.
|
|
187
|
+
Setup summary: Set current_iteration_index to 3; call _plan_or_execute;
|
|
188
|
+
assert loop_number == 3.
|
|
189
|
+
"""
|
|
190
|
+
ua = _build_unique_ai()
|
|
191
|
+
ua.current_iteration_index = 3
|
|
192
|
+
await _call_plan_or_execute(ua, monkeypatch, {"reasoning_effort": "medium"})
|
|
193
|
+
|
|
194
|
+
assert ua._loop_debug_params[0]["loop_number"] == 3
|
|
195
|
+
|
|
196
|
+
@pytest.mark.ai
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_plan_or_execute__multiple_calls__accumulates_entries_in_order(
|
|
199
|
+
self, monkeypatch
|
|
200
|
+
) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Purpose: Verify that repeated calls across iterations append independent
|
|
203
|
+
entries in the correct order.
|
|
204
|
+
Why this matters: A multi-iteration run must produce one entry per iteration
|
|
205
|
+
with no overwrites or entry merges.
|
|
206
|
+
Setup summary: Call _plan_or_execute twice with different iteration indices
|
|
207
|
+
and thinking levels; assert len == 2 and each entry has the correct values.
|
|
208
|
+
"""
|
|
209
|
+
ua = _build_unique_ai()
|
|
210
|
+
|
|
211
|
+
monkeypatch.setattr(
|
|
212
|
+
"unique_orchestrator.unique_ai.resolve_other_options",
|
|
213
|
+
lambda *_args, **_kwargs: {"reasoning_effort": "high"},
|
|
214
|
+
)
|
|
215
|
+
ua._compose_message_plan_execution = AsyncMock(return_value=[]) # type: ignore[method-assign]
|
|
216
|
+
ua._loop_iteration_runner = AsyncMock(return_value=_make_loop_response())
|
|
217
|
+
ua.current_iteration_index = 0
|
|
218
|
+
await ua._plan_or_execute()
|
|
219
|
+
|
|
220
|
+
monkeypatch.setattr(
|
|
221
|
+
"unique_orchestrator.unique_ai.resolve_other_options",
|
|
222
|
+
lambda *_args, **_kwargs: {"reasoning": {"effort": "low"}},
|
|
223
|
+
)
|
|
224
|
+
ua.current_iteration_index = 1
|
|
225
|
+
await ua._plan_or_execute()
|
|
226
|
+
|
|
227
|
+
assert len(ua._loop_debug_params) == 2
|
|
228
|
+
assert ua._loop_debug_params[0] == {"loop_number": 0, "thinking_level": "high"}
|
|
229
|
+
assert ua._loop_debug_params[1] == {"loop_number": 1, "thinking_level": "low"}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# run() integration — loop_params written to debug info manager
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestRunLoopDebugParams:
|
|
238
|
+
def _build_run_ua(self, monkeypatch):
|
|
239
|
+
mock_feature_flags = MagicMock()
|
|
240
|
+
monkeypatch.setattr(
|
|
241
|
+
"unique_orchestrator.unique_ai.feature_flags", mock_feature_flags
|
|
242
|
+
)
|
|
243
|
+
monkeypatch.setattr(
|
|
244
|
+
"unique_orchestrator.unique_ai.resolve_other_options",
|
|
245
|
+
lambda *_args, **_kwargs: {"reasoning_effort": "high"},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
mock_cancellation = MagicMock()
|
|
249
|
+
mock_cancellation.is_cancelled = False
|
|
250
|
+
mock_cancellation.on_cancellation.subscribe = MagicMock(
|
|
251
|
+
return_value=MagicMock()
|
|
252
|
+
)
|
|
253
|
+
mock_cancellation.check_cancellation_async = AsyncMock(return_value=False)
|
|
254
|
+
|
|
255
|
+
mock_chat_service = MagicMock()
|
|
256
|
+
mock_chat_service.cancellation = mock_cancellation
|
|
257
|
+
mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
|
|
258
|
+
mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
|
|
259
|
+
mock_chat_service.modify_assistant_message_async = AsyncMock(return_value=None)
|
|
260
|
+
mock_chat_service.create_assistant_message_async = AsyncMock(
|
|
261
|
+
return_value=MagicMock(id="assist_new")
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
mock_tool_manager = MagicMock()
|
|
265
|
+
mock_tool_manager.available_tools = []
|
|
266
|
+
mock_tool_manager.get_forced_tools.return_value = []
|
|
267
|
+
mock_tool_manager.get_tool_definitions.return_value = []
|
|
268
|
+
mock_tool_manager.filter_duplicate_tool_calls.side_effect = lambda x: x
|
|
269
|
+
mock_tool_manager.filter_tool_calls_by_max_tool_calls_allowed.side_effect = (
|
|
270
|
+
lambda x: x
|
|
271
|
+
)
|
|
272
|
+
mock_tool_manager.does_a_tool_take_control.return_value = False
|
|
273
|
+
mock_tool_manager.should_stop_after_tool_calls.return_value = False
|
|
274
|
+
mock_tool_manager.get_evaluation_check_list.return_value = []
|
|
275
|
+
|
|
276
|
+
mock_debug_info_manager = MagicMock()
|
|
277
|
+
mock_debug_info_manager.get.return_value = {"tools": []}
|
|
278
|
+
|
|
279
|
+
mock_config = MagicMock()
|
|
280
|
+
mock_config.effective_max_loop_iterations = 1
|
|
281
|
+
mock_config.agent.prompt_config.user_metadata = []
|
|
282
|
+
|
|
283
|
+
mock_history_manager = MagicMock()
|
|
284
|
+
mock_history_manager.get_history_for_model_call = AsyncMock(
|
|
285
|
+
return_value=MagicMock()
|
|
286
|
+
)
|
|
287
|
+
mock_history_manager.add_tool_call_results = MagicMock()
|
|
288
|
+
mock_history_manager.extract_message_tools.return_value = []
|
|
289
|
+
|
|
290
|
+
mock_postprocessor_manager = MagicMock()
|
|
291
|
+
mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
|
|
292
|
+
mock_postprocessor_manager.get_execution_times.return_value = {}
|
|
293
|
+
|
|
294
|
+
mock_evaluation_manager = MagicMock()
|
|
295
|
+
mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
|
|
296
|
+
mock_evaluation_manager.get_execution_times.return_value = {}
|
|
297
|
+
|
|
298
|
+
dummy_event = MagicMock()
|
|
299
|
+
dummy_event.payload.assistant_message.id = "assist_1"
|
|
300
|
+
dummy_event.payload.user_message.text = "query"
|
|
301
|
+
dummy_event.payload.assistant_id = "assistant_123"
|
|
302
|
+
dummy_event.payload.name = "TestAssistant"
|
|
303
|
+
dummy_event.payload.user_metadata = {}
|
|
304
|
+
dummy_event.payload.tool_parameters = {}
|
|
305
|
+
dummy_event.company_id = "company_1"
|
|
306
|
+
|
|
307
|
+
loop_response = _make_loop_response()
|
|
308
|
+
|
|
309
|
+
from unique_orchestrator.unique_ai import UniqueAI
|
|
310
|
+
|
|
311
|
+
ua = UniqueAI(
|
|
312
|
+
logger=MagicMock(),
|
|
313
|
+
event=dummy_event,
|
|
314
|
+
config=mock_config,
|
|
315
|
+
chat_service=mock_chat_service,
|
|
316
|
+
content_service=MagicMock(),
|
|
317
|
+
debug_info_manager=mock_debug_info_manager,
|
|
318
|
+
streaming_handler=MagicMock(),
|
|
319
|
+
reference_manager=MagicMock(),
|
|
320
|
+
thinking_manager=MagicMock(),
|
|
321
|
+
tool_manager=mock_tool_manager,
|
|
322
|
+
history_manager=mock_history_manager,
|
|
323
|
+
evaluation_manager=mock_evaluation_manager,
|
|
324
|
+
postprocessor_manager=mock_postprocessor_manager,
|
|
325
|
+
message_step_logger=MagicMock(),
|
|
326
|
+
mcp_servers=[],
|
|
327
|
+
loop_iteration_runner=AsyncMock(return_value=loop_response),
|
|
328
|
+
)
|
|
329
|
+
ua._render_user_prompt = AsyncMock(return_value="user") # type: ignore[method-assign]
|
|
330
|
+
ua._render_system_prompt = AsyncMock(return_value="system") # type: ignore[method-assign]
|
|
331
|
+
ua._thinking_manager.thinking_is_displayed = MagicMock(return_value=True)
|
|
332
|
+
return ua
|
|
333
|
+
|
|
334
|
+
@pytest.mark.ai
|
|
335
|
+
@pytest.mark.asyncio
|
|
336
|
+
async def test_run__debug_info_manager__receives_loop_params_key(
|
|
337
|
+
self, monkeypatch
|
|
338
|
+
) -> None:
|
|
339
|
+
"""
|
|
340
|
+
Purpose: Verify that run() calls debug_info_manager.add with 'loop_params'
|
|
341
|
+
after the agentic loop completes.
|
|
342
|
+
Why this matters: Without this call, loop-level debug params are never
|
|
343
|
+
persisted and the feature has no observable effect.
|
|
344
|
+
Setup summary: Run one iteration; assert add was called with 'loop_params'
|
|
345
|
+
as the first argument.
|
|
346
|
+
"""
|
|
347
|
+
ua = self._build_run_ua(monkeypatch)
|
|
348
|
+
|
|
349
|
+
await ua.run()
|
|
350
|
+
|
|
351
|
+
keys_written = [
|
|
352
|
+
call.args[0] for call in ua._debug_info_manager.add.call_args_list
|
|
353
|
+
]
|
|
354
|
+
assert "loop_params" in keys_written
|
|
355
|
+
|
|
356
|
+
@pytest.mark.ai
|
|
357
|
+
@pytest.mark.asyncio
|
|
358
|
+
async def test_run__loop_params__contains_one_entry_per_iteration(
|
|
359
|
+
self, monkeypatch
|
|
360
|
+
) -> None:
|
|
361
|
+
"""
|
|
362
|
+
Purpose: Verify that the loop_params value written to the debug info manager
|
|
363
|
+
contains exactly one entry for a single-iteration run.
|
|
364
|
+
Why this matters: The entry count must match the iteration count so that
|
|
365
|
+
loop_params and execution_time.loop_iterations stay in sync.
|
|
366
|
+
Setup summary: Run with effective_max_loop_iterations=1; find the loop_params
|
|
367
|
+
add call; assert the value is a list of length 1.
|
|
368
|
+
"""
|
|
369
|
+
ua = self._build_run_ua(monkeypatch)
|
|
370
|
+
|
|
371
|
+
await ua.run()
|
|
372
|
+
|
|
373
|
+
loop_params_call = next(
|
|
374
|
+
call
|
|
375
|
+
for call in ua._debug_info_manager.add.call_args_list
|
|
376
|
+
if call.args[0] == "loop_params"
|
|
377
|
+
)
|
|
378
|
+
payload = loop_params_call.args[1]
|
|
379
|
+
assert isinstance(payload, list)
|
|
380
|
+
assert len(payload) == 1
|
|
381
|
+
|
|
382
|
+
@pytest.mark.ai
|
|
383
|
+
@pytest.mark.asyncio
|
|
384
|
+
async def test_run__loop_params_entry__contains_loop_number_and_thinking_level(
|
|
385
|
+
self, monkeypatch
|
|
386
|
+
) -> None:
|
|
387
|
+
"""
|
|
388
|
+
Purpose: Verify that each entry in loop_params has the expected keys.
|
|
389
|
+
Why this matters: Consumers read these keys by name; missing keys cause
|
|
390
|
+
KeyErrors or require defensive coding across all consumers.
|
|
391
|
+
Setup summary: Run one iteration; assert the single entry has both
|
|
392
|
+
'loop_number' and 'thinking_level'.
|
|
393
|
+
"""
|
|
394
|
+
ua = self._build_run_ua(monkeypatch)
|
|
395
|
+
|
|
396
|
+
await ua.run()
|
|
397
|
+
|
|
398
|
+
loop_params_call = next(
|
|
399
|
+
call
|
|
400
|
+
for call in ua._debug_info_manager.add.call_args_list
|
|
401
|
+
if call.args[0] == "loop_params"
|
|
402
|
+
)
|
|
403
|
+
entry = loop_params_call.args[1][0]
|
|
404
|
+
assert "loop_number" in entry
|
|
405
|
+
assert "thinking_level" in entry
|
|
406
|
+
|
|
407
|
+
@pytest.mark.ai
|
|
408
|
+
@pytest.mark.asyncio
|
|
409
|
+
async def test_run__loop_debug_params__reset_on_each_run(self, monkeypatch) -> None:
|
|
410
|
+
"""
|
|
411
|
+
Purpose: Verify that _loop_debug_params is cleared at the start of each
|
|
412
|
+
run() call.
|
|
413
|
+
Why this matters: Without a reset, consecutive run() calls on the same
|
|
414
|
+
instance accumulate stale entries from previous runs, inflating the
|
|
415
|
+
loop_params list.
|
|
416
|
+
Setup summary: Run twice; assert _loop_debug_params has exactly one entry
|
|
417
|
+
after the second run (not two).
|
|
418
|
+
"""
|
|
419
|
+
ua = self._build_run_ua(monkeypatch)
|
|
420
|
+
|
|
421
|
+
await ua.run()
|
|
422
|
+
await ua.run()
|
|
423
|
+
|
|
424
|
+
assert len(ua._loop_debug_params) == 1
|
unique_orchestrator-2026.22.0.dev8/unique_orchestrator/tests/test_utils_resolve_other_options.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Tests for utils.resolve_other_options().
|
|
2
|
+
|
|
3
|
+
Verifies that the highest effort level is selected across
|
|
4
|
+
``additional_llm_options`` from config and the ``thinking_level`` hints
|
|
5
|
+
declared on skills activated in the current run, and that the effort is
|
|
6
|
+
written in the format required by the active API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
|
|
13
|
+
from unique_skill_tool.service import SkillTool
|
|
14
|
+
|
|
15
|
+
from unique_orchestrator.utils import resolve_other_options
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_config(
|
|
19
|
+
additional_llm_options: dict,
|
|
20
|
+
use_responses_api: bool = False,
|
|
21
|
+
supported_reasoning_efforts: list[str] | None = None,
|
|
22
|
+
) -> MagicMock:
|
|
23
|
+
config = MagicMock()
|
|
24
|
+
config.agent.experimental.additional_llm_options = additional_llm_options
|
|
25
|
+
config.agent.experimental.responses_api_config.use_responses_api = use_responses_api
|
|
26
|
+
config.agent.experimental.use_responses_api = use_responses_api
|
|
27
|
+
config.space.language_model.supported_reasoning_efforts = (
|
|
28
|
+
supported_reasoning_efforts
|
|
29
|
+
)
|
|
30
|
+
return config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_tool_manager(skill_tool: SkillTool | None = None) -> MagicMock:
|
|
34
|
+
tool_manager = MagicMock()
|
|
35
|
+
tool_manager.get_tool_by_name.return_value = skill_tool
|
|
36
|
+
return tool_manager
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _make_skill_tool_with_max(max_level: str | None) -> SkillTool:
|
|
40
|
+
tool = MagicMock(spec=SkillTool)
|
|
41
|
+
tool.max_thinking_level = max_level
|
|
42
|
+
return tool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _call(
|
|
46
|
+
additional_llm_options: dict,
|
|
47
|
+
skill_tool: SkillTool | None = None,
|
|
48
|
+
use_responses_api: bool = False,
|
|
49
|
+
supported_reasoning_efforts: list[str] | None = None,
|
|
50
|
+
) -> dict:
|
|
51
|
+
return resolve_other_options(
|
|
52
|
+
config=_make_config(
|
|
53
|
+
additional_llm_options, use_responses_api, supported_reasoning_efforts
|
|
54
|
+
),
|
|
55
|
+
tool_manager=_make_tool_manager(skill_tool),
|
|
56
|
+
logger=MagicMock(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestResolveOtherOptions:
|
|
61
|
+
def test_returns_config_options_unchanged_when_no_skill_tool(self) -> None:
|
|
62
|
+
result = _call({"temperature": 0.5})
|
|
63
|
+
assert result == {"temperature": 0.5}
|
|
64
|
+
|
|
65
|
+
def test_returns_config_options_unchanged_when_skill_tool_has_no_hint(self) -> None:
|
|
66
|
+
skill_tool = _make_skill_tool_with_max(None)
|
|
67
|
+
result = _call({"temperature": 0.5}, skill_tool=skill_tool)
|
|
68
|
+
assert result == {"temperature": 0.5}
|
|
69
|
+
|
|
70
|
+
def test_injects_skill_hint_when_config_has_no_reasoning_effort(self) -> None:
|
|
71
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
72
|
+
result = _call({}, skill_tool=skill_tool)
|
|
73
|
+
assert result["reasoning_effort"] == "high"
|
|
74
|
+
|
|
75
|
+
def test_config_effort_wins_when_higher_than_skill(self) -> None:
|
|
76
|
+
skill_tool = _make_skill_tool_with_max("low")
|
|
77
|
+
result = _call({"reasoning_effort": "high"}, skill_tool=skill_tool)
|
|
78
|
+
assert result["reasoning_effort"] == "high"
|
|
79
|
+
|
|
80
|
+
def test_skill_hint_wins_when_higher_than_config(self) -> None:
|
|
81
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
82
|
+
result = _call({"reasoning_effort": "low"}, skill_tool=skill_tool)
|
|
83
|
+
assert result["reasoning_effort"] == "high"
|
|
84
|
+
|
|
85
|
+
def test_equal_levels_preserved(self) -> None:
|
|
86
|
+
skill_tool = _make_skill_tool_with_max("medium")
|
|
87
|
+
result = _call({"reasoning_effort": "medium"}, skill_tool=skill_tool)
|
|
88
|
+
assert result["reasoning_effort"] == "medium"
|
|
89
|
+
|
|
90
|
+
def test_other_options_keys_passed_through_unchanged(self) -> None:
|
|
91
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
92
|
+
result = _call(
|
|
93
|
+
{"reasoning_effort": "low", "responseFormat": {"type": "json_object"}},
|
|
94
|
+
skill_tool=skill_tool,
|
|
95
|
+
)
|
|
96
|
+
assert result["reasoning_effort"] == "high"
|
|
97
|
+
assert result["responseFormat"] == {"type": "json_object"}
|
|
98
|
+
|
|
99
|
+
def test_does_not_mutate_config_dict(self) -> None:
|
|
100
|
+
original = {"reasoning_effort": "low"}
|
|
101
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
102
|
+
_call(original, skill_tool=skill_tool)
|
|
103
|
+
assert original == {"reasoning_effort": "low"}
|
|
104
|
+
|
|
105
|
+
# ── Responses API ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def test_reads_effort_from_nested_reasoning_dict_for_responses_api(self) -> None:
|
|
108
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
109
|
+
result = _call(
|
|
110
|
+
{"reasoning": {"effort": "low"}},
|
|
111
|
+
skill_tool=skill_tool,
|
|
112
|
+
use_responses_api=True,
|
|
113
|
+
)
|
|
114
|
+
assert result["reasoning"] == {"effort": "high"}
|
|
115
|
+
assert "reasoning_effort" not in result
|
|
116
|
+
|
|
117
|
+
def test_nested_effort_config_wins_when_higher_than_skill(self) -> None:
|
|
118
|
+
skill_tool = _make_skill_tool_with_max("low")
|
|
119
|
+
result = _call(
|
|
120
|
+
{"reasoning": {"effort": "high"}},
|
|
121
|
+
skill_tool=skill_tool,
|
|
122
|
+
use_responses_api=True,
|
|
123
|
+
)
|
|
124
|
+
assert result["reasoning"] == {"effort": "high"}
|
|
125
|
+
|
|
126
|
+
def test_skill_hint_injected_when_no_nested_effort_config(self) -> None:
|
|
127
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
128
|
+
result = _call({}, skill_tool=skill_tool, use_responses_api=True)
|
|
129
|
+
assert result["reasoning"] == {"effort": "high"}
|
|
130
|
+
assert "reasoning_effort" not in result
|
|
131
|
+
|
|
132
|
+
def test_flat_reasoning_effort_ignored_when_responses_api_active(self) -> None:
|
|
133
|
+
"""``reasoning_effort`` (flat) is in the wrong format for the responses API
|
|
134
|
+
and must not be picked up as the config effort value."""
|
|
135
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
136
|
+
result = _call(
|
|
137
|
+
{"reasoning_effort": "low"},
|
|
138
|
+
skill_tool=skill_tool,
|
|
139
|
+
use_responses_api=True,
|
|
140
|
+
)
|
|
141
|
+
assert result["reasoning"] == {"effort": "high"}
|
|
142
|
+
assert result.get("reasoning_effort") == "low"
|
|
143
|
+
|
|
144
|
+
def test_extra_reasoning_subkeys_preserved_for_responses_api(self) -> None:
|
|
145
|
+
"""Other keys inside ``reasoning`` (e.g. ``summary``) must survive."""
|
|
146
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
147
|
+
result = _call(
|
|
148
|
+
{"reasoning": {"effort": "low", "summary": "auto"}},
|
|
149
|
+
skill_tool=skill_tool,
|
|
150
|
+
use_responses_api=True,
|
|
151
|
+
)
|
|
152
|
+
assert result["reasoning"] == {"effort": "high", "summary": "auto"}
|
|
153
|
+
|
|
154
|
+
def test_json_string_reasoning_is_parsed_for_responses_api(self) -> None:
|
|
155
|
+
"""``reasoning`` stored as a JSON string must be parsed before use."""
|
|
156
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
157
|
+
result = _call(
|
|
158
|
+
{"reasoning": '{"effort": "low"}'},
|
|
159
|
+
skill_tool=skill_tool,
|
|
160
|
+
use_responses_api=True,
|
|
161
|
+
)
|
|
162
|
+
assert result["reasoning"] == {"effort": "high"}
|
|
163
|
+
|
|
164
|
+
def test_invalid_json_string_reasoning_treated_as_empty_for_responses_api(
|
|
165
|
+
self,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""An unparseable string must not crash; skill max is used as effort."""
|
|
168
|
+
skill_tool = _make_skill_tool_with_max("medium")
|
|
169
|
+
result = _call(
|
|
170
|
+
{"reasoning": "not-json"},
|
|
171
|
+
skill_tool=skill_tool,
|
|
172
|
+
use_responses_api=True,
|
|
173
|
+
)
|
|
174
|
+
assert result["reasoning"] == {"effort": "medium"}
|
|
175
|
+
|
|
176
|
+
# ── Completions API ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def test_nested_reasoning_effort_ignored_when_completions_api_active(self) -> None:
|
|
179
|
+
"""``reasoning: {effort: ...}`` is in the wrong format for the completions API
|
|
180
|
+
and must not be picked up as the config effort value."""
|
|
181
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
182
|
+
result = _call(
|
|
183
|
+
{"reasoning": {"effort": "low"}},
|
|
184
|
+
skill_tool=skill_tool,
|
|
185
|
+
use_responses_api=False,
|
|
186
|
+
)
|
|
187
|
+
assert result["reasoning_effort"] == "high"
|
|
188
|
+
assert result.get("reasoning") == {"effort": "low"}
|
|
189
|
+
|
|
190
|
+
# ── Invalid config effort ─────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def test_unrecognised_config_effort_falls_back_to_skill_max(self) -> None:
|
|
193
|
+
"""An unrecognised effort string must not crash; skill max is used instead."""
|
|
194
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
195
|
+
result = _call({"reasoning_effort": "very_high"}, skill_tool=skill_tool)
|
|
196
|
+
assert result["reasoning_effort"] == "high"
|
|
197
|
+
|
|
198
|
+
def test_unrecognised_config_effort_passes_through_when_no_skill_max(self) -> None:
|
|
199
|
+
"""When no skill thinking_level is active the invalid value passes through unchanged."""
|
|
200
|
+
skill_tool = _make_skill_tool_with_max(None)
|
|
201
|
+
result = _call({"reasoning_effort": "very_high"}, skill_tool=skill_tool)
|
|
202
|
+
assert result["reasoning_effort"] == "very_high"
|
|
203
|
+
|
|
204
|
+
def test_unrecognised_nested_effort_falls_back_to_skill_max_responses_api(
|
|
205
|
+
self,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Same guard for the responses-API nested path."""
|
|
208
|
+
skill_tool = _make_skill_tool_with_max("medium")
|
|
209
|
+
result = _call(
|
|
210
|
+
{"reasoning": {"effort": "very_high"}},
|
|
211
|
+
skill_tool=skill_tool,
|
|
212
|
+
use_responses_api=True,
|
|
213
|
+
)
|
|
214
|
+
assert result["reasoning"] == {"effort": "medium"}
|
|
215
|
+
|
|
216
|
+
# ── Supported reasoning efforts ───────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def test_effort_written_when_within_supported_list(self) -> None:
|
|
219
|
+
"""Resolved effort is written when it appears in the supported list."""
|
|
220
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
221
|
+
result = _call(
|
|
222
|
+
{},
|
|
223
|
+
skill_tool=skill_tool,
|
|
224
|
+
supported_reasoning_efforts=["low", "medium", "high"],
|
|
225
|
+
)
|
|
226
|
+
assert result["reasoning_effort"] == "high"
|
|
227
|
+
|
|
228
|
+
def test_effort_skipped_when_model_does_not_support_reasoning(self) -> None:
|
|
229
|
+
"""Empty supported list means the model does not support reasoning_effort at all."""
|
|
230
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
231
|
+
result = _call({}, skill_tool=skill_tool, supported_reasoning_efforts=[])
|
|
232
|
+
assert "reasoning_effort" not in result
|
|
233
|
+
|
|
234
|
+
def test_effort_skipped_when_resolved_effort_not_in_supported_list(self) -> None:
|
|
235
|
+
"""Resolved effort outside the supported list is not written."""
|
|
236
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
237
|
+
result = _call(
|
|
238
|
+
{},
|
|
239
|
+
skill_tool=skill_tool,
|
|
240
|
+
supported_reasoning_efforts=["low"],
|
|
241
|
+
)
|
|
242
|
+
assert "reasoning_effort" not in result
|
|
243
|
+
|
|
244
|
+
def test_original_options_preserved_when_effort_skipped(self) -> None:
|
|
245
|
+
"""Other options survive when the effort is skipped due to model constraints."""
|
|
246
|
+
skill_tool = _make_skill_tool_with_max("high")
|
|
247
|
+
result = _call(
|
|
248
|
+
{"temperature": 0.5},
|
|
249
|
+
skill_tool=skill_tool,
|
|
250
|
+
supported_reasoning_efforts=[],
|
|
251
|
+
)
|
|
252
|
+
assert result == {"temperature": 0.5}
|
|
253
|
+
|
|
254
|
+
# ── No effort resolved ────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def test_no_effort_resolved_leaves_options_unchanged(self) -> None:
|
|
257
|
+
"""When neither config nor skill provides an effort, options pass through."""
|
|
258
|
+
result = _call({"responseFormat": {"type": "json_object"}})
|
|
259
|
+
assert result == {"responseFormat": {"type": "json_object"}}
|
|
260
|
+
assert "reasoning_effort" not in result
|
|
261
|
+
assert "reasoning" not in result
|
|
@@ -57,6 +57,7 @@ from unique_orchestrator._builders.inject_tool_reminders import (
|
|
|
57
57
|
from unique_orchestrator._builders.skill_setup import preload_invoked_skills
|
|
58
58
|
from unique_orchestrator.config import UniqueAIConfig
|
|
59
59
|
from unique_orchestrator.settings import env_settings
|
|
60
|
+
from unique_orchestrator.utils import resolve_other_options
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
class UniqueAI:
|
|
@@ -187,6 +188,7 @@ class UniqueAI:
|
|
|
187
188
|
|
|
188
189
|
self._execution_times: list[dict[str, Any]] = []
|
|
189
190
|
self._current_loop_timing: dict[str, Any] = {}
|
|
191
|
+
self._loop_debug_params: list[dict[str, Any]] = []
|
|
190
192
|
|
|
191
193
|
async def _on_cancellation(self, _event: CancellationEvent) -> None:
|
|
192
194
|
"""Subscriber called by the cancellation event bus."""
|
|
@@ -211,6 +213,7 @@ class UniqueAI:
|
|
|
211
213
|
)
|
|
212
214
|
|
|
213
215
|
self._execution_times = []
|
|
216
|
+
self._loop_debug_params = []
|
|
214
217
|
run_start = time.perf_counter()
|
|
215
218
|
|
|
216
219
|
await preload_invoked_skills(
|
|
@@ -305,6 +308,7 @@ class UniqueAI:
|
|
|
305
308
|
"total_time": round(time.perf_counter() - run_start, 3),
|
|
306
309
|
},
|
|
307
310
|
)
|
|
311
|
+
self._debug_info_manager.add("loop_params", self._loop_debug_params)
|
|
308
312
|
|
|
309
313
|
tool_names = [
|
|
310
314
|
tool["name"] for tool in self._debug_info_manager.get()["tools"]
|
|
@@ -327,6 +331,20 @@ class UniqueAI:
|
|
|
327
331
|
finally:
|
|
328
332
|
sub.cancel()
|
|
329
333
|
|
|
334
|
+
def _record_loop_debug_params(self, other_options: dict) -> None:
|
|
335
|
+
reasoning = other_options.get("reasoning")
|
|
336
|
+
thinking_level: str = (
|
|
337
|
+
other_options.get("reasoning_effort")
|
|
338
|
+
if not isinstance(reasoning, dict)
|
|
339
|
+
else reasoning.get("effort")
|
|
340
|
+
) or "None"
|
|
341
|
+
self._loop_debug_params.append(
|
|
342
|
+
{
|
|
343
|
+
"loop_number": self.current_iteration_index,
|
|
344
|
+
"thinking_level": thinking_level,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
330
348
|
# @track()
|
|
331
349
|
async def _plan_or_execute(self) -> LanguageModelStreamResponse:
|
|
332
350
|
self._logger.info("Planning or executing the loop.")
|
|
@@ -334,6 +352,11 @@ class UniqueAI:
|
|
|
334
352
|
|
|
335
353
|
self._logger.info("Done composing message plan execution.")
|
|
336
354
|
|
|
355
|
+
other_options = resolve_other_options(
|
|
356
|
+
self._config, self._tool_manager, self._logger
|
|
357
|
+
)
|
|
358
|
+
self._record_loop_debug_params(other_options)
|
|
359
|
+
|
|
337
360
|
kwargs: dict = dict(
|
|
338
361
|
messages=messages,
|
|
339
362
|
iteration_index=self.current_iteration_index,
|
|
@@ -345,7 +368,7 @@ class UniqueAI:
|
|
|
345
368
|
debug_info=self._debug_info_manager.get(),
|
|
346
369
|
temperature=self._config.agent.experimental.temperature,
|
|
347
370
|
tool_choices=self._tool_manager.get_forced_tools(), # type: ignore (as above)
|
|
348
|
-
other_options=
|
|
371
|
+
other_options=other_options,
|
|
349
372
|
)
|
|
350
373
|
|
|
351
374
|
# Experimental Feature UN-17905
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from logging import Logger
|
|
5
|
+
|
|
6
|
+
from unique_skill_tool.service import SkillTool
|
|
7
|
+
from unique_toolkit.agentic.feature_flags import feature_flags
|
|
8
|
+
from unique_toolkit.agentic.tools.tool_manager import (
|
|
9
|
+
ResponsesApiToolManager,
|
|
10
|
+
ToolManager,
|
|
11
|
+
)
|
|
12
|
+
from unique_toolkit.app.schemas import ChatEventAdditionalParameters
|
|
13
|
+
from unique_toolkit.content import Content
|
|
14
|
+
from unique_toolkit.language_model.schemas import (
|
|
15
|
+
REASONING_EFFORT_ORDER,
|
|
16
|
+
to_reasoning_effort,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from unique_orchestrator.config import UniqueAIConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_other_options(
|
|
23
|
+
config: UniqueAIConfig,
|
|
24
|
+
tool_manager: ToolManager | ResponsesApiToolManager,
|
|
25
|
+
logger: Logger,
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Build ``other_options`` for the LLM call.
|
|
28
|
+
|
|
29
|
+
Merges ``additional_llm_options`` from config with the highest
|
|
30
|
+
``thinking_level`` declared across all skills activated in this run.
|
|
31
|
+
The final effort level is the highest value among the config setting
|
|
32
|
+
(if any) and the skill hint (if any).
|
|
33
|
+
|
|
34
|
+
Determines the active API first, then reads the effort from the
|
|
35
|
+
matching format and writes the resolved value back in that same format:
|
|
36
|
+
- completions API: ``reasoning_effort: "high"``
|
|
37
|
+
- responses API: ``reasoning: {"effort": "high"}``
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
options: dict = dict(config.agent.experimental.additional_llm_options)
|
|
41
|
+
|
|
42
|
+
skill_tool = tool_manager.get_tool_by_name(SkillTool.name)
|
|
43
|
+
if not isinstance(skill_tool, SkillTool) or skill_tool.max_thinking_level is None:
|
|
44
|
+
return options
|
|
45
|
+
|
|
46
|
+
skill_max: str = skill_tool.max_thinking_level
|
|
47
|
+
|
|
48
|
+
use_responses_api: bool = (
|
|
49
|
+
config.agent.experimental.responses_api_config.use_responses_api
|
|
50
|
+
or config.agent.experimental.use_responses_api
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if use_responses_api:
|
|
54
|
+
reasoning_raw = options.get("reasoning")
|
|
55
|
+
if isinstance(reasoning_raw, str):
|
|
56
|
+
try:
|
|
57
|
+
reasoning_dict: dict = json.loads(reasoning_raw)
|
|
58
|
+
except (json.JSONDecodeError, ValueError):
|
|
59
|
+
reasoning_dict = {}
|
|
60
|
+
elif isinstance(reasoning_raw, dict):
|
|
61
|
+
reasoning_dict = reasoning_raw
|
|
62
|
+
else:
|
|
63
|
+
reasoning_dict = {}
|
|
64
|
+
config_effort: str | None = reasoning_dict.get("effort")
|
|
65
|
+
else:
|
|
66
|
+
config_effort = options.get("reasoning_effort")
|
|
67
|
+
|
|
68
|
+
valid_config_effort: str | None = None
|
|
69
|
+
if config_effort is not None:
|
|
70
|
+
try:
|
|
71
|
+
valid_config_effort = to_reasoning_effort(config_effort)
|
|
72
|
+
except ValueError:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"additional_llm_options contains an unrecognised "
|
|
75
|
+
"reasoning_effort value %r — cannot compare it with "
|
|
76
|
+
"the skill's thinking_level; using skill maximum %r instead.",
|
|
77
|
+
config_effort,
|
|
78
|
+
skill_max,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
resolved_effort: str = (
|
|
82
|
+
skill_max
|
|
83
|
+
if valid_config_effort is None
|
|
84
|
+
else max([valid_config_effort, skill_max], key=REASONING_EFFORT_ORDER.index)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
supported_efforts = config.space.language_model.supported_reasoning_efforts
|
|
88
|
+
if supported_efforts is not None:
|
|
89
|
+
if len(supported_efforts) == 0:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"Skipping reasoning_effort %r: model %s does not support reasoning_effort.",
|
|
92
|
+
resolved_effort,
|
|
93
|
+
config.space.language_model.name,
|
|
94
|
+
)
|
|
95
|
+
return options
|
|
96
|
+
if resolved_effort not in supported_efforts:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Skipping reasoning_effort %r: model %s only supports %s.",
|
|
99
|
+
resolved_effort,
|
|
100
|
+
config.space.language_model.name,
|
|
101
|
+
supported_efforts,
|
|
102
|
+
)
|
|
103
|
+
return options
|
|
104
|
+
|
|
105
|
+
if use_responses_api:
|
|
106
|
+
existing_reasoning: dict = dict(reasoning_dict)
|
|
107
|
+
existing_reasoning["effort"] = resolved_effort
|
|
108
|
+
options["reasoning"] = existing_reasoning
|
|
109
|
+
else:
|
|
110
|
+
options["reasoning_effort"] = resolved_effort
|
|
111
|
+
|
|
112
|
+
return options
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def filter_uploaded_documents_by_selection(
|
|
116
|
+
documents: list[Content],
|
|
117
|
+
additional_parameters: ChatEventAdditionalParameters | None,
|
|
118
|
+
company_id: str,
|
|
119
|
+
) -> list[Content]:
|
|
120
|
+
"""Return only the documents the user has actively selected.
|
|
121
|
+
|
|
122
|
+
Returns *all* documents unchanged when:
|
|
123
|
+
- the feature flag is disabled for ``company_id``,
|
|
124
|
+
- ``additional_parameters`` is ``None``.
|
|
125
|
+
"""
|
|
126
|
+
if not feature_flags.enable_selected_uploaded_files_un_18215.is_enabled(company_id):
|
|
127
|
+
return documents
|
|
128
|
+
|
|
129
|
+
if additional_parameters is None:
|
|
130
|
+
return documents
|
|
131
|
+
|
|
132
|
+
selected_ids = additional_parameters.selected_uploaded_file_ids
|
|
133
|
+
return [doc for doc in documents if doc.id in selected_ids]
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from unique_toolkit.agentic.feature_flags import feature_flags
|
|
4
|
-
from unique_toolkit.app.schemas import ChatEventAdditionalParameters
|
|
5
|
-
from unique_toolkit.content import Content
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def filter_uploaded_documents_by_selection(
|
|
9
|
-
documents: list[Content],
|
|
10
|
-
additional_parameters: ChatEventAdditionalParameters | None,
|
|
11
|
-
company_id: str,
|
|
12
|
-
) -> list[Content]:
|
|
13
|
-
"""Return only the documents the user has actively selected.
|
|
14
|
-
|
|
15
|
-
Returns *all* documents unchanged when:
|
|
16
|
-
- the feature flag is disabled for ``company_id``,
|
|
17
|
-
- ``additional_parameters`` is ``None``.
|
|
18
|
-
"""
|
|
19
|
-
if not feature_flags.enable_selected_uploaded_files_un_18215.is_enabled(company_id):
|
|
20
|
-
return documents
|
|
21
|
-
|
|
22
|
-
if additional_parameters is None:
|
|
23
|
-
return documents
|
|
24
|
-
|
|
25
|
-
selected_ids = additional_parameters.selected_uploaded_file_ids
|
|
26
|
-
return [doc for doc in documents if doc.id in selected_ids]
|
|
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
|