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 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
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 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
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. Выполняем цикл с потоковой передачей событий
|
||||
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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user