Fix display of tool results

This commit is contained in:
dimitrievgs 2026-06-14 00:16:34 +03:00
parent 305f8813a8
commit 82891822a5
5 changed files with 216 additions and 16 deletions

View File

@ -10,6 +10,7 @@ from flask_cors import CORS
from flask_socketio import SocketIO
from workflows import graph_history_manager, run_agent_streaming
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
import base64
api = Flask(__name__)
CORS(
@ -260,6 +261,11 @@ def chat_stream():
# Запускаем стриминг ответа от LLM (используем обновленный агент)
accumulated_content = ""
# Вспомогательная функция для безопасной передачи JSON в HTML-атрибутах
def safe_b64(text):
if not text: return "e30=" # пустой json '{}'
return base64.b64encode(str(text).encode('utf-8')).decode('utf-8')
try:
for chunk_data in run_agent_streaming(graph_id, user_node_id,
assistant_node_id,
@ -271,6 +277,27 @@ def chat_stream():
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
if chunk_data.get("type") == "chunk":
accumulated_content += chunk_data.get("content", "")
elif chunk_data.get("type") == "tool_start":
# Встраиваем стартовый маркер в память базы данных
start_marker = f'\n\n<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>\n\n'
accumulated_content += start_marker
elif chunk_data.get("type") == "tool_end":
# Находим стартовый маркер и меняем его на финальный с данными
search_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>'
req_b64 = safe_b64(chunk_data.get("request"))
res_b64 = safe_b64(chunk_data.get("response"))
status = "error" if chunk_data.get("is_error") else "success"
end_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="{status}" data-req="{req_b64}" data-res="{res_b64}"></div>'
if search_marker in accumulated_content:
accumulated_content = accumulated_content.replace(search_marker, end_marker)
else:
accumulated_content += f'\n\n{end_marker}\n\n'
yield f"data: {json.dumps(chunk_data)}\n\n"
# Обработка ошибки во время стриминга (крашим узел на фронте)

125
app/mcp_tools.py Normal file
View File

@ -0,0 +1,125 @@
from pydantic import BaseModel, Field, create_model
from langchain_core.tools import StructuredTool
# ----------------- MCP MANAGER -----------------
try:
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
_MCP_CACHED_TOOLS = {}
def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]:
"""Преобразует JSON Schema от MCP сервера в Pydantic модель для LangChain"""
fields = {}
properties = schema.get("properties", {})
required = schema.get("required", [])
for key, val in properties.items():
t = val.get("type", "string")
py_type = str
if t == "integer": py_type = int
elif t == "number": py_type = float
elif t == "boolean": py_type = bool
elif t == "array": py_type = list
elif t == "object": py_type = dict
desc = val.get("description", "")
if key in required:
fields[key] = (py_type, Field(..., description=desc))
else:
fields[key] = (py_type, Field(default=None, description=desc))
if not fields:
fields["kwargs"] = (dict, Field(default_factory=dict, description="Аргументы"))
return create_model(model_name, **fields)
def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None):
"""Обертка, которая поднимает контейнер/процесс ровно на 1 вызов тула и убивает его"""
args_schema = json_schema_to_pydantic(json_schema, f"MCP_{tool_name.replace('-','_')}_Schema")
command = server_config.get("command")
def mcp_tool_runner(**kwargs):
import asyncio
import json
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
async def _run():
server_params = StdioServerParameters(command=command, args=args, env=full_env)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool_name, arguments=kwargs)
text_outputs = []
for c in result.content:
if hasattr(c, 'text'):
text_outputs.append(c.text)
else:
text_outputs.append(str(c))
return "\n".join(text_outputs)
raw_output = asyncio.run(_run())
if debug_callback:
# debug_callback(f"MCP: {tool_name}\n" + input_json, raw_output)
debug_callback(kwargs, raw_output)
return f"Инструмент '{tool_name}' вернул следующий результат:\n<tool_output>\n{raw_output}\n</tool_output>"
return StructuredTool.from_function(
func=mcp_tool_runner,
name=tool_name.replace('-','_'), # Langchain не любит тире в именах
description=tool_desc or f"MCP Tool {tool_name}",
args_schema=args_schema
)
def fetch_mcp_tools(server_config, debug_callback=None):
"""Один раз читает список тулов от сервера и кеширует их схемы"""
if not MCP_AVAILABLE:
print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp")
return []
import asyncio
import shlex
import os
config_hash = str(server_config) # primitive hash
if config_hash in _MCP_CACHED_TOOLS:
return _MCP_CACHED_TOOLS[config_hash]
async def _fetch():
command = server_config.get("command", "")
args_str = server_config.get("args", "")
args = shlex.split(args_str) if args_str else []
env_dict = {}
env_str = server_config.get("envString", "")
if env_str:
for pair in env_str.split(','):
if '=' in pair:
k, v = pair.split('=', 1)
env_dict[k.strip()] = v.strip()
full_env = {**os.environ.copy(), **env_dict}
server_params = StdioServerParameters(command=command, args=args, env=full_env)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools_resp = await session.list_tools()
return tools_resp.tools, full_env, args
try:
raw_tools, full_env, args = asyncio.run(_fetch())
wrapped_tools = []
for t in raw_tools:
wrapped_tools.append(create_mcp_tool(server_config, t.name, t.description, t.inputSchema, full_env, args, debug_callback))
_MCP_CACHED_TOOLS[config_hash] = wrapped_tools
print(f"🔌 Успешно загружено {len(wrapped_tools)} инструментов от MCP-сервера '{server_config.get('name')}'")
return wrapped_tools
except Exception as e:
print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}")
return []

