diff --git a/app/llm_client.py b/app/llm_client.py
index 6b49a99..d08b3f4 100644
--- a/app/llm_client.py
+++ b/app/llm_client.py
@@ -181,6 +181,19 @@ MODELS: Dict[str, Dict[str, Any]] = {
"stream": True,
"capabilities": ["vision"],
},
+ "claude-sonnet-4.6-openrouter": {
+ "name": "anthropic/claude-sonnet-4.6",
+ "provider": "openai",
+ "model_name": "anthropic/claude-sonnet-4.6",
+ "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
+ "apiKey":
+ "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",
+ "stream": True,
+ "capabilities": ["vision", "reasoning"],
+ "model_kwargs": {
+ "include_reasoning": True
+ }
+ },
}
diff --git a/app/react_agent.py b/app/react_agent.py
index 7267f32..e48f97e 100644
--- a/app/react_agent.py
+++ b/app/react_agent.py
@@ -15,9 +15,9 @@ import subprocess
import json
import os
import sys
-from typing import Any
+from typing import Any, get_type_hints
from langchain_core.tools import StructuredTool
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, create_model
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
"""Инициализация ядра на базе LangGraph."""
@@ -41,16 +41,29 @@ class PurePromptSchema(BaseModel):
def create_subprocess_tool(script_path: str, method_name: str,
hidden_prompt: str):
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
- spec = importlib.util.spec_from_file_location("user_dynamic_tool",
- script_path)
- module = importlib.util.module_from_spec(spec)
+
+ script_dir = os.path.dirname(os.path.abspath(script_path))
+ added_to_path = False
+ if script_dir not in sys.path:
+ sys.path.insert(0, script_dir)
+ added_to_path = True
try:
+ spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
+ module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
func = getattr(module, method_name)
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
+
+ # ← КЛЮЧЕВОЕ: строим схему ДО того, как потеряем оригинальную сигнатуру
+ args_schema = _build_pydantic_schema_from_func(func)
+
except Exception as e:
raise RuntimeError(f"Ошибка чтения сигнатуры {script_path}: {e}")
+ finally:
+ # Убираем путь сразу после импорта и получения функции
+ if added_to_path:
+ sys.path.remove(script_dir)
@functools.wraps(func)
def wrapper(**kwargs: Any) -> str:
@@ -58,6 +71,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
import json
import sys
import importlib.util
+import os
+
+# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
+path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
+original_path = sys.path.copy()
+sys.path.insert(0, path_to_add)
try:
spec = importlib.util.spec_from_file_location('sub_module', r'{script_path}')
@@ -70,20 +89,57 @@ try:
except Exception as e:
print(f"SCRIPT RUNTIME ERROR: {{e}}", file=sys.stderr)
sys.exit(1)
+finally:
+ # Возвращаем sys.path в исходное состояние (чистота внутри процесса)
+ sys.path = original_path
"""
try:
- process = subprocess.run([sys.executable, "-c", runner_code],
- input=json.dumps(kwargs),
- capture_output=True,
- text=True,
- encoding='utf-8',
- check=True)
+ process = subprocess.run(
+ [sys.executable, "-c", runner_code],
+ input=json.dumps(kwargs),
+ capture_output=True,
+ text=True,
+ encoding='utf-8',
+ check=True
+ )
raw_output = process.stdout.strip()
- # Прилизывание для скрипта
- if hidden_prompt:
- return f"РЕЗУЛЬТАТ ВЫПОЛНЕНИЯ СКРИПТА:\n{raw_output}\n\nИНСТРУКЦИЯ ПО ФОРМАТИРОВАНИЮ ОТВЕТА ДЛЯ АГЕНТА:\n{hidden_prompt}"
- return raw_output
+ raw_stripped = raw_output.strip() if raw_output else ""
+ prompt_stripped = hidden_prompt.strip() if hidden_prompt else ""
+
+ if raw_stripped and prompt_stripped:
+ # Случай 1: Есть и результат скрипта, и инструкция — полный вариант
+ result = (
+ f"Инструмент вернул следующий результат:\n"
+ f"\n{raw_output}\n\n\n"
+ f"---\n"
+ f"Инструкция по оформлению финального ответа пользователю:\n"
+ f"\n{hidden_prompt}\n"
+ )
+
+ elif raw_stripped and not prompt_stripped:
+ # Случай 2: Есть результат, нет инструкции
+ result = (
+ f"Инструмент успешно выполнен. Передай пользователю следующий результат "
+ f"ДОСЛОВНО, без изменений, сокращений и дополнений:\n\n"
+ f"\n{raw_output}\n"
+ )
+
+ elif not raw_stripped and prompt_stripped:
+ # Случай 3: Скрипт вернул пустоту (заглушка/нет данных) — только промпт как инструкция
+ result = prompt_stripped
+ """ result = (
+ f"Скрипт не вернул данных. Выполни следующую инструкцию:\n\n{hidden_prompt}"
+ )"""
+
+ else:
+ # Случай 4: Оба пустые — сообщаем агенту честно
+ result = (
+ "Инструмент не вернул никаких данных и не содержит инструкций. "
+ "Сообщи пользователю, что инструмент настроен некорректно."
+ )
+
+ return result
except subprocess.CalledProcessError as e:
# Даже если скрипт упал, мы отдаем ошибку + промпт, чтобы агент мог обработать её по инструкции
@@ -92,10 +148,13 @@ except Exception as e:
return f"{error_msg}\n\nИНСТРУКЦИЯ НА СЛУЧАЙ ОШИБКИ:\n{hidden_prompt}"
return error_msg
- return StructuredTool.from_function(func=wrapper,
- name=func.__name__,
- description=docstring,
- infer_schema=True)
+ # Явно передаём args_schema — LLM будет видеть реальную типизацию
+ return StructuredTool.from_function(
+ func=wrapper,
+ name=func.__name__,
+ description=docstring,
+ args_schema=args_schema, # ← вместо infer_schema=True
+ )
def get_dynamic_tools(obsidian_settings: dict):
"""Собирает инструменты двух видов на основе настроек Obsidian."""
@@ -170,3 +229,49 @@ def get_dynamic_tools(obsidian_settings: dict):
print(f"❌ Ошибка генерации инструмента-скрипта '{name}': {e}")
return tools
+
+def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
+ """
+ Динамически строит Pydantic BaseModel из сигнатуры функции.
+ Используется для корректной передачи схемы в StructuredTool,
+ чтобы LLM видела реальные типы аргументов, а не **kwargs.
+ """
+ sig = inspect.signature(func)
+
+ # Пробуем получить аннотации типов (включая from __future__ import annotations)
+ try:
+ hints = get_type_hints(func)
+ except Exception:
+ hints = {}
+
+ fields = {}
+ for param_name, param in sig.parameters.items():
+ if param_name in ("self", "cls"):
+ continue
+ if param.kind in (
+ inspect.Parameter.VAR_POSITIONAL, # *args
+ inspect.Parameter.VAR_KEYWORD, # **kwargs
+ ):
+ continue
+
+ # Тип параметра
+ annotation = hints.get(param_name, Any)
+
+ # Дефолтное значение
+ if param.default is inspect.Parameter.empty:
+ # Обязательный параметр
+ fields[param_name] = (annotation, Field(...))
+ else:
+ fields[param_name] = (annotation, Field(default=param.default))
+
+ if not fields:
+ # Фоллбек: если функция не имеет параметров или все **kwargs —
+ # создаём схему с одним полем, чтобы LLM хоть что-то передала
+ fields["kwargs"] = (dict, Field(default_factory=dict,
+ description="Аргументы функции"))
+
+ model = create_model(
+ f"{func.__name__}_Schema",
+ **fields
+ )
+ return model