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