View File

@ -16,9 +16,10 @@ import json
import os
import sys
from typing import Any, get_type_hints
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field, create_model
from mcp_tools import fetch_mcp_tools
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict, debug_callback=None):
"""Инициализация ядра на базе LangGraph."""
m_cfg = MODELS.get(model_name)
@ -166,6 +167,8 @@ finally:
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
"""Собирает инструменты двух видов на основе настроек Obsidian."""
tools = []
# Инициализация внутренних инструментов (скриптов)
custom_tools = obsidian_settings.get("customTools", [])
for ct in custom_tools:
@ -247,6 +250,12 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
)
tools.append(help_tool)
# Инициализация внутренних MCP серверов
mcp_servers = obsidian_settings.get("mcpServers", [])
for mcp_server in mcp_servers:
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback)
tools.extend(mcp_tools)
return tools
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:

26
app/test_mcp.py Normal file
View File

@ -0,0 +1,26 @@
# test_mcp.py
import asyncio
import sys
print(f"Python: {sys.version}")
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
async def test():
params = StdioServerParameters(
command="podman",
args=["run", "-i", "--rm", "ddg-mcp-server"],
env=None
)
print("opening stdio_client...")
async with stdio_client(params) as (read, write):
print("creating session...")
async with ClientSession(read, write) as session:
print("initializing...")
await session.initialize()
print("OK!")
tools = await session.list_tools()
for t in tools.tools:
print(f" - {t.name}")
asyncio.run(test())

View File

@ -105,6 +105,7 @@ def run_agent_streaming(graph_id: str,
# 4. Выполняем цикл с потоковой передачей событий
step_counter = 0
tool_mapping = {} # Хранит связку: ID вызова -> Номер шага
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
@ -115,6 +116,10 @@ def run_agent_streaming(graph_id: str,
for tc in chunk.tool_call_chunks:
if "name" in tc and tc["name"]:
step_counter += 1
tc_id = tc.get("id", f"unknown_{step_counter}")
# Сохраняем шаг для этого конкретного вызова
tool_mapping[tc_id] = step_counter
yield {
"type": "tool_start",
"name": tc["name"],
@ -132,23 +137,31 @@ def run_agent_streaming(graph_id: str,
# Тул отработал и принес ответ
elif hasattr(chunk, "type") and chunk.type == "tool":
req = ""
res = str(chunk.content)
# Достаем JSON отправленных параметров из последнего debug_callback
if current_run_debug:
dbg = current_run_debug.pop(0) # Берем первый из очереди
req = dbg.get("req", "")
# Ловушка для определения ошибок
is_error = False
if getattr(chunk, "status", "") == "error" or "ОШИБКА ИСПОЛНЕНИЯ" in res or "SCRIPT RUNTIME ERROR" in res:
is_error = True
# Достаем ИМЕННО ТОТ ШАГ, на котором начинался вызов этого инструмента
tc_id = getattr(chunk, "tool_call_id", "")
actual_step = tool_mapping.get(tc_id, step_counter) # Fallback, если id нет
yield {
"type": "tool_end",
"name": chunk.name,
"step": step_counter,
"node_id": assistant_node_id
}
# В САМОМ КОНЦЕ, после того как агент закончил работу (вышел из цикла)
if current_run_debug and obsidian_settings.get('showDebugInfo'):
debug_section = "\n\n---\n" + "\n\n---\n".join([
f"**JSON Request:**\n```json\n{d['req']}\n```\n**Result:**\n```text\n{d['res']}\n```"
for d in current_run_debug
])
yield {
"type": "chunk",
"content": debug_section,
"node_id": assistant_node_id
"name": getattr(chunk, "name", "tool"),
"step": actual_step,
"node_id": assistant_node_id,
"request": req,
"response": res,
"is_error": is_error
}
except Exception as e: