Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/application/tests/test_ai_chat_tool_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,56 @@ async def next_model_input(state):
self.assertEqual(cards[-1].status, Status.SUCCESS)
self.assertEqual(cards[-1].name, "display-tool")
self.assertNotIn("image-payload", cards[-1].content)


class AIChatAgentBackendTests(SimpleTestCase):
def _create_deep_agent_kwargs(self, skill_tool_ids):
async def stream(*args, **kwargs):
return
yield

with (
TemporaryDirectory(prefix="maxkb-backend-test-") as directory,
patch.object(agent_module, "init_skills", new=AsyncMock()),
patch.object(
agent_module, "MultiServerMCPClient", return_value=SimpleNamespace(get_tools=AsyncMock(return_value=[]))
),
patch.object(agent_module, "SandboxShellBackend", return_value="shell-backend"),
patch.object(
agent_module, "create_deep_agent", return_value=SimpleNamespace(astream=stream)
) as create_deep_agent,
):
agent_module.create_agent(
MagicMock(),
"system",
[],
"{}",
SimpleNamespace(on_next=lambda chunk: None, on_complete=lambda error: None),
chat_id=directory,
skill_tool_ids=skill_tool_ids,
async_runner=SimpleNamespace(run_async=lambda fn: asyncio.run(fn())),
)
return create_deep_agent.call_args.kwargs

def test_mcp_or_tools_only_do_not_expose_server_shell(self):
for skill_tool_ids in (None, []):
with self.subTest(skill_tool_ids=skill_tool_ids):
kwargs = self._create_deep_agent_kwargs(skill_tool_ids)
self.assertIsNone(kwargs["backend"])
self.assertIsNone(kwargs["skills"])
self.assertIsInstance(kwargs["middleware"][0], agent_module.HideFilesystemToolsMiddleware)

def test_skills_keep_shell_backend(self):
kwargs = self._create_deep_agent_kwargs(["skill-id"])

self.assertEqual(kwargs["backend"], "shell-backend")
self.assertEqual(kwargs["skills"], ["/skills"])
self.assertEqual(kwargs["middleware"], ())

def test_hide_filesystem_tools_keeps_configured_tools(self):
request = MagicMock(tools=[SimpleNamespace(name="ls"), {"name": "execute"}, SimpleNamespace(name="agent_x")])

agent_module.HideFilesystemToolsMiddleware().wrap_model_call(request, lambda req: req)

request.override.assert_called_once()
self.assertEqual([getattr(t, "name", None) for t in request.override.call_args.kwargs["tools"]], ["agent_x"])
35 changes: 33 additions & 2 deletions apps/application/workflow/nodes/ai_chat_node/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import langchain_core.messages.ai as _lc_ai_module
import uuid_utils.compat as uuid
from deepagents import create_deep_agent
from langchain.agents.middleware import AgentMiddleware
from langchain_core.utils._merge import merge_lists as _original_merge_lists
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.checkpoint.memory import MemorySaver
Expand Down Expand Up @@ -139,6 +140,33 @@ def set_tool_id_name(self, tool_id, name):
self.id_name_map[tool_id] = name


# deepagents 内置的文件与 Shell 工具
_FILESYSTEM_TOOL_NAMES = frozenset({"ls", "read_file", "write_file", "edit_file", "delete", "glob", "grep", "execute"})


class HideFilesystemToolsMiddleware(AgentMiddleware):
"""
未启用技能时隐藏 deepagents 内置的文件与 Shell 工具,模型只能使用节点配置的工具与 MCP,
避免绕过 MCP 自己写文件或在服务器上执行命令。
"""

@staticmethod
def _filter(request):
tools = [
tool
for tool in request.tools
if (tool.get("name") if isinstance(tool, dict) else getattr(tool, "name", None))
not in _FILESYSTEM_TOOL_NAMES
]
return request.override(tools=tools) if len(tools) != len(request.tools) else request

