unique_orchestrator 2026.18.0.dev3__tar.gz → 2026.18.0.dev5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unique_orchestrator-2026.18.0.dev5/PKG-INFO +26 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/pyproject.toml +15 -11
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/_builders/__init__.py +4 -0
- unique_orchestrator-2026.18.0.dev5/unique_orchestrator/_builders/inject_tool_reminders.py +88 -0
- unique_orchestrator-2026.18.0.dev5/unique_orchestrator/_builders/skill_setup.py +399 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/config.py +5 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_builder_uploaded_search.py +1 -5
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/unique_ai.py +25 -2
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/unique_ai_builder.py +19 -2
- unique_orchestrator-2026.18.0.dev3/PKG-INFO +0 -24
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/README.md +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_builder_selected_uploaded_files.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/tests/test_unique_ai_selected_uploaded_files.py +0 -0
- {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev5}/unique_orchestrator/utils.py +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: unique-orchestrator
|
|
3
|
+
Version: 2026.18.0.dev5
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Andreas Hauri
|
|
6
|
+
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
7
|
+
License: Proprietary
|
|
8
|
+
Requires-Dist: pydantic>=2.8.2,<3
|
|
9
|
+
Requires-Dist: typing-extensions>=4.9.0,<5
|
|
10
|
+
Requires-Dist: jinja2>=3.1.0,<4
|
|
11
|
+
Requires-Dist: unique-toolkit>=2026.18.0.dev5
|
|
12
|
+
Requires-Dist: unique-stock-ticker>=2026.18.0.dev3
|
|
13
|
+
Requires-Dist: unique-follow-up-questions>=2026.18.0.dev3
|
|
14
|
+
Requires-Dist: unique-internal-search>=2026.18.0.dev3
|
|
15
|
+
Requires-Dist: unique-deep-research>=2026.18.0.dev3
|
|
16
|
+
Requires-Dist: unique-web-search>=2026.18.0.dev3
|
|
17
|
+
Requires-Dist: unique-swot>=2026.18.0.dev3
|
|
18
|
+
Requires-Dist: unique-skill-tool>=2026.18.0.dev1
|
|
19
|
+
Requires-Dist: openai>=1.109.1,<3
|
|
20
|
+
Requires-Dist: python-frontmatter>=1.1.0,<2
|
|
21
|
+
Requires-Python: >=3.12, <4
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Unique Orchestrator
|
|
25
|
+
|
|
26
|
+
Unique AI orchestrator agent to struct and direct subagents, tools and services
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_orchestrator"
|
|
3
|
-
version = "2026.18.0.
|
|
3
|
+
version = "2026.18.0.dev5"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -12,14 +12,16 @@ dependencies = [
|
|
|
12
12
|
"pydantic>=2.8.2,<3",
|
|
13
13
|
"typing-extensions>=4.9.0,<5",
|
|
14
14
|
"jinja2>=3.1.0,<4",
|
|
15
|
-
"unique-toolkit>=2026.18.0.
|
|
16
|
-
"unique-stock-ticker>=2026.18.0.
|
|
17
|
-
"unique-follow-up-questions>=2026.18.0.
|
|
18
|
-
"unique-internal-search>=2026.18.0.
|
|
19
|
-
"unique-deep-research>=2026.18.0.
|
|
20
|
-
"unique-web-search>=2026.18.0.
|
|
21
|
-
"unique-swot>=2026.18.0.
|
|
15
|
+
"unique-toolkit>=2026.18.0.dev5",
|
|
16
|
+
"unique-stock-ticker>=2026.18.0.dev3",
|
|
17
|
+
"unique-follow-up-questions>=2026.18.0.dev3",
|
|
18
|
+
"unique-internal-search>=2026.18.0.dev3",
|
|
19
|
+
"unique-deep-research>=2026.18.0.dev3",
|
|
20
|
+
"unique-web-search>=2026.18.0.dev3",
|
|
21
|
+
"unique-swot>=2026.18.0.dev3",
|
|
22
|
+
"unique-skill-tool>=2026.18.0.dev1",
|
|
22
23
|
"openai>=1.109.1,<3",
|
|
24
|
+
"python-frontmatter>=1.1.0,<2",
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
[build-system]
|
|
@@ -43,6 +45,7 @@ exclude-newer = "2 weeks"
|
|
|
43
45
|
"unique-stock-ticker" = false
|
|
44
46
|
"unique-swot" = false
|
|
45
47
|
"unique-toolkit" = false
|
|
48
|
+
"unique-skill-tool" = false
|
|
46
49
|
"unique-web-search" = false
|
|
47
50
|
|
|
48
51
|
[tool.uv.sources]
|
|
@@ -52,6 +55,7 @@ unique-stock-ticker = { workspace = true }
|
|
|
52
55
|
unique-follow-up-questions = { workspace = true }
|
|
53
56
|
unique-internal-search = { workspace = true }
|
|
54
57
|
unique-swot = { workspace = true }
|
|
58
|
+
unique-skill-tool = { workspace = true }
|
|
55
59
|
unique-web-search = { workspace = true }
|
|
56
60
|
unique-deep-research = { workspace = true }
|
|
57
61
|
|
|
@@ -82,12 +86,12 @@ filterwarnings = [
|
|
|
82
86
|
]
|
|
83
87
|
|
|
84
88
|
[tool.deptry]
|
|
85
|
-
known_first_party = ["unique_orchestrator", "unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot"]
|
|
89
|
+
known_first_party = ["unique_orchestrator", "unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot", "unique_skill_tool"]
|
|
86
90
|
extend_exclude = ["unique_orchestrator/tests"]
|
|
87
91
|
|
|
88
92
|
[tool.deptry.per_rule_ignores]
|
|
89
|
-
DEP002 = ["unique-toolkit", "unique-stock-ticker", "unique-follow-up-questions", "unique-internal-search", "unique-deep-research", "unique-web-search", "unique-swot"]
|
|
90
|
-
DEP003 = ["unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot"]
|
|
93
|
+
DEP002 = ["unique-toolkit", "unique-stock-ticker", "unique-follow-up-questions", "unique-internal-search", "unique-deep-research", "unique-web-search", "unique-swot", "unique-skill-tool"]
|
|
94
|
+
DEP003 = ["unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot", "unique_skill_tool"]
|
|
91
95
|
|
|
92
96
|
[tool.basedpyright]
|
|
93
97
|
typeCheckingMode = "standard"
|
|
@@ -6,10 +6,14 @@ from unique_orchestrator._builders.open_file_setup import (
|
|
|
6
6
|
configure_file_payload,
|
|
7
7
|
handle_uploaded_file_tool_choices,
|
|
8
8
|
)
|
|
9
|
+
from unique_orchestrator._builders.skill_setup import (
|
|
10
|
+
configure_skill_tool,
|
|
11
|
+
)
|
|
9
12
|
|
|
10
13
|
__all__ = [
|
|
11
14
|
"build_loop_iteration_runner",
|
|
12
15
|
"configure_file_payload",
|
|
16
|
+
"configure_skill_tool",
|
|
13
17
|
"handle_uploaded_file_tool_choices",
|
|
14
18
|
"build_responses_loop_iteration_runner",
|
|
15
19
|
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Inject per-turn tool reminders as standalone user content parts.
|
|
2
|
+
|
|
3
|
+
Tools expose two per-turn hooks:
|
|
4
|
+
|
|
5
|
+
- `Tool.tool_system_reminder_for_user_prompt` — a ``<system-reminder>`` block
|
|
6
|
+
describing the tool's current state (e.g. the Skill tool's listing
|
|
7
|
+
of currently-available skills). Regenerated every loop iteration.
|
|
8
|
+
|
|
9
|
+
The orchestrator collects it, filters empties, and hands the list
|
|
10
|
+
to this module. We surface each string as its own
|
|
11
|
+
``{"type": "text"}`` entry on the latest user message, prepended
|
|
12
|
+
before the residual query text::
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"role": "user",
|
|
16
|
+
"content": [
|
|
17
|
+
{"type": "text", "text": "<system-reminder>...skills...</system-reminder>"},
|
|
18
|
+
{"type": "text", "text": "commit my staged changes"}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
The user message may already be a multi-part list when this runs
|
|
23
|
+
(the open-file tool injects file parts before this step), so both
|
|
24
|
+
``str`` and ``list`` content shapes are supported. Non-text parts
|
|
25
|
+
(e.g. ``image_url``, ``file``) are preserved.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from unique_toolkit.language_model.schemas import (
|
|
33
|
+
LanguageModelMessageRole,
|
|
34
|
+
LanguageModelMessages,
|
|
35
|
+
LanguageModelUserMessage,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_last_user_index(messages: LanguageModelMessages) -> int | None:
|
|
40
|
+
for i in range(len(messages.root) - 1, -1, -1):
|
|
41
|
+
if messages.root[i].role == LanguageModelMessageRole.USER:
|
|
42
|
+
return i
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _content_as_parts(content: object) -> list[dict[str, Any]] | None:
|
|
47
|
+
"""Normalise a user-message ``content`` value to a parts list.
|
|
48
|
+
|
|
49
|
+
Returns ``None`` when the content is neither a string nor a list
|
|
50
|
+
of part dicts (i.e. nothing for us to process).
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(content, str):
|
|
53
|
+
return [{"type": "text", "text": content}]
|
|
54
|
+
if isinstance(content, list):
|
|
55
|
+
return [p for p in content if isinstance(p, dict)] # pyright: ignore[reportUnnecessaryIsInstance]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inject_tool_reminders_into_user_message(
|
|
60
|
+
messages: LanguageModelMessages,
|
|
61
|
+
tool_reminders: list[str],
|
|
62
|
+
) -> LanguageModelMessages:
|
|
63
|
+
"""Prepend each non-empty ``tool_reminder`` as a text part.
|
|
64
|
+
|
|
65
|
+
The reminders are inserted at the head of the latest user-role
|
|
66
|
+
message's ``content`` array, in the order given, ahead of the
|
|
67
|
+
residual query text and any other existing parts. Empty strings
|
|
68
|
+
are skipped. Returns ``messages`` unchanged if there are no
|
|
69
|
+
non-empty reminders or no user message in the stream.
|
|
70
|
+
"""
|
|
71
|
+
non_empty_reminders = [r for r in tool_reminders if r]
|
|
72
|
+
if not non_empty_reminders or not messages.root:
|
|
73
|
+
return messages
|
|
74
|
+
|
|
75
|
+
user_idx = _find_last_user_index(messages)
|
|
76
|
+
if user_idx is None:
|
|
77
|
+
return messages
|
|
78
|
+
|
|
79
|
+
existing_parts = _content_as_parts(messages.root[user_idx].content)
|
|
80
|
+
if existing_parts is None:
|
|
81
|
+
return messages
|
|
82
|
+
|
|
83
|
+
new_parts: list[dict[str, Any]] = [
|
|
84
|
+
{"type": "text", "text": reminder} for reminder in non_empty_reminders
|
|
85
|
+
]
|
|
86
|
+
new_parts.extend(existing_parts)
|
|
87
|
+
messages.root[user_idx] = LanguageModelUserMessage(content=new_parts)
|
|
88
|
+
return messages
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Helpers for the Skill tool.
|
|
2
|
+
|
|
3
|
+
Loads skill definitions from the knowledge base and registers the
|
|
4
|
+
SkillTool with the tool manager.
|
|
5
|
+
|
|
6
|
+
Skill files are ``.md`` documents with optional YAML frontmatter::
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
name: summarize-report
|
|
10
|
+
description: >-
|
|
11
|
+
Summarize a document into key findings.
|
|
12
|
+
Use when the user asks for a summary or overview.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Summarize Report
|
|
16
|
+
...instructions...
|
|
17
|
+
|
|
18
|
+
The frontmatter fields are used for the skill listing shown to the
|
|
19
|
+
LLM. The body (everything after the frontmatter) is the prompt
|
|
20
|
+
content injected when the skill is invoked.
|
|
21
|
+
|
|
22
|
+
TODO: Add a skill-path reconciliation module that supports both KB
|
|
23
|
+
layouts and infers the skill name when frontmatter ``name:`` is
|
|
24
|
+
missing. Today every ``.md`` is treated as an independent skill and
|
|
25
|
+
is skipped unless the frontmatter carries both ``name`` and
|
|
26
|
+
``description`` — a folder-based skill (``<scope>/draft-email/
|
|
27
|
+
SKILL.md`` + assets) is indistinguishable from a flat-layout skill
|
|
28
|
+
(``<scope>/draft-email.md``), and authors must repeat the name in
|
|
29
|
+
frontmatter even though the filename or folder already implies it.
|
|
30
|
+
|
|
31
|
+
Target layouts:
|
|
32
|
+
* flat: ``<scope>/draft-email.md``
|
|
33
|
+
-> name inferred from filename stem
|
|
34
|
+
* folder: ``<scope>/draft-email/SKILL.md`` + sibling assets
|
|
35
|
+
-> name inferred from folder name; other ``.md`` files in
|
|
36
|
+
the folder are treated as assets, not separate skills
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
from logging import Logger
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
import frontmatter
|
|
46
|
+
from pydantic import ValidationError
|
|
47
|
+
from unique_skill_tool.schemas import SkillDefinition
|
|
48
|
+
from unique_skill_tool.service import SkillTool
|
|
49
|
+
from unique_skill_tool.utils import extract_prefix_skills
|
|
50
|
+
from unique_toolkit.agentic.tools.schemas import ToolCallResponse
|
|
51
|
+
from unique_toolkit.content.schemas import Content, ContentInfo
|
|
52
|
+
from unique_toolkit.content.smart_rules import (
|
|
53
|
+
AndStatement,
|
|
54
|
+
Operator,
|
|
55
|
+
OrStatement,
|
|
56
|
+
Statement,
|
|
57
|
+
)
|
|
58
|
+
from unique_toolkit.language_model.schemas import LanguageModelFunction
|
|
59
|
+
from unique_toolkit.services.knowledge_base import KnowledgeBaseService
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from unique_toolkit.agentic.history_manager.history_manager import HistoryManager
|
|
63
|
+
from unique_toolkit.agentic.tools.tool_manager import (
|
|
64
|
+
ResponsesApiToolManager,
|
|
65
|
+
ToolManager,
|
|
66
|
+
)
|
|
67
|
+
from unique_toolkit.app.schemas import ChatEvent
|
|
68
|
+
from unique_toolkit.content.service import ContentService
|
|
69
|
+
|
|
70
|
+
from unique_orchestrator.config import UniqueAIConfig
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SUBTREE_PAGE_SIZE = 100
|
|
74
|
+
_MAX_SUBTREE_ITEMS = 10_000
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_markdown(*, content: Content | ContentInfo) -> bool:
|
|
78
|
+
return content.key.lower().endswith(".md")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_subtree_metadata_filter(*, scope_ids: list[str]) -> dict[str, Any]:
|
|
82
|
+
"""Build a UniqueQL filter matching any file under the given scope IDs.
|
|
83
|
+
|
|
84
|
+
For each configured scope ID we add a ``folderIdPath CONTAINS
|
|
85
|
+
uniquepathid://<scope_id>`` predicate, then OR them together. The
|
|
86
|
+
backend's ACL is then applied to the matching set, so only files
|
|
87
|
+
the current user can access are returned.
|
|
88
|
+
"""
|
|
89
|
+
statements: list[Statement | AndStatement | OrStatement] = [
|
|
90
|
+
Statement(
|
|
91
|
+
operator=Operator.CONTAINS,
|
|
92
|
+
value=f"uniquepathid://{scope_id}",
|
|
93
|
+
path=["folderIdPath"],
|
|
94
|
+
)
|
|
95
|
+
for scope_id in scope_ids
|
|
96
|
+
]
|
|
97
|
+
if len(statements) == 1:
|
|
98
|
+
return statements[0].model_dump(mode="json")
|
|
99
|
+
return OrStatement(or_list=statements).model_dump(mode="json")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_frontmatter(*, text: str) -> tuple[dict[str, Any], str]:
|
|
103
|
+
"""Split YAML frontmatter from the markdown body.
|
|
104
|
+
|
|
105
|
+
Thin wrapper around ``python-frontmatter``. On a YAML parse error,
|
|
106
|
+
or when the frontmatter parses to a non-mapping (e.g. a top-level
|
|
107
|
+
YAML list), we fall back to ``({}, <stripped body>)`` so a broken
|
|
108
|
+
skill file never leaks its raw ``---\\nname: ...\\n---`` block into
|
|
109
|
+
the LLM prompt, but also never crashes ``_build_skill`` with an
|
|
110
|
+
unhandled ``TypeError``/``ValueError`` from ``dict(...)``.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
post = frontmatter.loads(text)
|
|
114
|
+
metadata = post.metadata
|
|
115
|
+
body = post.content
|
|
116
|
+
except Exception:
|
|
117
|
+
return {}, text
|
|
118
|
+
|
|
119
|
+
if not isinstance(metadata, dict):
|
|
120
|
+
return {}, body
|
|
121
|
+
|
|
122
|
+
return dict(metadata), body
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_skill(
|
|
126
|
+
*,
|
|
127
|
+
content: Content | ContentInfo,
|
|
128
|
+
file_text: str,
|
|
129
|
+
logger: Logger,
|
|
130
|
+
) -> SkillDefinition | None:
|
|
131
|
+
"""Build a ``SkillDefinition`` from the raw file text of a KB document.
|
|
132
|
+
|
|
133
|
+
Parses YAML frontmatter for ``name`` and ``description``. A malformed
|
|
134
|
+
``name`` (non-kebab-case, contains whitespace/punctuation, too long)
|
|
135
|
+
is rejected by ``SkillDefinition`` rather than silently flowing into
|
|
136
|
+
the OpenAI tool enum where the model could never emit it verbatim.
|
|
137
|
+
"""
|
|
138
|
+
if not file_text.strip():
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
metadata, body = _parse_frontmatter(text=file_text)
|
|
142
|
+
|
|
143
|
+
name = metadata.get("name")
|
|
144
|
+
description = metadata.get("description")
|
|
145
|
+
|
|
146
|
+
if not name or not description:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Skipping '%s' (%s): wrong skill format.",
|
|
149
|
+
content.key,
|
|
150
|
+
content.id,
|
|
151
|
+
)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
return SkillDefinition(
|
|
156
|
+
name=name,
|
|
157
|
+
description=description,
|
|
158
|
+
content=body,
|
|
159
|
+
)
|
|
160
|
+
except ValidationError as exc:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"Skipping '%s' (%s): invalid skill definition: %s",
|
|
163
|
+
content.key,
|
|
164
|
+
content.id,
|
|
165
|
+
exc.errors(include_url=False),
|
|
166
|
+
)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def load_skills_from_knowledge_base(
|
|
171
|
+
*,
|
|
172
|
+
content_service: ContentService,
|
|
173
|
+
knowledge_base_service: KnowledgeBaseService,
|
|
174
|
+
scope_ids: list[str],
|
|
175
|
+
logger: Logger,
|
|
176
|
+
) -> dict[str, SkillDefinition]:
|
|
177
|
+
"""Load all ``.md`` files from *scope_ids* (recursively) into a skill registry.
|
|
178
|
+
|
|
179
|
+
Pages through ``/content/infos`` with a UniqueQL metadata filter that
|
|
180
|
+
OR's ``folderIdPath CONTAINS uniquepathid://<scope_id>`` for every
|
|
181
|
+
configured scope. The public API caps ``take`` at 100, so results are
|
|
182
|
+
accumulated across pages up to ``_MAX_SUBTREE_ITEMS``. This matches
|
|
183
|
+
any file whose folder path contains the configured scope at any
|
|
184
|
+
depth, so:
|
|
185
|
+
|
|
186
|
+
* Each configured scope ID acts as a subtree root — all files inside
|
|
187
|
+
it and any of its descendants are considered.
|
|
188
|
+
* The backend's ACL is applied to the matching set, so only files
|
|
189
|
+
the current user can access are returned.
|
|
190
|
+
* To expose skills to users who only have access to specific
|
|
191
|
+
sub-folders, configure those sub-folder scope IDs directly.
|
|
192
|
+
|
|
193
|
+
File downloads are fanned out concurrently via ``asyncio.gather`` so
|
|
194
|
+
per-request build latency stays ~O(1) in the number of skills rather
|
|
195
|
+
than O(N) synchronous HTTP round-trips.
|
|
196
|
+
"""
|
|
197
|
+
if not scope_ids:
|
|
198
|
+
logger.info("SkillTool has no scope_ids configured.")
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
metadata_filter = _build_subtree_metadata_filter(scope_ids=scope_ids)
|
|
202
|
+
|
|
203
|
+
all_infos: list[ContentInfo] = []
|
|
204
|
+
skip = 0
|
|
205
|
+
try:
|
|
206
|
+
while skip < _MAX_SUBTREE_ITEMS:
|
|
207
|
+
paginated = knowledge_base_service.get_paginated_content_infos(
|
|
208
|
+
metadata_filter=metadata_filter,
|
|
209
|
+
skip=skip,
|
|
210
|
+
take=_SUBTREE_PAGE_SIZE,
|
|
211
|
+
)
|
|
212
|
+
all_infos.extend(paginated.content_infos)
|
|
213
|
+
skip += len(paginated.content_infos)
|
|
214
|
+
if (
|
|
215
|
+
len(paginated.content_infos) < _SUBTREE_PAGE_SIZE
|
|
216
|
+
or skip >= paginated.total_count
|
|
217
|
+
):
|
|
218
|
+
break
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"Failed to list contents for scope_ids=%s — "
|
|
222
|
+
"SkillTool will have an empty skill registry.",
|
|
223
|
+
scope_ids,
|
|
224
|
+
exc_info=True,
|
|
225
|
+
)
|
|
226
|
+
return {}
|
|
227
|
+
|
|
228
|
+
md_infos = [ci for ci in all_infos if _is_markdown(content=ci)]
|
|
229
|
+
if not md_infos:
|
|
230
|
+
logger.info("No .md files found for scope_ids=%s.", scope_ids)
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
download_results = await asyncio.gather(
|
|
234
|
+
*(
|
|
235
|
+
content_service.download_content_to_bytes_async(content_id=info.id)
|
|
236
|
+
for info in md_infos
|
|
237
|
+
),
|
|
238
|
+
return_exceptions=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
skill_registry: dict[str, SkillDefinition] = {}
|
|
242
|
+
for info, result in zip(md_infos, download_results, strict=True):
|
|
243
|
+
if isinstance(result, BaseException):
|
|
244
|
+
logger.warning(
|
|
245
|
+
"Failed to download '%s' (%s) — skipping.",
|
|
246
|
+
info.key,
|
|
247
|
+
info.id,
|
|
248
|
+
exc_info=result,
|
|
249
|
+
)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
file_text = result.decode("utf-8")
|
|
254
|
+
except UnicodeDecodeError:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"Failed to decode '%s' (%s) as UTF-8 — skipping.",
|
|
257
|
+
info.key,
|
|
258
|
+
info.id,
|
|
259
|
+
exc_info=True,
|
|
260
|
+
)
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
skill = _build_skill(content=info, file_text=file_text, logger=logger)
|
|
264
|
+
if skill is None:
|
|
265
|
+
logger.debug(
|
|
266
|
+
"Skipping '%s' (%s): empty file.",
|
|
267
|
+
info.key,
|
|
268
|
+
info.id,
|
|
269
|
+
)
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if skill.name in skill_registry:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"Duplicate skill name '%s' — keeping the first occurrence.",
|
|
275
|
+
skill.name,
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
skill_registry[skill.name] = skill
|
|
280
|
+
|
|
281
|
+
logger.info(
|
|
282
|
+
"Loaded %d skill(s) from knowledge base (scope_ids=%s).",
|
|
283
|
+
len(skill_registry),
|
|
284
|
+
scope_ids,
|
|
285
|
+
)
|
|
286
|
+
return skill_registry
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def configure_skill_tool(
|
|
290
|
+
*,
|
|
291
|
+
config: UniqueAIConfig,
|
|
292
|
+
event: ChatEvent,
|
|
293
|
+
logger: Logger,
|
|
294
|
+
content_service: ContentService,
|
|
295
|
+
tool_manager: ToolManager | ResponsesApiToolManager,
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Register the SkillTool if enabled in the config.
|
|
298
|
+
|
|
299
|
+
Lists all ``.md`` files in the configured ``scope_ids`` (and every
|
|
300
|
+
sub-folder reachable from them), downloads each one concurrently,
|
|
301
|
+
and registers the SkillTool with the parsed skills.
|
|
302
|
+
"""
|
|
303
|
+
skill_config = config.agent.experimental.skill_tool_config
|
|
304
|
+
if not skill_config.enabled:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
if not skill_config.scope_ids:
|
|
308
|
+
logger.warning(
|
|
309
|
+
"SkillTool is enabled but no scope_ids are configured — "
|
|
310
|
+
"no skills will be loaded."
|
|
311
|
+
)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
knowledge_base_service = KnowledgeBaseService(
|
|
315
|
+
company_id=event.company_id,
|
|
316
|
+
user_id=event.user_id,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
skill_registry = await load_skills_from_knowledge_base(
|
|
320
|
+
content_service=content_service,
|
|
321
|
+
knowledge_base_service=knowledge_base_service,
|
|
322
|
+
scope_ids=skill_config.scope_ids,
|
|
323
|
+
logger=logger,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if not skill_registry:
|
|
327
|
+
logger.info(
|
|
328
|
+
"SkillTool has an empty skill registry — tool will not be registered."
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
tool_manager.add_tool(
|
|
333
|
+
SkillTool(
|
|
334
|
+
event=event,
|
|
335
|
+
skill_registry=skill_registry,
|
|
336
|
+
config=skill_config,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def preload_invoked_skills(
|
|
342
|
+
*,
|
|
343
|
+
event: ChatEvent,
|
|
344
|
+
tool_manager: ToolManager | ResponsesApiToolManager,
|
|
345
|
+
history_manager: HistoryManager,
|
|
346
|
+
logger: Logger,
|
|
347
|
+
) -> str | None:
|
|
348
|
+
"""Preload skills invoked as ``/skill-name`` prefix(es) in the user message.
|
|
349
|
+
|
|
350
|
+
Mirrors the normal mid-loop activation path so preloaded skills are
|
|
351
|
+
indistinguishable from skills the model activates itself:
|
|
352
|
+
|
|
353
|
+
1. Parses consecutive ``/skill-name`` tokens from the start of the
|
|
354
|
+
user message.
|
|
355
|
+
2. For each matched skill, synthesizes a ``LanguageModelFunction``
|
|
356
|
+
and runs it through the already-registered ``SkillTool`` — the
|
|
357
|
+
exact same code path ``UniqueAI._handle_tool_calls`` uses.
|
|
358
|
+
3. Appends the synthetic assistant tool-call message plus the
|
|
359
|
+
resulting tool-result messages to history, so the first model
|
|
360
|
+
turn sees the skills as already-activated tool calls.
|
|
361
|
+
4. Strips the matched ``/skill-name`` tokens from the user message
|
|
362
|
+
so the rendered turn shows only the user's actual query.
|
|
363
|
+
|
|
364
|
+
No-ops when the SkillTool is not registered or no tokens match.
|
|
365
|
+
"""
|
|
366
|
+
skill_tool = tool_manager.get_tool_by_name(SkillTool.name)
|
|
367
|
+
if not isinstance(skill_tool, SkillTool):
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
original_text = event.payload.user_message.text or ""
|
|
371
|
+
|
|
372
|
+
skills, stripped_text = extract_prefix_skills(
|
|
373
|
+
original_text, skill_tool.skill_registry
|
|
374
|
+
)
|
|
375
|
+
if not skills:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
tool_calls: list[LanguageModelFunction] = []
|
|
379
|
+
responses: list[ToolCallResponse] = []
|
|
380
|
+
for skill in skills:
|
|
381
|
+
tool_call = LanguageModelFunction(
|
|
382
|
+
name=SkillTool.name,
|
|
383
|
+
arguments={"skill_name": skill.name},
|
|
384
|
+
)
|
|
385
|
+
response = await skill_tool.run(tool_call)
|
|
386
|
+
tool_calls.append(tool_call)
|
|
387
|
+
responses.append(response)
|
|
388
|
+
history_manager.add_tool_call(tool_call)
|
|
389
|
+
|
|
390
|
+
history_manager._append_tool_calls_to_history(tool_calls)
|
|
391
|
+
history_manager.add_tool_call_results(responses)
|
|
392
|
+
|
|
393
|
+
logger.info(
|
|
394
|
+
"Preloaded %d skill(s) from slash invocation: %s",
|
|
395
|
+
len(skills),
|
|
396
|
+
[s.name for s in skills],
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return stripped_text
|
|
@@ -9,6 +9,9 @@ from unique_deep_research.service import DeepResearchTool
|
|
|
9
9
|
from unique_follow_up_questions.config import FollowUpQuestionsConfig
|
|
10
10
|
from unique_internal_search.config import InternalSearchConfig
|
|
11
11
|
from unique_internal_search.service import InternalSearchTool
|
|
12
|
+
from unique_skill_tool.config import (
|
|
13
|
+
SkillToolConfig,
|
|
14
|
+
)
|
|
12
15
|
from unique_stock_ticker.config import StockTickerConfig
|
|
13
16
|
from unique_swot import SwotAnalysisTool, SwotAnalysisToolConfig
|
|
14
17
|
from unique_toolkit._common.validators import (
|
|
@@ -348,6 +351,8 @@ class ExperimentalConfig(BaseToolConfig):
|
|
|
348
351
|
|
|
349
352
|
open_file_tool_config: OpenFileToolConfig = OpenFileToolConfig()
|
|
350
353
|
|
|
354
|
+
skill_tool_config: SkillToolConfig = SkillToolConfig()
|
|
355
|
+
|
|
351
356
|
use_responses_api: bool = Field(
|
|
352
357
|
default=False,
|
|
353
358
|
description="If set, the main agent will use the Responses API from OpenAI",
|
|
@@ -345,10 +345,6 @@ class TestConfigureUploadedSearchToolIngestionFilter:
|
|
|
345
345
|
assert UploadedSearchTool.name not in tool_names
|
|
346
346
|
|
|
347
347
|
def test_skip_excel_ingestion_mode_is_excluded(self):
|
|
348
|
-
# SKIP_EXCEL_INGESTION only excludes the doc when the mime type is
|
|
349
|
-
# actually Excel/CSV (see unique_toolkit.Content.is_ingested after
|
|
350
|
-
# #1454). A SKIP_EXCEL_INGESTION doc with a non-Excel or unknown
|
|
351
|
-
# mime type is still considered ingested.
|
|
352
348
|
doc = self._make_doc(
|
|
353
349
|
{"uniqueIngestionMode": "SKIP_EXCEL_INGESTION"},
|
|
354
350
|
mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
@@ -381,7 +377,7 @@ class TestConfigureUploadedSearchToolIngestionFilter:
|
|
|
381
377
|
self._make_doc({"uniqueIngestionMode": "SKIP_INGESTION"}),
|
|
382
378
|
self._make_doc(
|
|
383
379
|
{"uniqueIngestionMode": "SKIP_EXCEL_INGESTION"},
|
|
384
|
-
mime_type="
|
|
380
|
+
mime_type="text/csv",
|
|
385
381
|
),
|
|
386
382
|
]
|
|
387
383
|
common = self._run(docs)
|
|
@@ -50,6 +50,10 @@ from unique_toolkit.protocols.support import (
|
|
|
50
50
|
SupportCompleteWithReferences,
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
+
from unique_orchestrator._builders.inject_tool_reminders import (
|
|
54
|
+
inject_tool_reminders_into_user_message,
|
|
55
|
+
)
|
|
56
|
+
from unique_orchestrator._builders.skill_setup import preload_invoked_skills
|
|
53
57
|
from unique_orchestrator.config import UniqueAIConfig
|
|
54
58
|
from unique_orchestrator.utils import filter_uploaded_documents_by_selection
|
|
55
59
|
|
|
@@ -210,6 +214,16 @@ class UniqueAI:
|
|
|
210
214
|
self._execution_times = []
|
|
211
215
|
run_start = time.perf_counter()
|
|
212
216
|
|
|
217
|
+
if self._config.agent.experimental.skill_tool_config.enabled:
|
|
218
|
+
stripped_text = await preload_invoked_skills(
|
|
219
|
+
event=self._event,
|
|
220
|
+
tool_manager=self._tool_manager,
|
|
221
|
+
history_manager=self._history_manager,
|
|
222
|
+
logger=self._logger,
|
|
223
|
+
)
|
|
224
|
+
if stripped_text is not None:
|
|
225
|
+
self._event.payload.user_message.text = stripped_text
|
|
226
|
+
|
|
213
227
|
sub = self._chat_service.cancellation.on_cancellation.subscribe(
|
|
214
228
|
self._on_cancellation
|
|
215
229
|
)
|
|
@@ -405,6 +419,12 @@ class UniqueAI:
|
|
|
405
419
|
messages
|
|
406
420
|
)
|
|
407
421
|
)
|
|
422
|
+
|
|
423
|
+
tool_reminders: list[str] = []
|
|
424
|
+
for prompts in self._tool_manager.get_tool_prompts():
|
|
425
|
+
if prompts.tool_system_reminder_for_user_prompt:
|
|
426
|
+
tool_reminders.append(prompts.tool_system_reminder_for_user_prompt)
|
|
427
|
+
messages = inject_tool_reminders_into_user_message(messages, tool_reminders)
|
|
408
428
|
return messages
|
|
409
429
|
|
|
410
430
|
async def _render_user_prompt(self) -> str:
|
|
@@ -635,8 +655,11 @@ class UniqueAI:
|
|
|
635
655
|
for tool in self._tool_manager.available_tools
|
|
636
656
|
}
|
|
637
657
|
|
|
638
|
-
# Tool names that should not be logged in the
|
|
639
|
-
|
|
658
|
+
# Tool names that should not be logged in the "Triggered Tool Calls"
|
|
659
|
+
# step. The Skill tool emits its own message log entry per invocation
|
|
660
|
+
# (see ``unique_skill_tool.SkillTool._log_skill_loaded``), so it is
|
|
661
|
+
# redundant and noisy to also list it here.
|
|
662
|
+
tool_names_not_to_log = ["DeepResearch", "Skill"]
|
|
640
663
|
|
|
641
664
|
used_tools: dict[str, int] = {}
|
|
642
665
|
for tool_call in tool_calls:
|
|
@@ -83,6 +83,7 @@ from unique_orchestrator._builders.open_file_setup import (
|
|
|
83
83
|
configure_file_payload,
|
|
84
84
|
handle_uploaded_file_tool_choices,
|
|
85
85
|
)
|
|
86
|
+
from unique_orchestrator._builders.skill_setup import configure_skill_tool
|
|
86
87
|
from unique_orchestrator.config import UniqueAIConfig
|
|
87
88
|
from unique_orchestrator.unique_ai import UniqueAI
|
|
88
89
|
from unique_orchestrator.utils import filter_uploaded_documents_by_selection
|
|
@@ -140,7 +141,7 @@ async def build_unique_ai(
|
|
|
140
141
|
common_components=common_components,
|
|
141
142
|
)
|
|
142
143
|
else:
|
|
143
|
-
return _build_completions(
|
|
144
|
+
return await _build_completions(
|
|
144
145
|
event=event,
|
|
145
146
|
logger=logger,
|
|
146
147
|
config=config,
|
|
@@ -432,6 +433,14 @@ async def _build_responses(
|
|
|
432
433
|
history_manager=history_manager,
|
|
433
434
|
)
|
|
434
435
|
|
|
436
|
+
await configure_skill_tool(
|
|
437
|
+
config=config,
|
|
438
|
+
event=event,
|
|
439
|
+
logger=logger,
|
|
440
|
+
content_service=common_components.content_service,
|
|
441
|
+
tool_manager=tool_manager,
|
|
442
|
+
)
|
|
443
|
+
|
|
435
444
|
loop_iteration_runner = build_responses_loop_iteration_runner(
|
|
436
445
|
config=config,
|
|
437
446
|
history_manager=common_components.history_manager,
|
|
@@ -478,7 +487,7 @@ async def _build_responses(
|
|
|
478
487
|
)
|
|
479
488
|
|
|
480
489
|
|
|
481
|
-
def _build_completions(
|
|
490
|
+
async def _build_completions(
|
|
482
491
|
event: ChatEvent,
|
|
483
492
|
logger: Logger,
|
|
484
493
|
config: UniqueAIConfig,
|
|
@@ -502,6 +511,14 @@ def _build_completions(
|
|
|
502
511
|
if not has_tool_choices and has_valid_uploaded_documents:
|
|
503
512
|
tool_manager.add_forced_tool(UploadedSearchTool.name)
|
|
504
513
|
|
|
514
|
+
await configure_skill_tool(
|
|
515
|
+
config=config,
|
|
516
|
+
event=event,
|
|
517
|
+
logger=logger,
|
|
518
|
+
content_service=common_components.content_service,
|
|
519
|
+
tool_manager=tool_manager,
|
|
520
|
+
)
|
|
521
|
+
|
|
505
522
|
postprocessor_manager = common_components.postprocessor_manager
|
|
506
523
|
|
|
507
524
|
_add_sub_agents_postprocessor(
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: unique-orchestrator
|
|
3
|
-
Version: 2026.18.0.dev3
|
|
4
|
-
Summary:
|
|
5
|
-
Author: Andreas Hauri
|
|
6
|
-
Author-email: Andreas Hauri <andreas.hauri@unique.ai>
|
|
7
|
-
License: Proprietary
|
|
8
|
-
Requires-Dist: pydantic>=2.8.2,<3
|
|
9
|
-
Requires-Dist: typing-extensions>=4.9.0,<5
|
|
10
|
-
Requires-Dist: jinja2>=3.1.0,<4
|
|
11
|
-
Requires-Dist: unique-toolkit>=2026.18.0.dev4
|
|
12
|
-
Requires-Dist: unique-stock-ticker>=2026.18.0.dev2
|
|
13
|
-
Requires-Dist: unique-follow-up-questions>=2026.18.0.dev2
|
|
14
|
-
Requires-Dist: unique-internal-search>=2026.18.0.dev2
|
|
15
|
-
Requires-Dist: unique-deep-research>=2026.18.0.dev2
|
|
16
|
-
Requires-Dist: unique-web-search>=2026.18.0.dev2
|
|
17
|
-
Requires-Dist: unique-swot>=2026.18.0.dev2
|
|
18
|
-
Requires-Dist: openai>=1.109.1,<3
|
|
19
|
-
Requires-Python: >=3.12, <4
|
|
20
|
-
Description-Content-Type: text/markdown
|
|
21
|
-
|
|
22
|
-
# Unique Orchestrator
|
|
23
|
-
|
|
24
|
-
Unique AI orchestrator agent to struct and direct subagents, tools and services
|
|
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
|