Fix display of tool results
This commit is contained in:
parent
305f8813a8
commit
82891822a5
27
app/api.py
27
app/api.py
|
|
@ -10,6 +10,7 @@ from flask_cors import CORS
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
from workflows import graph_history_manager, run_agent_streaming
|
from workflows import graph_history_manager, run_agent_streaming
|
||||||
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
||||||
|
import base64
|
||||||
|
|
||||||
api = Flask(__name__)
|
api = Flask(__name__)
|
||||||
CORS(
|
CORS(
|
||||||
|
|
@ -260,6 +261,11 @@ def chat_stream():
|
||||||
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
||||||
accumulated_content = ""
|
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:
|
try:
|
||||||
for chunk_data in run_agent_streaming(graph_id, user_node_id,
|
for chunk_data in run_agent_streaming(graph_id, user_node_id,
|
||||||
assistant_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") in ["chunk", "tool_start", "tool_end"]:
|
||||||
if chunk_data.get("type") == "chunk":
|
if chunk_data.get("type") == "chunk":
|
||||||
accumulated_content += chunk_data.get("content", "")
|
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"
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
||||||
|
|
||||||
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
||||||
|
|
|
||||||
125
app/mcp_tools.py
Normal file
125
app/mcp_tools.py
Normal 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 []
|
||||||
|
|
@ -16,9 +16,10 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, get_type_hints
|
from typing import Any, get_type_hints
|
||||||
from langchain_core.tools import StructuredTool
|
|
||||||
from pydantic import BaseModel, Field, create_model
|
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):
|
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict, debug_callback=None):
|
||||||
"""Инициализация ядра на базе LangGraph."""
|
"""Инициализация ядра на базе LangGraph."""
|
||||||
m_cfg = MODELS.get(model_name)
|
m_cfg = MODELS.get(model_name)
|
||||||
|
|
@ -166,6 +167,8 @@ finally:
|
||||||
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
||||||
"""Собирает инструменты двух видов на основе настроек Obsidian."""
|
"""Собирает инструменты двух видов на основе настроек Obsidian."""
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
|
# Инициализация внутренних инструментов (скриптов)
|
||||||
custom_tools = obsidian_settings.get("customTools", [])
|
custom_tools = obsidian_settings.get("customTools", [])
|
||||||
|
|
||||||
for ct in custom_tools:
|
for ct in custom_tools:
|
||||||
|
|
@ -247,6 +250,12 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
||||||
)
|
)
|
||||||
tools.append(help_tool)
|
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
|
return tools
|
||||||
|
|
||||||
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
|
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
|
||||||
|
|
|
||||||
26
app/test_mcp.py
Normal file
26
app/test_mcp.py
Normal 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())
|
||||||
|
|
@ -105,6 +105,7 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
# 4. Выполняем цикл с потоковой передачей событий
|
# 4. Выполняем цикл с потоковой передачей событий
|
||||||
step_counter = 0
|
step_counter = 0
|
||||||
|
tool_mapping = {} # Хранит связку: ID вызова -> Номер шага
|
||||||
|
|
||||||
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
||||||
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, 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:
|
for tc in chunk.tool_call_chunks:
|
||||||
if "name" in tc and tc["name"]:
|
if "name" in tc and tc["name"]:
|
||||||
step_counter += 1
|
step_counter += 1
|
||||||
|
tc_id = tc.get("id", f"unknown_{step_counter}")
|
||||||
|
# Сохраняем шаг для этого конкретного вызова
|
||||||
|
tool_mapping[tc_id] = step_counter
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_start",
|
"type": "tool_start",
|
||||||
"name": tc["name"],
|
"name": tc["name"],
|
||||||
|
|
@ -132,23 +137,31 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
# Тул отработал и принес ответ
|
# Тул отработал и принес ответ
|
||||||
elif hasattr(chunk, "type") and chunk.type == "tool":
|
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 {
|
yield {
|
||||||
"type": "tool_end",
|
"type": "tool_end",
|
||||||
"name": chunk.name,
|
"name": getattr(chunk, "name", "tool"),
|
||||||
"step": step_counter,
|
"step": actual_step,
|
||||||
"node_id": assistant_node_id
|
"node_id": assistant_node_id,
|
||||||
}
|
"request": req,
|
||||||
|
"response": res,
|
||||||
# В САМОМ КОНЦЕ, после того как агент закончил работу (вышел из цикла)
|
"is_error": is_error
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user