def wrap_model_call(self, request, handler):
return handler(self._filter(request))

async def awrap_model_call(self, request, handler):
return await handler(self._filter(request))


def create_agent(
chat_model,
system_prompt,
Expand Down Expand Up @@ -169,10 +197,13 @@ async def _run():
for tool in extra_tools:
tools.append(tool)

# 只有技能需要读写技能文件并执行脚本,才挂载本机 Shell 后端与内置文件工具;
# 仅 MCP/工具时使用默认的内存后端并隐藏内置文件工具,模型只能通过配置的工具完成任务
agent = create_deep_agent(
model=chat_model,
backend=SandboxShellBackend(root_dir=temp_dir, virtual_mode=True),
skills=["/skills"],
backend=SandboxShellBackend(root_dir=temp_dir, virtual_mode=True) if skill_tool_ids else None,
skills=["/skills"] if skill_tool_ids else None,
middleware=() if skill_tool_ids else (HideFilesystemToolsMiddleware(),),
tools=tools,
system_prompt=system_prompt,
interrupt_on={"write_file": False, "read_file": False, "edit_file": False},
Expand Down
200 changes: 148 additions & 52 deletions apps/chat/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@

UPLOAD_FILE_TOOL = "upload_file"

# 全局 MCP 地址(不带应用 id)使用:先列出可访问的智能体,再按 application_id 调用
LIST_AGENTS_TOOL = "list_agents"

CALL_AGENT_TOOL = "call_agent"


class InvalidParamsError(Exception):
"""工具参数不合法,对应 JSON-RPC -32602"""
Expand Down Expand Up @@ -211,9 +216,11 @@ def call_tool(self, params):

class ChatUserMCPToolHandler:
"""
对话用户 API Key(chat-xxx)的 MCP 接口:Key 不绑定应用,
以对话用户身份暴露其有权限访问的所有已发布应用,每个应用一个工具,另加一个上传文件工具。
传入上一次返回的 chat_id 即可续聊;上传的文件挂在 chat_id 下,调用应用时通过 file_ids 携带。
对话用户 API Key(chat-xxx)的 MCP 接口,以对话用户身份调用其有权限访问的已发布应用:
- 单应用地址:暴露该应用的 agent_<id> 工具和上传文件工具;
- 全局地址:暴露 list_agents(列出可访问的应用)、call_agent(按 application_id 调用)和上传文件工具,
应用再多工具数量也固定。
传入上一次返回的 chat_id 即可续聊;上传的文件挂在 chat_id 下,调用应用时通过 image_list 等附件字段携带。
"""

def __init__(self, chat_user, auth, application_id=None):
Expand Down Expand Up @@ -241,40 +248,91 @@ def get_application_ids(self):
def get_applications(self):
return QuerySet(Application).filter(id__in=self.get_application_ids(), is_publish=True).order_by("name")

def is_accessible_application(self, application_id):
try:
application_id = str(std_uuid.UUID(str(application_id)))
except ValueError:
return False
return self.get_applications().filter(id=application_id).exists()

@staticmethod
def build_agent_tool(application):
def build_agent_input_schema(with_application_id=False):
application_id = (
{
"application_id": {
"type": "string",
"description": f"Id of the agent to call, from {LIST_AGENTS_TOOL}.",
}
}
if with_application_id
else {}
)
return {
"name": f"{AGENT_TOOL_PREFIX}{str(application.id)}",
"description": f"{application.name} {application.desc or ''}".strip(),
"inputSchema": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "The message to send to the AI."},
"chat_id": {
"type": "string",
"description": "Conversation id returned by a previous call or by upload_file. "
"Pass it to continue the same conversation with history; omit it to start a new one.",
},
"file_ids": {
"type": "object",
"properties": {
**application_id,
"message": {"type": "string", "description": "The message to send to the AI."},
"chat_id": {
"type": "string",
"description": "Conversation id returned by a previous call or by upload_file. "
"Pass it to continue the same conversation with history; omit it to start a new one.",
},
**{
field: {
"type": "array",
"items": {"type": "string"},
"description": "Ids of files uploaded with upload_file to attach to this message.",
},
"form_data": {
"type": "object",
"description": "Values for the application's input parameters.",
},
"items": {
"type": "object",
"properties": {"file_id": {"type": "string"}},
"required": ["file_id"],
},
"description": f"{CHAT_FILE_TYPE_LABELS[field].capitalize()} files uploaded with "
'upload_file to attach to this message, e.g. [{"file_id": "..."}].',
}
for field in CHAT_FILE_LIST_FIELDS
},
"form_data": {
"type": "object",
"description": "Values for the application's input parameters.",
},
"required": ["message"],
},
"required": [*application_id, "message"],
}

@classmethod
def build_agent_tool(cls, application):
# 应用描述可能为空,补充通用说明,调用方模型才知道把用户的任务交给该智能体
description = f"{application.name} {application.desc or ''}".strip()
return {
"name": f"{AGENT_TOOL_PREFIX}{str(application.id)}",
"description": f"{description}\nAn AI agent. Send the user's request as message; "
"the agent completes the task and returns its answer.",
"inputSchema": cls.build_agent_input_schema(),
}

@classmethod
def build_global_tools(cls):
return [
{
"name": LIST_AGENTS_TOOL,
"description": "List the AI agents you can call, with their id, name and description. "
f"Pick the agent that fits the user's request and call it with {CALL_AGENT_TOOL}.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": CALL_AGENT_TOOL,
"description": f"Call an AI agent from {LIST_AGENTS_TOOL} by application_id. "
"Send the user's request as message; the agent completes the task and returns its answer.",
"inputSchema": cls.build_agent_input_schema(with_application_id=True),
},
]

@staticmethod
def build_upload_tool():
return {
"name": UPLOAD_FILE_TOOL,
"description": "Upload a file to attach to a conversation. Returns file_id and chat_id; "
"pass both to an agent tool (file_ids, chat_id) so the AI can read the file.",
"description": "Upload a file to attach to a conversation. Returns file_id, field and chat_id; "
'pass {"file_id": ...} in the returned field (e.g. document_list) together with chat_id '
"to an agent tool so the AI can read the file.",
"inputSchema": {
"type": "object",
"properties": {
Expand All @@ -290,11 +348,21 @@ def build_upload_tool():
}

def list_tools(self):
agent_tools = (
[self.build_agent_tool(application) for application in self.get_applications()]
if self.application_id
else self.build_global_tools()
)
return {"tools": [*agent_tools, self.build_upload_tool()]}

def list_agents(self):
agents = [
{"application_id": str(application.id), "name": application.name, "desc": application.desc or ""}
for application in self.get_applications()
]
return {
"tools": [
*[self.build_agent_tool(application) for application in self.get_applications()],
self.build_upload_tool(),
]
"content": [{"type": "text", "text": json.dumps(agents, ensure_ascii=False)}],
"structuredContent": {"agents": agents},
}

def check_chat_id(self, chat_id, application_id=None):
Expand All @@ -315,29 +383,42 @@ def check_chat_id(self, chat_id, application_id=None):
raise InvalidParamsError(f"Invalid chat_id: {chat_id}")
return chat_id

def get_chat_files(self, chat_id, file_ids):
def get_chat_files(self, chat_id, args):
"""
根据 file_ids 查出当前用户上传到该会话的文件,按类型组装成消息附件;未传 chat_id 时取文件所在会话。
与 MaxKB 对话接口一致,按 image_list / document_list 等字段接收附件 [{"file_id": ...}],
校验为当前用户上传到同一会话的文件后补全 name、url;未传 chat_id 时取文件所在会话。
"""
if not file_ids:
field_file_ids = {}
for field in CHAT_FILE_LIST_FIELDS:
attachments = args.get(field)
if not attachments:
continue
if not isinstance(attachments, list) or not all(
isinstance(item, dict) and item.get("file_id") for item in attachments
):
raise InvalidParamsError(f'{field} must be an array of {{"file_id": ...}}')
field_file_ids[field] = [str(item["file_id"]) for item in attachments]
if not field_file_ids:
return chat_id, {}
if not isinstance(file_ids, list):
raise InvalidParamsError("file_ids must be an array")
files = list(
QuerySet(File).filter(
id__in=[str(file_id) for file_id in file_ids],
file_ids = {file_id for ids in field_file_ids.values() for file_id in ids}
files = {
str(file.id): file
for file in QuerySet(File).filter(
id__in=file_ids,
source_type=FileSourceType.CHAT,
meta__user_id=str(self.chat_user.id),
)
)
source_ids = {str(file.source_id) for file in files}
if len(files) != len(set(map(str, file_ids))) or len(source_ids) != 1 or chat_id not in (None, *source_ids):
raise InvalidParamsError("file_ids must be files uploaded to the same chat_id")
chat_files = {}
for file in files:
chat_files.setdefault(get_chat_file_field(file.file_name), []).append(
{"name": file.file_name, "url": f"./oss/file/{file.id}", "file_id": str(file.id)}
)
}
source_ids = {str(file.source_id) for file in files.values()}
if len(files) != len(file_ids) or len(source_ids) != 1 or chat_id not in (None, *source_ids):
raise InvalidParamsError("Attached files must be uploaded with upload_file to the same chat_id")
chat_files = {
field: [
{"name": files[file_id].file_name, "url": f"./oss/file/{file_id}", "file_id": file_id}
for file_id in ids
]
for field, ids in field_file_ids.items()
}
return source_ids.pop(), chat_files

def upload_file(self, args):
Expand All @@ -360,7 +441,14 @@ def upload_file(self, args):
"source_type": FileSourceType.CHAT,
}
).upload(user_id=str(self.chat_user.id))
result = {"file_id": file_url.split("/")[-1], "name": file_name, "url": file_url, "chat_id": chat_id}
result = {
"file_id": file_url.split("/")[-1],
"name": file_name,
"url": file_url,
# 按后缀给出建议的附件字段,调用方可据此放入对应的 *_list
"field": get_chat_file_field(file_name),
"chat_id": chat_id,
}
return {
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
"structuredContent": result,
Expand All @@ -371,15 +459,23 @@ def call_tool(self, params):
args = params.get("arguments") or {}
if name == UPLOAD_FILE_TOOL:
return self.upload_file(args)
if not self.application_id and name == LIST_AGENTS_TOOL:
return self.list_agents()
if not self.application_id and name == CALL_AGENT_TOOL:
application_id = str(args.get("application_id") or "")
if not self.is_accessible_application(application_id):
raise InvalidParamsError(f"Unknown application_id: {application_id}")
return self.call_agent(application_id, args)
application_id = name[len(AGENT_TOOL_PREFIX) :] if name.startswith(AGENT_TOOL_PREFIX) else ""
if not application_id or not self.get_applications().filter(id=application_id).exists():
if not self.application_id or not self.is_accessible_application(application_id):
raise InvalidParamsError(f"Unknown tool: {name}")
return self.call_agent(application_id, args)

def call_agent(self, application_id, args):
form_data = args.get("form_data") or {}
if not isinstance(form_data, dict):
raise InvalidParamsError("form_data must be an object")
chat_id, chat_files = self.get_chat_files(
self.check_chat_id(args.get("chat_id"), application_id), args.get("file_ids")
)
chat_id, chat_files = self.get_chat_files(self.check_chat_id(args.get("chat_id"), application_id), args)
# 文件可能先上传到别的应用的会话,这里再按当前应用校验一次
chat_id = self.check_chat_id(chat_id, application_id)
answer, chat_id = chat_application(
Expand Down
Loading
Loading