This commit is contained in:
minorQAQ committed 2026-06-17 12:58:18 +08:00
1 parent 5e5a4b7376
commit 1396d652f7
145 files changed
+9542 -1453

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
"""Agent 实例注册包。
系统定位:
对外导出各 Agent 的 ``AgentRuntime`` 单例及多 Agent 运行时。
Agent 列表:
- main_agent_runtime: 主 Agent 运行时(单 Agent 模式)。
- build_multi_agent_runtime: 构建一主多子 Agent 运行时。
- get_agent_graph_snapshot: 获取多 Agent 图状态快照。
可扩展性:
子 Agent 实现后在此追加导出,如::
from agent.agents.research_agent import research_agent_runtime
__all__ = ["main_agent_runtime", "research_agent_runtime"]
"""
from agent.agents.main_agent import main_agent_runtime, reload_main_agent_runtime
from agent.agents.multi_agent_runtime import (
MultiAgentRuntime,
SubAgentConfig,
build_multi_agent_runtime,
get_agent_graph_snapshot,
)
__all__ = [
"main_agent_runtime",
"reload_main_agent_runtime",
"MultiAgentRuntime",
"SubAgentConfig",
"build_multi_agent_runtime",
"get_agent_graph_snapshot",
]
+10
View File
@@ -0,0 +1,10 @@
"""代码子 Agent(预留)。
系统定位:
计划用于代码生成、静态分析、仓库操作等,可绑定代码执行沙箱工具
与目录读写类工具。
可扩展性:
可实现独立 AgentRuntime,或在 team 模式下由 main Agent 委派任务;
建议对执行类工具保持 confirm 策略。
"""
View File
Whitespace-only changes.
+10
View File
@@ -0,0 +1,10 @@
"""GUI子 Agent(预留)。
系统定位:
计划用于GUI自动化、页面理解与操作编排,工具以 gui_control
及 screen_capture 为主。
可扩展性:
实现时可单独配置 tool_policy 白名单、更严格的 confirm 策略,
并绑定专用 system_prompt 与较短上下文窗口。
"""
+74
View File
@@ -0,0 +1,74 @@
"""主 Agent 装配入口:工具、模型、图与运行时单例。
系统定位:
项目默认 Agent 实例 ``main_agent_runtime``,由 Web 层导入并调用
``core.runtime.execute_agent`` / ``continue_after_human_action``。
装配内容:
- get_tools(): 主 Agent 可用工具集
- llm.bind_tools: 工具调用能力
- build_agent_graph: 标准 ReAct 图
- MAIN_SYSTEM_PROMPT: 系统提示词
可扩展性:
- 可按环境变量切换工具子集或模型。
- 子 Agent(research/browser/code)可仿照本文件独立装配 AgentRuntime。
"""
from __future__ import annotations
from agent.core.graph_builder import build_agent_graph
from agent.core.llm import llm
from agent.core.runtime import AgentRuntime
from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT
from agent.tools import get_tools
def _build_main_agent_runtime():
"""根据当前配置重新构建主 Agent 运行时。"""
from agent.core.llm import llm as _llm
_system_prompt = MAIN_SYSTEM_PROMPT
_max_iterations = 200
_tools = get_tools()
try:
from agent.core.config_manager import load_agent_configs
configs = load_agent_configs()
for cfg in configs:
if cfg.name == "main" and cfg.enabled:
if cfg.system_prompt:
_system_prompt = cfg.system_prompt
_max_iterations = cfg.max_iterations
break
except Exception:
pass
model_with_tools = _llm.bind_tools(_tools, tool_choice="auto")
return AgentRuntime(
name="main",
graph=build_agent_graph(_tools, model_with_tools),
llm=_llm,
tools=_tools,
tools_by_name={tool.name: tool for tool in _tools},
system_prompt=_system_prompt,
max_iterations=_max_iterations,
)
main_agent_runtime = _build_main_agent_runtime()
def reload_main_agent_runtime() -> dict:
"""重新加载配置并重建主 Agent 运行时,返回当前状态供调试。"""
global main_agent_runtime
import agent.chatbot_agent as ca
main_agent_runtime = _build_main_agent_runtime()
# 更新 chatbot_agent 中缓存的引用
ca.main_agent_runtime = main_agent_runtime
ca.agent_graph = main_agent_runtime.graph
ca.tools = main_agent_runtime.tools
ca.TOOLS_BY_NAME = main_agent_runtime.tools_by_name
ca.llm = main_agent_runtime.llm
return {
"name": main_agent_runtime.name,
"tools_count": len(main_agent_runtime.tools),
"max_iterations": main_agent_runtime.max_iterations,
}
+252
View File
@@ -0,0 +1,252 @@
"""多 Agent 运行时:一主多子 Agent 体系的初始化与管理。
系统定位:
提供 ``MultiAgentRuntime`` 将主 Agent 与多个子 Agent 组装为
完整的 SupervisorGraph 运行时,供 Web 层调用。
与单 Agent 的区别:
- 使用 MultiAgentState 替代 AgentState
- 使用 build_multi_agent_graph 替代 build_agent_graph
- 额外维护 graph_events / agent_status / tool_calls_history 供前端 graph 面板
可扩展性:
- 支持运行时动态添加/移除子 Agent
- 支持并行子 Agent 执行策略
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from agent.core.multi_agent_graph import build_multi_agent_graph
from agent.core.multi_agent_state import (
AgentStatusInfo,
GraphEvent,
ToolCallEvent,
init_multi_agent_state,
)
from agent.tools.tool_agent_call import AgentCallTool
@dataclass
class SubAgentConfig:
"""子 Agent 配置容器。
字段:
name: 子 Agent 名称。
graph: 编译后的 LangGraph 图。
llm: 原始 LLM 实例。
tools: 工具列表。
tools_by_name: 工具名 -> 工具实例映射。
system_prompt: 系统提示词。
icon: 前端展示图标。
"""
name: str
graph: Any
llm: Any
tools: list[Any] = field(default_factory=list)
tools_by_name: dict[str, Any] = field(default_factory=dict)
system_prompt: str = ""
icon: str = ""
@dataclass
class MultiAgentRuntime:
"""多 Agent 运行时配置容器。
字段:
name: 运行时标识(如 "multi_agent_v1")。
graph: 编译后的 SupervisorGraph。
llm: 主 Agent 的 LLM 实例。
main_agent_name: 主 Agent 名称。
agents: {name: SubAgentConfig} 所有 agent 配置(含主 agent 信息)。
graph_events: 最近一次执行的图事件列表(浅拷贝)。
agent_status: 最近一次执行的 agent 状态。
tool_calls_history: 最近一次执行的工具调用历史。
"""
name: str
graph: Any
llm: Any
main_agent_name: str = "main"
agents: dict[str, SubAgentConfig] = field(default_factory=dict)
graph_events: list[GraphEvent] = field(default_factory=list)
agent_status: dict[str, AgentStatusInfo] = field(default_factory=dict)
tool_calls_history: dict[str, list[ToolCallEvent]] = field(default_factory=dict)
def build_multi_agent_runtime(
main_runtime: Any,
sub_runtimes: list[dict[str, Any]] | None = None,
) -> MultiAgentRuntime:
"""根据主 Agent 和子 Agent 运行时列表构建多 Agent 运行时。
输入:
main_runtime: 主 Agent 的 AgentRuntime 实例(name/graph/llm/tools/tools_by_name/system_prompt)。
sub_runtimes: 子 Agent 配置列表,每项为 dict,含:
- runtime: AgentRuntime 实例(必填)
- icon: 前端图标(可选,默认 🔧)
sub_runtimes 为 None 时构建纯主 Agent 的单 Agent 运行时。
输出:
MultiAgentRuntime 实例,其 graph 为编译后的 SupervisorGraph。
示例::
from agent.agents.main_agent import main_agent_runtime
runtime = build_multi_agent_runtime(
main_runtime=main_agent_runtime,
sub_runtimes=[
{"runtime": code_agent_runtime, "icon": "💻"},
{"runtime": research_agent_runtime, "icon": "🔍"},
],
)
"""
sub_runtimes = sub_runtimes or []
# 组装主 agent 工具(含 agent_call)
main_tools = list(main_runtime.tools) + [AgentCallTool()]
main_tools_by_name = dict(main_runtime.tools_by_name)
main_tools_by_name["agent_call"] = AgentCallTool()
main_model_with_tools = main_runtime.llm.bind_tools(main_tools, tool_choice="auto")
# 子 agent 配置列表
sub_agent_configs: list[dict[str, Any]] = []
agent_configs: dict[str, SubAgentConfig] = {}
main_agent_name = main_runtime.name
agent_configs[main_agent_name] = SubAgentConfig(
name=main_agent_name,
graph=main_runtime.graph,
llm=main_runtime.llm,
tools=main_tools,
tools_by_name=main_tools_by_name,
system_prompt=main_runtime.system_prompt,
icon="🧠",
)
for sr in sub_runtimes:
sr_runtime = sr["runtime"]
sr_icon = sr.get("icon", "🔧")
sa_config = SubAgentConfig(
name=sr_runtime.name,
graph=sr_runtime.graph,
llm=sr_runtime.llm,
tools=sr_runtime.tools,
tools_by_name=sr_runtime.tools_by_name,
system_prompt=sr_runtime.system_prompt,
icon=sr_icon,
)
agent_configs[sr_runtime.name] = sa_config
sub_agent_configs.append({
"name": sr_runtime.name,
"graph": sr_runtime.graph,
"tools_by_name": sr_runtime.tools_by_name,
})
# 构建 SupervisorGraph
graph = build_multi_agent_graph(
main_model_with_tools=main_model_with_tools,
main_tools=main_tools,
main_tools_by_name=main_tools_by_name,
sub_agents=sub_agent_configs,
main_agent_name=main_agent_name,
)
return MultiAgentRuntime(
name=f"multi_{main_agent_name}",
graph=graph,
llm=main_runtime.llm,
main_agent_name=main_agent_name,
agents=agent_configs,
)
def get_agent_graph_snapshot(runtime: MultiAgentRuntime) -> dict:
"""获取当前多 Agent 运行时状态快照,供前端 graph 面板渲染。
输入:
runtime: MultiAgentRuntime 实例。
输出:
{
"agents": [{"name": str, "role": "main"|"sub", "icon": str, "status": str, "error_message": str}, ...],
"edges": [{"source": str, "target": str, "highlighted": bool}, ...],
"events": [...],
"tool_calls": {agent_name: [ToolCallEvent, ...]},
}
"""
agents_list = []
for name, cfg in runtime.agents.items():
status_info = runtime.agent_status.get(name)
status = status_info.status if status_info and isinstance(status_info, AgentStatusInfo) else "idle"
error_msg = status_info.error_message if status_info and isinstance(status_info, AgentStatusInfo) else ""
agents_list.append({
"name": name,
"role": "main" if name == runtime.main_agent_name else "sub",
"icon": cfg.icon,
"status": status,
"error_message": error_msg,
})
# 构建边:主 agent 与每个子 agent 之间两条单向线
edges = []
main_name = runtime.main_agent_name
for name in runtime.agents:
if name == main_name:
continue
# 检查是否有消息传递事件
highlighted_to_sub = any(
e.event_type == "message_pass" and e.source_agent == main_name and e.target_agent == name
for e in runtime.graph_events
)
highlighted_to_main = any(
e.event_type == "message_pass" and e.source_agent == name and e.target_agent == main_name
for e in runtime.graph_events
)
edges.append({
"source": main_name,
"target": name,
"highlighted": highlighted_to_sub,
})
edges.append({
"source": name,
"target": main_name,
"highlighted": highlighted_to_main,
})
# 序列化事件
events_list = []
for e in runtime.graph_events:
events_list.append({
"event_type": e.event_type,
"source_agent": e.source_agent,
"target_agent": e.target_agent,
"agent_name": e.agent_name,
"tool_name": e.tool_name,
"tool_args": e.tool_args,
"tool_result_preview": e.tool_result_preview,
"timestamp": e.timestamp,
"message_preview": e.message_preview,
})
# 序列化工具调用历史
tool_calls_serialized = {}
for agent_name, calls in runtime.tool_calls_history.items():
tool_calls_serialized[agent_name] = [
{
"tool_name": c.tool_name,
"tool_args": c.tool_args,
"result_preview": c.result_preview,
"status": c.status,
"timestamp": c.timestamp,
}
for c in calls
]
return {
"agents": agents_list,
"edges": edges,
"events": events_list,
"tool_calls": tool_calls_serialized,
}
+12
View File
@@ -0,0 +1,12 @@
"""研究子 Agent(预留)。
系统定位:
计划用于深度检索、文献综述等专用任务,与 main Agent 通过 team 模式
或子图委派协作。
可扩展性:
实现步骤建议:
1. 定义 RESEARCH_SYSTEM_PROMPT 与精简工具集(web_search、RAG 等)
2. build_agent_graph(tools, model) 或自定义图(含检索节点)
3. 导出 research_agent_runtime = AgentRuntime(...)
"""
+35 -264
View File
@@ -1,280 +1,51 @@
from typing import Any, Literal, Sequence, Annotated, TypedDict
import json
from __future__ import annotations
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from typing import Any, Literal
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.llm import llm
from agent.tools import get_tools
from agent.utils.agent_utils import (
assistant_text,
get_history,
summarizer,
get_session_meta,
update_session_meta,
)
from web.ui_session import (
rewrite_user_message_files_to_session_dir,
session_dir,
sync_manifest_from_chat_history,
)
from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT
from agent.agents.main_agent import main_agent_runtime
from agent.core.runtime import continue_after_human_action as continue_runtime_after_human_action
from agent.core.runtime import execute_agent as execute_runtime_agent
# ---------- 1. 工具与模型 ----------
tools = get_tools()
model_with_tools = llm.bind_tools(tools)
AgentMode = Literal["agent", "team"]
# ---------- 2. 定义状态 ----------
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], add_messages]
# ---------- 3. 节点定义 ----------
def call_model(state: AgentState) -> dict:
"""调用 LLM 生成回复或工具调用"""
response = model_with_tools.invoke(state["messages"])
# ----- 打印 token 使用情况 -----
usage = response.usage_metadata if hasattr(response, 'usage_metadata') else {}
if usage:
input_tokens = usage.get('input_tokens', '?')
output_tokens = usage.get('output_tokens', '?')
total_tokens = usage.get('total_tokens', '?')
print(f"[LLM] Token使用: input={input_tokens}, output={output_tokens}, total={total_tokens}")
# ----- 打印工具调用信息 -----
if hasattr(response, 'tool_calls') and response.tool_calls:
for tc in response.tool_calls:
# tc 是 ToolCall 对象,可直接访问 .name 和 .args
tool_name = tc.get('name') if isinstance(tc, dict) else tc.name
args = tc.get('args') if isinstance(tc, dict) else tc.args
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
pass
print(f"[LLM] 工具调用: {tool_name}, 参数: {args}")
return {"messages": [response]}
# 工具执行节点(LangGraph 内置,自动解包 content_and_artifact 到 ToolMessage.artifact)
tool_node = ToolNode(tools)
def process_tool_artifact(state: AgentState) -> dict:
last_msg = state["messages"][-1]
# 日志打印
if isinstance(last_msg, ToolMessage):
content_preview = str(last_msg.content)
if len(content_preview) > 200:
content_preview = content_preview[:200] + '...'
print(f"[Tool] 工具返回 (id={last_msg.tool_call_id}): {content_preview}")
# 只处理 ToolMessage 且有 artifact 的情况
if not isinstance(last_msg, ToolMessage) or not last_msg.artifact:
return {}
artifact = last_msg.artifact
# ---------- 统一提取所有 image_bytes ----------
img_bytes_list: list[bytes] = []
if isinstance(artifact, dict) and "image_bytes" in artifact:
# 单张图片:artifact = {"image_bytes": b"..."}
img_bytes_list.append(artifact["image_bytes"])
elif isinstance(artifact, list):
for item in artifact:
if isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
elif isinstance(item, bytes):
img_bytes_list.append(item)
# 兼容 artifact 直接就是 bytes 的情况
elif isinstance(artifact, bytes):
img_bytes_list.append(artifact)
if not img_bytes_list:
return {}
# ---------- 将图片转换为多模态 content ----------
content_parts = []
failed_count = 0
for img_bytes in img_bytes_list:
data_url = image_bytes_to_openai_image_url_part(img_bytes)
if data_url is None:
failed_count += 1
continue
content_parts.append(data_url)
# 所有图片都处理失败时的降级方案
if not content_parts:
human_msg = HumanMessage(
content="工具返回了截图,但图片处理失败,请向用户说明。",
additional_kwargs={"synthetic": True}
)
return {"messages": [human_msg]}
# 追加引导文本(放在最后,也可以放在最前,取决于你的需求)
content_parts.append({
"type": "text",
"text": "上面是工具返回的截图。"
})
# 如果有部分图片失败,可以在 text 中提示(可选)
if failed_count > 0:
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
human_msg = HumanMessage(
content=content_parts,
additional_kwargs={"synthetic": True}
)
return {"messages": [human_msg]}
# ---------- 4. 条件路由 ----------
def should_continue(state: AgentState) -> Literal["tools", END]:
last_msg = state["messages"][-1]
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
return "tools"
return END
# ---------- 5. 构建 Graph ----------
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
workflow.add_node("process_tool_artifact", process_tool_artifact)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, {
"tools": "tools",
END: END,
})
workflow.add_edge("tools", "process_tool_artifact")
workflow.add_edge("process_tool_artifact", "agent")
agent_graph = workflow.compile()
# ---------- 6. 辅助函数:保存消息(过滤合成消息) ----------
def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None:
if not session_id:
return
history = get_history(session_id)
# 只保存非合成消息
if not user_msg.additional_kwargs.get("synthetic"):
history.add_message(user_msg)
history.add_message(assistant_msg)
# ---------- 7. 核心执行函数 ----------
def execute_agent(
chat_history: list[dict[str, Any]],
session_id: str = "test",
output_type: str = "text",
agent_mode: AgentMode = "agent",
) -> list[dict[str, Any]]:
chat_history = chat_history or []
if not chat_history or chat_history[-1].get("role") != "user":
return chat_history
# 上传文件处理
if session_id:
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
user_content = chat_history[-1]["content"]
# 上下文感知构建
messages, context_exceeded = summarizer(
"""Web/API 层入口:使用 main_agent 运行时处理用户新一轮输入。"""
return execute_runtime_agent(
runtime=main_agent_runtime,
chat_history=chat_history,
session_id=session_id,
agent_name="main",
system_prompt=MAIN_SYSTEM_PROMPT,
user_content=user_content,
llm=llm,
output_type=output_type,
agent_mode=agent_mode,
)
if context_exceeded:
chat_history.append({
"role": "assistant",
"content": "上下文已满,请新建会话继续对话。",
})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
graph_config = {
"configurable": {"session_id": session_id},
"metadata": {
"output_type": output_type,
"audio_output_dir": session_dir(session_id) if session_id else None,
},
}
def continue_after_human_action(
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit"],
instruction: str | None = None,
output_type: str = "text",
) -> list[dict[str, Any]]:
"""Web/API 层入口:用户对待确认项决策后继续执行 main_agent。"""
return continue_runtime_after_human_action(
runtime=main_agent_runtime,
chat_history=chat_history,
session_id=session_id,
approval_id=approval_id,
decision=decision,
instruction=instruction,
output_type=output_type,
)
try:
# 调用自定义 Graph
result = agent_graph.invoke({"messages": messages}, config=graph_config)
# 提取最后一条 AI 消息
all_messages = result["messages"]
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
text_content = assistant_text(assistant_msg.content)
# token 统计更新
usage = assistant_msg.usage_metadata if hasattr(assistant_msg, 'usage_metadata') else {}
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
if total_tokens > 0:
meta = get_session_meta(session_id, "main")
update_session_meta(
session_id=session_id,
agent_name="main",
cursor_index=meta["cursor_index"],
compressed_content=meta["compressed_content"],
last_total_tokens=total_tokens,
)
# TTS 处理
output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text")
audio_data = llm.last_audio_kwargs or {}
if audio_data.get("output_type") != output_type_from_config:
audio_data = {}
assistant_meta = {
"output_type": output_type_from_config,
"autoplay": False,
"audio": audio_data.get("audio"),
}
if output_type_from_config == "audio" and assistant_meta.get("audio"):
assistant_content = []
elif output_type_from_config == "both" and assistant_meta.get("audio"):
assistant_content = text_content
else:
assistant_content = text_content if text_content else "(空回复)"
chat_history.append({
"role": "assistant",
"content": assistant_content,
"meta": assistant_meta,
})
# 持久化历史消息(注意过滤掉合成消息)
save_messages_to_history(
session_id,
HumanMessage(content=user_content),
AIMessage(content=text_content),
)
llm.last_audio_kwargs = None
except Exception as e:
import traceback
print("[ERROR] 调用模型时出错:")
traceback.print_exc()
err_text = "抱歉,我遇到了一些问题,请稍后再试。"
chat_history.append({"role": "assistant", "content": err_text})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
agent_graph = main_agent_runtime.graph
tools = main_agent_runtime.tools
TOOLS_BY_NAME = main_agent_runtime.tools_by_name
llm = main_agent_runtime.llm
+16
View File
@@ -0,0 +1,16 @@
"""Agent 核心运行时包。
包含:
- state / graph_builder: LangGraph 状态与图拓扑
- nodes / routing: 图节点与条件路由
- runtime: 对外执行入口 execute_agent / continue_after_human_action
- llm: ChatQwen 多模态网关
- approvals / human_interaction / tool_policy: HITL 与安全策略
- plan: Plan 模式 UI 构建
系统定位:
Agent 业务逻辑中枢,``agents`` 包在此之上组装具体 Agent 实例。
可扩展性:
子 Agent 可复用本包组件,仅替换 tools、system_prompt 与 graph 变体。
"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
[
{
"name": "main",
"system_prompt": "你是一个多模态助手,能够直接理解用户上传的图片、语音等内容。\n 【关键行为准则】\n - 你的默认行为是直接回答用户问题,不要调用任何工具。\n - 只有当某个信息是你**绝对无法从当前对话或上传内容中获取**,且该信息必须通过外部工具才能获得时,才允许调用工具。\n - 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。\n - 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。\n - 违反上述规则是一种严重错误。",
"max_iterations": 200,
"llm": {
"model": "qwen3.6-flash",
"api_key": "sk-a44b226ec79a40fd8fcdbbaa74ba133e",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"timeout": 60.0,
"max_retries": 0
},
"llm_model_id": "a44b226e",
"tools": [
"web_search_class",
"date_query_class",
"screen_capture",
"browser_control",
"human_interaction",
"software_control",
"directory_listing"
],
"enabled": true,
"role": "main"
}
]
+166
View File
@@ -0,0 +1,166 @@
"""待确认操作(HITL)内存注册与 UI 元数据构建。
系统定位:
维护进程内 ``PENDING_TOOL_APPROVALS`` 字典,在图因需确认工具而暂停时
保存上下文;``runtime.continue_after_human_action`` 消费并清理。
注意:
当前为内存存储,进程重启后待确认项丢失;生产环境可改为 Redis/DB。
可扩展性:
- 增加 TTL、session 级清理、持久化后端。
- ``build_*_meta`` 可与前端组件协议版本化。
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Sequence
from langchain_core.messages import BaseMessage
from langchain_core.messages import messages_to_dict
from agent.core.human_interaction import normalize_human_interaction_args
from agent.utils.tool_call_utils import json_safe
PENDING_TOOL_APPROVALS: dict[str, dict[str, Any]] = {}
def build_approval_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage]) -> dict[str, Any]:
"""为需确认的普通工具调用构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: normalize 后的工具调用 dict。
messages: 当前图内消息序列(序列化后存入 pending)。
输出:
``{"pending_action": {...}}``,含 id、type=tool_call、args、options 等。
系统定位:
``routing.first_pending_tool_meta`` 对 confirm 类工具调用。
可扩展性:
可按工具名定制 title、风险提示、参数展示模板。
"""
approval_id = uuid.uuid4().hex
tool_name = tool_call.get("name") or "unknown_tool"
args = json_safe(tool_call.get("args") or {})
display_call = {"name": tool_name, "args": args}
instruction = json.dumps(display_call, ensure_ascii=False, indent=2)
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "tool_call",
"tool_call": {"id": tool_call.get("id"), "name": tool_name, "args": args},
"messages": messages_to_dict(list(messages)),
}
return {
"pending_action": {
"id": approval_id,
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": display_call,
"instruction": instruction,
"options": ["approve", "reject"],
"resolved": False,
}
}
def build_human_interaction_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage]) -> dict[str, Any]:
"""为 human_interaction 工具构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: 含 name=args 的工具调用。
messages: 当前消息序列。
输出:
``{"pending_action": {...}}``,type=human_interaction,含 interaction_type 等。
系统定位:
模型主动请求用户补充信息/选择/审批时使用。
可扩展性:
可扩展表单 schema、多步向导式交互。
"""
approval_id = uuid.uuid4().hex
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
"approval": "需要人工审批",
}
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "human_interaction",
"tool_call": {"id": tool_call.get("id"), "name": "human_interaction", "args": args},
"messages": messages_to_dict(list(messages)),
}
return {
"pending_action": {
"id": approval_id,
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"),
"prompt": args.get("prompt") or "请处理后继续。",
"tool_name": "human_interaction",
"args": {
"交互类型": interaction_type,
"说明": args.get("prompt") or "请处理后继续。",
"可选项": args.get("options") or [],
},
"instruction": args.get("suggested_response") or "",
"options": args.get("options") or [],
"resolved": False,
}
}
def pending_assistant_message(meta: dict[str, Any], content: str = "需要你确认后继续执行。") -> dict[str, Any]:
"""构造带 pending_action 的 assistant 聊天消息 dict。
输入:
meta: 通常含 pending_action 的字典。
content: 展示给用户的提示文案。
输出:
Web UI 使用的 ``{"role": "assistant", "content": ..., "meta": ...}``。
系统定位:
``runtime.execute_agent`` 检测到待确认时追加到 chat_history。
可扩展性:
可统一 output_type、国际化 content 模板。
"""
return {
"role": "assistant",
"content": content,
"meta": {"output_type": "text", "autoplay": False, **meta},
}
def remove_pending_ui_message(chat_history: list[dict[str, Any]], approval_id: str) -> None:
"""在 chat_history 中将指定 approval_id 的 pending_action 标记为已解决。
输入:
chat_history: 可变聊天历史列表(原地修改)。
approval_id: 待确认项 ID。
输出:
无(副作用:匹配消息的 meta.pending_action.resolved = True)。
系统定位:
``runtime.continue_after_human_action`` 用户操作后更新 UI 状态。
可扩展性:
可改为从 history 中移除而非仅标记 resolved。
"""
for msg in reversed(chat_history):
pending = ((msg.get("meta") or {}).get("pending_action") or {}) if isinstance(msg, dict) else {}
if pending.get("id") == approval_id:
pending["resolved"] = True
msg.setdefault("meta", {})["pending_action"] = pending
return
+339
View File
@@ -0,0 +1,339 @@
"""Agent 配置管理:从 JSON 文件加载/保存 agent、tool、env、model 配置。
系统定位:
提供统一接口读写 ``agent/core/`` 下的 JSON 配置文件,
使前端手动配置的 JSON 可以替代 core 底下的相应 .py 硬编码。
配置文件:
agent_config.json - Agent 运行时配置(名称、模型引用、工具列表、system_prompt)
tool_config.json - 工具权限与参数配置
env_config.json - 环境变量与 LLM 模型列表
可扩展性:
可增加版本号、校验 schema、多环境配置切换。
"""
from __future__ import annotations
import json
import os
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any
# ---------- 配置文件路径 ----------
_CORE_DIR = Path(__file__).resolve().parent
AGENT_CONFIG_PATH = _CORE_DIR / "agent_config.json"
TOOL_CONFIG_PATH = _CORE_DIR / "tool_config.json"
ENV_CONFIG_PATH = _CORE_DIR / "env_config.json"
# ---------- 模型配置 ----------
@dataclass
class ModelConfig:
id: str = "" # 唯一标识
name: str = "" # 显示名称
api_key: str = ""
base_url: str = ""
model: str = "" # API 模型名
timeout: float = 60.0
max_retries: int = 0
# ---------- LLM 配置结构(保留兼容) ----------
@dataclass
class LLMConfig:
model: str = ""
api_key: str = ""
base_url: str = ""
timeout: float = 60.0
max_retries: int = 0
@dataclass
class AgentConfig:
name: str = ""
system_prompt: str = ""
max_iterations: int = 200
llm: LLMConfig = field(default_factory=LLMConfig) # 保留兼容
llm_model_id: str = "" # 新:引用 models 列表中的模型 ID
tools: list[str] = field(default_factory=list)
enabled: bool = True
role: str = "sub" # "main" | "sub"
@dataclass
class ToolConfig:
name: str = ""
display_name: str = ""
description: str = ""
permission: str = "confirm" # "direct" | "confirm"
auto_execute_rules: list[dict] = field(default_factory=list) # [{"parameter":"action","operator":"equals","value":"open"}, ...]
enabled: bool = True
# ---------- 通用 JSON 读写 ----------
def _read_json(path: Path) -> dict | list | None:
"""读取 JSON 文件,不存在或格式错误返回 None。"""
if not path.is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def _write_json(path: Path, data: Any) -> bool:
"""写入 JSON 文件,成功返回 True。"""
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except OSError:
return False
# ---------- Agent 配置 ----------
def _default_agent_config() -> list[dict]:
"""生成默认 agent 配置(单 agent: main)。"""
from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT
from agent.tools import get_all_available_tool_names
models = load_models()
default_model_id = models[0]["id"] if models else ""
return [
{
"name": "main",
"system_prompt": MAIN_SYSTEM_PROMPT,
"max_iterations": 200,
"llm_model_id": default_model_id,
"tools": get_all_available_tool_names(),
"enabled": True,
"role": "main",
}
]
def load_agent_configs() -> list[AgentConfig]:
"""从 agent_config.json 加载 agent 配置列表,无文件时返回默认。"""
raw = _read_json(AGENT_CONFIG_PATH)
if not raw or not isinstance(raw, list):
raw = _default_agent_config()
_write_json(AGENT_CONFIG_PATH, raw)
configs = []
for item in raw:
llm_raw = item.get("llm", {})
configs.append(AgentConfig(
name=item.get("name", ""),
system_prompt=item.get("system_prompt", ""),
max_iterations=item.get("max_iterations", 200),
llm=LLMConfig(
model=llm_raw.get("model", ""),
api_key=llm_raw.get("api_key", ""),
base_url=llm_raw.get("base_url", ""),
timeout=llm_raw.get("timeout", 60.0),
max_retries=llm_raw.get("max_retries", 0),
),
llm_model_id=item.get("llm_model_id", ""),
tools=item.get("tools", []),
enabled=item.get("enabled", True),
role=item.get("role", "sub"),
))
return configs
def save_agent_configs(configs: list[dict]) -> bool:
"""将 agent 配置列表写入 agent_config.json。"""
return _write_json(AGENT_CONFIG_PATH, configs)
# ---------- Tool 配置 ----------
# 各工具的默认 auto_execute_rules —— 与 tool_policy.py 的硬编码规则对应
_TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = {
"browser_control": [
{"parameter": "action", "operator": "equals", "value": "open"},
],
"rag_tool": [
{"parameter": "action_type", "operator": "equals", "value": "query"},
],
}
def _get_tool_parameters() -> dict[str, list[dict]]:
"""获取每个工具的参数名和类型信息,供前端生成 auto_execute_rules 提示。
返回:
{tool_name: [{"name": "action", "type": "Literal[open,close]"}, ...], ...}
"""
from agent.tools import _ALL_AVAILABLE_TOOL_CLASSES
import typing
result: dict[str, list[dict]] = {}
for name, cls in _ALL_AVAILABLE_TOOL_CLASSES.items():
try:
inst = cls()
schema = getattr(inst, "args_schema", None)
if schema is None:
result[name] = []
continue
fields_info = getattr(schema, "model_fields", {})
params = []
for fname, finfo in fields_info.items():
annotation = finfo.annotation
type_str = str(annotation) if annotation is not None else "Any"
# 简化 Literal 显示
import re
m = re.search(r"Literal\[(.+?)\]", type_str)
if m:
type_str = "Literal[" + m.group(1) + "]"
params.append({"name": fname, "type": type_str})
result[name] = params
except Exception:
result[name] = []
return result
def _default_tool_config() -> list[dict]:
"""根据所有可用工具生成默认 tool 配置。"""
from agent.tools import get_all_available_tool_names, get_all_available_tool_descriptions
from agent.core.tool_policy import classify_tool_execution
all_names = get_all_available_tool_names()
all_descriptions = get_all_available_tool_descriptions()
result = []
for tname in all_names:
display_name, desc = all_descriptions.get(tname, (tname, ""))
perm = classify_tool_execution(tname, {})
default_rules = _TOOL_DEFAULT_AUTO_RULES.get(tname, [])
result.append({
"name": tname,
"display_name": display_name,
"description": desc,
"permission": perm,
"auto_execute_rules": default_rules,
"enabled": True,
})
return result
def load_tool_configs() -> list[ToolConfig]:
"""从 tool_config.json 加载工具配置,无文件时返回默认。"""
raw = _read_json(TOOL_CONFIG_PATH)
if not raw or not isinstance(raw, list):
raw = _default_tool_config()
_write_json(TOOL_CONFIG_PATH, raw)
configs = []
for item in raw:
configs.append(ToolConfig(
name=item.get("name", ""),
display_name=item.get("display_name", ""),
description=item.get("description", ""),
permission=item.get("permission", "confirm"),
auto_execute_rules=item.get("auto_execute_rules", []),
enabled=item.get("enabled", True),
))
return configs
def save_tool_configs(configs: list[dict]) -> bool:
"""将 tool 配置列表写入 tool_config.json。"""
return _write_json(TOOL_CONFIG_PATH, configs)
# ---------- 强制权限工具(前端锁死,不可修改) ----------
# 仅必须 confirm 的工具锁死;safe_tools 默认 direct 但用户可改为 confirm
_FORCED_CONFIRM_TOOLS = {"human_interaction"}
def get_tool_forced_permissions() -> dict[str, str]:
"""返回强制权限的工具映射 {tool_name: "confirm"},
前端应对这些工具禁用权限下拉框和 auto_execute_rules 编辑。"""
return {name: "confirm" for name in _FORCED_CONFIRM_TOOLS}
# ---------- 模型管理 ----------
def _default_models() -> list[dict]:
"""生成默认模型列表,首次启动时从 .env 继承已有 LLM 配置。"""
import os
api_key = os.environ.get("LLM_API_KEY", "")
base_url = os.environ.get("LLM_BASE_URL", "")
model = os.environ.get("LLM_MODEL", "")
timeout = os.environ.get("LLM_TIMEOUT", "60")
if api_key and base_url and model:
return [
{
"id": str(uuid.uuid4())[:8],
"name": "默认模型",
"api_key": api_key,
"base_url": base_url,
"model": model,
"timeout": float(timeout) if timeout else 60.0,
"max_retries": 0,
}
]
return []
def load_models() -> list[dict]:
"""从 env_config.json 的 models 字段加载模型列表。"""
raw = _read_json(ENV_CONFIG_PATH)
if isinstance(raw, dict) and "models" in raw and isinstance(raw["models"], list) and raw["models"]:
return raw["models"]
return _default_models()
def save_models(models: list[dict]) -> bool:
"""将模型列表写入 env_config.json(合并其他字段)。"""
current = load_env_config()
current["models"] = models
return _write_json(ENV_CONFIG_PATH, current)
# ---------- Env 配置 ----------
def _default_env_config() -> dict:
"""生成默认 env 配置(不含 models,models 由 load_models 独立处理)。"""
return {
"TTS_BASE_URL": "",
"ASR_BASE_URL": "",
"RAG_BASE_URL": "",
"LLM_CONTEXT_WINDOW": "131072",
"MINI_COMPRESS_RATE": "0.5",
"HARD_COMPRESS_RATE": "0.75",
"CURRENT_TURNS": "4",
"IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50",
"GROUNDING_WIDTH": "1000",
"GROUNDING_HEIGHT": "1000",
}
def load_env_config() -> dict:
"""从 env_config.json 加载环境变量配置,无文件时返回默认。"""
raw = _read_json(ENV_CONFIG_PATH)
if not raw or not isinstance(raw, dict):
raw = _default_env_config()
raw["models"] = _default_models()
_write_json(ENV_CONFIG_PATH, raw)
return raw
# 确保有 models 字段(向后兼容旧配置文件)
if "models" not in raw or not isinstance(raw.get("models"), list):
raw["models"] = _default_models()
return raw
def save_env_config(config: dict) -> bool:
"""将 env 配置写入 env_config.json(保留已有 models)。"""
# 保留已有的 models 列表(由 models API 单独管理)
existing = _read_json(ENV_CONFIG_PATH)
if isinstance(existing, dict) and "models" in existing:
config["models"] = existing["models"]
return _write_json(ENV_CONFIG_PATH, config)
# ---------- 获取所有已注册工具的基础信息 ----------
def get_registered_tool_names() -> list[str]:
"""返回所有可用工具名称(含数据库工具)。"""
from agent.tools import get_all_available_tool_names
return get_all_available_tool_names()
+25
View File
@@ -0,0 +1,25 @@
{
"TTS_BASE_URL": "",
"ASR_BASE_URL": "",
"RAG_BASE_URL": "",
"LLM_CONTEXT_WINDOW": "262144",
"MINI_COMPRESS_RATE": "0.4",
"HARD_COMPRESS_RATE": "0.6",
"CURRENT_TURNS": "4",
"IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50",
"GROUNDING_WIDTH": "1000",
"GROUNDING_HEIGHT": "1000",
"models": [
{
"id": "a44b226e",
"name": "阿里云Qwen",
"api_key": "sk-a44b226ec79a40fd8fcdbbaa74ba133e",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen3.6-flash",
"timeout": 60,
"max_retries": 0
}
]
}
+53
View File
@@ -0,0 +1,53 @@
"""LangGraph ReAct 工作流图构建。
系统定位:
将 LLM(已 bind_tools)、工具列表组装为可编译的 StateGraph,
供 ``agents/main_agent`` 及各子 Agent 的 ``AgentRuntime`` 使用。
图结构:
START -> agent -> (tools | END)
tools -> process_tool_artifact -> agent
可扩展性:
- 可增加 checkpoint、interrupt 节点、子图路由。
- 可为不同 Agent 注入不同的 conditional_edges 或额外节点(如 RAG 检索)。
"""
from __future__ import annotations
from typing import Any
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from agent.core.nodes import make_call_model_node, process_tool_artifact
from agent.core.routing import should_continue
from agent.core.state import AgentState
def build_agent_graph(tools: list[Any], model_with_tools: Any):
"""构建并编译标准 ReAct Agent 图。
输入:
tools: LangChain BaseTool 列表,供 ToolNode 执行。
model_with_tools: 已 ``bind_tools`` 的聊天模型,供 agent 节点调用。
输出:
编译后的 LangGraph CompiledGraph,支持 ``invoke({"messages": ...})``。
系统定位:
``agents/main_agent`` 入口;子 Agent 可复用同一拓扑并替换 tools/model。
可扩展性:
返回前可 ``workflow.compile(checkpointer=...)`` 启用持久化;
可参数化节点名以支持多 Agent 图合并。
"""
workflow = StateGraph(AgentState)
workflow.add_node("agent", make_call_model_node(model_with_tools))
workflow.add_node("tools", ToolNode(tools))
workflow.add_node("process_tool_artifact", process_tool_artifact)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
workflow.add_edge("tools", "process_tool_artifact")
workflow.add_edge("process_tool_artifact", "agent")
return workflow.compile()
+75
View File
@@ -0,0 +1,75 @@
"""人工交互(HITL)参数规范化与结果文案生成。
系统定位:
连接 ``human_interaction`` 工具与 ``core/approvals`` / ``core/runtime``,
将模型发起的人工交互请求转为 UI 可展示结构,并在用户操作后生成
ToolMessage 内容供图继续执行。
可扩展性:
- 新增 interaction_type 时同步扩展 ``normalize_human_interaction_args``
与 ``human_interaction_result`` 的分支。
- 可与前端约定更丰富的 payload(表单字段、文件上传等)。
"""
from __future__ import annotations
from typing import Any
from agent.tools.tool import HumanInteraction
def normalize_human_interaction_args(args: dict[str, Any]) -> dict[str, Any]:
"""规范化 human_interaction 工具参数为固定 schema。
输入:
args: 模型传入的原始参数字典。
输出:
含 interaction_type、title、prompt、options、suggested_response 的字典;
interaction_type 限定为 information | selection | approval。
系统定位:
``approvals.build_human_interaction_meta`` 构建待确认 UI 前调用。
可扩展性:
可增加 locale、超时、默认值校验;options 可支持 label/value 结构。
"""
normalized = dict(args or {})
normalized["interaction_type"] = str(normalized.get("interaction_type") or "information")
if normalized["interaction_type"] not in {"information", "selection", "approval"}:
normalized["interaction_type"] = "information"
normalized["title"] = str(normalized.get("title") or "需要人工处理")
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
normalized["suggested_response"] = str(normalized.get("suggested_response") or "")
return normalized
def human_interaction_result(pending: dict[str, Any], decision: str, instruction: str | None) -> str:
"""根据用户决策生成写入 ToolMessage 的文本结果。
输入:
pending: PENDING_TOOL_APPROVALS 中 type=human_interaction 的条目。
decision: approve | reject | edit。
instruction: 用户输入文本(补充信息、选项、修改说明等)。
输出:
供 LangGraph 继续推理的 ToolMessage 字符串内容。
系统定位:
``runtime.continue_after_human_action`` 在人工交互分支调用。
可扩展性:
可返回结构化 JSON 字符串,供下游节点解析;支持多语言模板。
"""
tool_call = pending.get("tool_call") or {}
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
text = (instruction or "").strip()
if interaction_type == "approval":
if decision == "reject":
return "用户拒绝"
return "用户同意"
if interaction_type == "selection":
selected = text or args.get("suggested_response") or ""
return f"用户选择:{selected}"
return f"用户补充信息如下:{text}"
+670
View File
@@ -0,0 +1,670 @@
"""ChatQwen:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。
系统定位:
项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担:
- 用户消息预处理(附件、ASR、图片 data URL)
- TTS 输出与音频元数据
- 非标准 tool_call XML/JSON 解析
- Vision 描述接口
依赖:
env_utils 中的 LLM_* / TTS_BASE_URL / ASR_BASE_URL
可扩展性:
- 可子类化覆盖 _preprocess_messages、_create_chat_result
- 流式、重试、限流可在 _generate 层扩展
"""
import io
import os
import tempfile
import wave
import pyaudio
from contextlib import closing
from typing import Any, List, Dict, Optional
from pydantic import Field
import re, json
from datetime import datetime as dt
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall
from langchain_core.outputs import ChatResult, ChatGeneration
from langchain_openai import ChatOpenAI
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT
import agent.utils.env_utils as env_utils
from agent.utils.agent_utils import assistant_text, Documents_process, split_text, merge_wavs
from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
class ChatQwen(ChatOpenAI):
"""Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。
属性:
output_type: 默认输出模式 text | audio | both。
stream_play: TTS 后是否本地播放。
last_audio_kwargs: 最近一次 TTS 的文件路径与时长等元数据。
系统定位:
agents/main_agent 的 llm 与 bind_tools 基底。
可扩展性:
新增厂商响应字段时在 _create_chat_result 中适配。
"""
output_type: str = Field(default="text")
stream_play: bool = Field(default=False)
last_audio_kwargs: Optional[dict] = Field(default=None)
def __init__(self, **kwargs: Any) -> None:
"""从环境变量或 kwargs 初始化 OpenAI 兼容客户端。
输入:
kwargs: 可覆盖 model、api_key、base_url、timeout、max_retries。
输出:
无。
系统定位:
模块级 ``llm = ChatQwen()`` 单例构造。
可扩展性:
可增加 default_headers、proxy 等 ChatOpenAI 参数。
"""
super().__init__(
model=kwargs.pop("model", os.environ.get("LLM_MODEL", "")),
api_key=kwargs.pop("api_key", os.environ.get("LLM_API_KEY", "")),
base_url=kwargs.pop("base_url", os.environ.get("LLM_BASE_URL", "")),
timeout=kwargs.pop("timeout", float(os.environ.get("LLM_TIMEOUT", 60))),
max_retries=kwargs.pop("max_retries", 0),
extra_body={
"chat_template_kwargs": {"enable_thinking": False}
},
**kwargs
)
@staticmethod
def play_audio_bytes(audio_bytes: bytes, chunk_size: int = 1024) -> None:
"""通过 pyaudio 播放 WAV 字节流。
输入: audio_bytes WAV 二进制;chunk_size 播放块大小。
输出: 无。
系统定位: _apply_tts 在 stream_play=True 时调用。
可扩展性: 可换用 sounddevice 或仅写文件不播放。
"""
if not audio_bytes:
return
wf = wave.open(io.BytesIO(audio_bytes), 'rb')
p = pyaudio.PyAudio()
stream = p.open(
format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
)
data = wf.readframes(chunk_size)
while data:
stream.write(data)
data = wf.readframes(chunk_size)
stream.stop_stream()
stream.close()
p.terminate()
wf.close()
@staticmethod
def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]:
"""将 image_url 块中的本地路径或字节转为 data URL。
输入: OpenAI 风格 content part dict。
输出: 处理后的 part 或失败时 text 占位。
系统定位: _preprocess_messages 多模态分支。
可扩展性: 委托 image_utils,保持单一图片处理逻辑。
"""
image_type = part.get("type")
if image_type != "image_url":
return part
image_url = part.get("image_url", {})
url = image_url.get("url", "")
if url.startswith(("http://", "https://", "data:")):
return part
if isinstance(url, str) and os.path.exists(url):
ext = os.path.splitext(url)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
result = image_path_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
if isinstance(url, bytes):
result = image_bytes_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
return part
@staticmethod
def _convert_audio_to_wav(audio_path: str) -> str:
"""非 WAV 音频经 pydub 转为临时 WAV 文件路径。
输入: audio_path 本地音频路径。
输出: WAV 文件路径(可能为临时文件,调用方负责清理)。
系统定位: _audio_to_text 前置格式统一。
可扩展性: 需系统安装 ffmpeg;可缓存转换结果。
"""
ext = os.path.splitext(audio_path)[1].lower()
if ext == ".wav":
return audio_path
try:
from pydub import AudioSegment
except ImportError as exc:
raise RuntimeError("当前 ASR 仅支持 WAV,但未安装 pydub/ffmpeg") from exc
try:
audio = AudioSegment.from_file(audio_path)
except Exception as exc:
raise RuntimeError(f"无法读取音频文件: {exc}") from exc
fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="asr_")
os.close(fd)
try:
audio.export(wav_path, format="wav")
return wav_path
except Exception:
if os.path.exists(wav_path):
os.remove(wav_path)
raise
def _audio_to_text(self, audio_path: str) -> str:
"""调用 ASR 服务将音频转为文本。
输入: audio_path 本地音频文件。
输出: 识别文本字符串。
系统定位: 用户消息含 audio part 时替换为 text。
可扩展性: ASR_BASE_URL 需配置;可支持流式识别。
"""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
wav_path = self._convert_audio_to_wav(audio_path)
cleanup_wav = (wav_path != audio_path)
try:
# 读取音频文件二进制内容
with open(wav_path, "rb") as f:
audio_bytes = f.read()
# 构建 Data URL
import base64
b64 = base64.b64encode(audio_bytes).decode("ascii")
mime_type = "audio/wav" # 我们已经转换为 wav
data_url = f"data:{mime_type};base64,{b64}"
payload = {
"messages": [
{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": data_url}}
]
}
]
}
endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/chat/completions"
resp = requests.post(
endpoint,
headers={"Content-Type": "application/json"},
json=payload,
timeout=120
)
resp.raise_for_status()
result = resp.json()
content = result["choices"][0]["message"]["content"]
return content.strip()
finally:
if cleanup_wav and os.path.exists(wav_path):
os.remove(wav_path)
def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]:
"""入模前统一处理:JSON 列表、附件、ASR、图片、文档解析。
输入: LangChain BaseMessage 列表。
输出: 规范化后的新消息列表(不修改原列表)。
系统定位: _generate 调用 super()._generate 之前的适配层。
可扩展性: 新增 part.type 时在此增加分支。
"""
new_messages = []
for msg in messages:
if isinstance(msg, HumanMessage) and isinstance(msg.content, str):
text = msg.content.strip()
if text.startswith("[") and text.endswith("]"):
try:
possible_list = json.loads(text)
if isinstance(possible_list, list):
msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs)
except (json.JSONDecodeError, ValueError):
pass
if isinstance(msg, HumanMessage) and isinstance(msg.content, dict):
# 尝试从 dict 中提取文本,否则序列化为字符串
text = msg.content.get("text") or msg.content.get("content") or ""
if text:
msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs)
elif isinstance(msg, HumanMessage) and isinstance(msg.content, list):
new_content: List[Dict[str, Any] | str] = []
for part in msg.content:
if isinstance(part, dict):
if part.get("type") == "audio":
audio_path = part.get("audio_path")
if audio_path:
try:
text = self._audio_to_text(audio_path)
new_content.append({"type": "text", "text": text})
except Exception as e:
new_content.append({"type": "text", "text": f"[语音识别失败: {e}]"})
elif part.get("type") == "image_url":
new_content.append(self._process_image_content(part))
elif part.get("type") == "text":
text = (part.get("text") or "").strip()
if text:
new_content.append({"type": "text", "text": text})
elif part.get("path") or (part.get("file") or {}).get("path"):
file_path = part.get("path") or (part.get("file") or {}).get("path")
if isinstance(file_path, str) and os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
normalized_path = file_path.replace("\\", "/")
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
try:
file_text, file_images = Documents_process.process(file_path)
new_content.append({"type": "text", "text": f"用户上传了一个名为\"{normalized_path}\"的文件。"})
if file_text.strip():
new_content.append({
"type": "text",
"text": f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}"
})
for image_bytes in file_images or []:
image_part = image_bytes_to_openai_image_url_part(image_bytes)
if image_part is not None:
new_content.append(image_part)
if not file_text.strip() and not file_images:
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {normalized_path}]"})
except Exception as e:
new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"})
else:
text = assistant_text(part)
if text:
new_content.append({"type": "text", "text": text})
elif isinstance(part, str):
text = part.strip()
if text:
new_content.append(text)
if len(new_content) == 1 and isinstance(new_content[0], str):
msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs)
new_messages.append(msg)
return new_messages
def _text_to_audio(self, text: str) -> bytes:
"""调用 TTS 服务合成语音,长文本分块并行后 merge_wavs。
输入: text 待朗读文本。
输出: WAV 二进制。
系统定位: _apply_tts 核心。
可扩展性: TTS_BASE_URL、并发 max_workers 可配置化。
"""
chunks = split_text(text, min_effective_chars=20)
if len(chunks) == 1:
resp = requests.get(f"{env_utils.TTS_BASE_URL}/tts", params={"text": chunks[0]})
resp.raise_for_status()
return resp.content
# 多块并行请求
audio_parts = [None] * len(chunks)
with ThreadPoolExecutor(max_workers=min(len(chunks), 4)) as pool:
future_to_idx = {
pool.submit(
lambda t: requests.get(
f"{env_utils.TTS_BASE_URL}/tts", params={"text": t}
).content,
chunk
): idx for idx, chunk in enumerate(chunks)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
audio_parts[idx] = future.result()
except Exception as e:
raise RuntimeError(f"TTS request failed for chunk {idx}: {e}")
return merge_wavs(audio_parts)
@staticmethod
def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None:
"""从 WAV 字节计算时长(秒)。
输入: audio_bytes WAV 数据。
输出: 秒数或 None(解析失败)。
系统定位: _apply_tts 写入 last_audio_kwargs.duration_seconds。
可扩展性: 可支持 mp3 等格式。
"""
if not audio_bytes:
return None
try:
with closing(wave.open(io.BytesIO(audio_bytes), "rb")) as wf:
frame_rate = wf.getframerate()
frame_count = wf.getnframes()
if frame_rate <= 0:
return None
return round(frame_count / frame_rate, 2)
except wave.Error:
return None
def _apply_tts(self, result: ChatResult, output_type: str, audio_output_dir: str | None = None) -> ChatResult:
"""根据 output_type 为 AI 回复合成语音并写入会话目录。
输入:
result: ChatResult 含 AIMessage。
output_type: audio | both。
audio_output_dir: WAV 保存目录。
输出:
可能清空 audio 模式下 content 的 ChatResult;设置 last_audio_kwargs。
系统定位:
_generate 在模型返回后的后处理。
可扩展性:
可上传对象存储而非本地路径。
"""
if output_type not in ("audio", "both") or not audio_output_dir:
return result
os.makedirs(audio_output_dir, exist_ok=True)
for idx, gen in enumerate(result.generations):
if not isinstance(gen, ChatGeneration):
continue
msg = gen.message
if not isinstance(msg, AIMessage):
continue
text = assistant_text(msg.content)
if not text.strip():
continue
try:
audio_bytes = self._text_to_audio(text)
timestamp = dt.now().strftime("%H%M%S")
audio_path = os.path.join(audio_output_dir, f"assistant_tts_{timestamp}_{idx}.wav")
with open(audio_path, "wb") as f:
f.write(audio_bytes)
duration_seconds = self._get_wav_duration_seconds(audio_bytes)
# 仅保存到实例属性,不再写入 additional_kwargs
if idx == 0:
self.last_audio_kwargs = {
"audio": {
"sample_rate": 24000,
"format": "wav",
"path": audio_path,
"duration_seconds": duration_seconds,
},
"output_type": output_type,
}
if self.stream_play:
self.play_audio_bytes(audio_bytes)
if output_type == "audio":
msg.content = ""
except Exception as e:
print(f"[TTS ERROR] 合成语音失败: {e}")
# 发生错误时也清空 last_audio_kwargs,避免污染
self.last_audio_kwargs = None
return result
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[Any] = None,
**kwargs: Any,
) -> ChatResult:
"""LangChain 生成入口:预处理 -> API 调用 -> 可选 TTS。
输入:
messages: 对话消息。
stop: 停止词。
run_manager: 含 metadata.output_type、audio_output_dir。
输出:
ChatResult。
系统定位:
所有 invoke/stream 最终经过此方法。
可扩展性:
可在此注入 token 统计回调、链路追踪 span。
"""
metadata = {}
if run_manager:
metadata = getattr(run_manager, 'metadata', {})
output_type = metadata.get("output_type", self.output_type)
audio_output_dir = metadata.get("audio_output_dir")
# 移除自定义参数,防止它们传入 OpenAI API
kwargs.pop("output_type", None)
kwargs.pop("audio_output_dir", None)
messages = self._preprocess_messages(messages)
result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
if output_type in ("audio", "both"):
result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir)
if output_type == "audio":
for gen in result.generations:
if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage):
gen.message.content = ""
return result
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
"""解析 API 响应:reasoning 合并、XML/JSON tool_call 提取。
输入:
response: OpenAI 兼容 API 原始响应。
generation_info: LangChain 生成元信息。
输出:
ChatResult,AIMessage 可能含 tool_calls。
系统定位:
兼容非 OpenAI 标准 tool_calls 格式的关键适配点。
可扩展性:
新增模型格式时在 regex/JSON 分支扩展。
"""
# ---------- 打印原始 tool_calls ----------
try:
raw = response if isinstance(response, dict) else response.model_dump()
print("[RAW_RESPONSE]", json.dumps(raw, indent=2, ensure_ascii=False))
choices = raw.get("choices", [])
if choices:
msg = choices[0].get("message", {})
tool_calls = msg.get("tool_calls")
if tool_calls:
print("[RAW_TOOLCALLS]", json.dumps(tool_calls, indent=2, ensure_ascii=False))
except Exception:
pass
result = super()._create_chat_result(response, generation_info)
try:
dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}})
for i, choice in enumerate(dumped.get("choices") or []):
if i >= len(result.generations):
break
lc_msg = result.generations[i].message
msg = choice.get("message") or {}
ak = lc_msg.additional_kwargs
# 优先处理 reasoning 内容
merged = ""
for key in ("reasoning_content", "reasoning_details", "reasoning"):
merged = assistant_text(msg.get(key))
if merged:
break
if merged:
ak.setdefault("reasoning_content", merged)
raw_content = assistant_text(msg.get("content"))
if not raw_content and merged:
lc_msg.content = merged
elif raw_content:
lc_msg.content = raw_content
content_text = lc_msg.content if isinstance(lc_msg.content, str) else ""
# 匹配 <tool_call>...</tool_call> 块
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', re.DOTALL)
matches = pattern.findall(content_text)
if matches:
tool_calls = []
for idx, match in enumerate(matches):
# 先尝试 JSON 解析(兼容旧格式)
try:
call_data = json.loads(match)
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": call_data.get("name", ""),
"arguments": json.dumps(call_data.get("arguments", {}))
}
})
continue # 解析成功,跳过 XML 尝试
except json.JSONDecodeError:
pass
# 再尝试 XML 解析
func_match = re.search(r'<function=(\w+)>', match)
if not func_match:
continue
func_name = func_match.group(1)
params = {}
for param_match in re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', match, re.DOTALL):
key = param_match.group(1)
value = param_match.group(2).strip()
# 可选:智能类型转换,先保持简单
params[key] = value
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": func_name,
"arguments": json.dumps(params)
}
})
if tool_calls:
ak["tool_calls"] = tool_calls
# 构造 LangChain 的 ToolCall 对象列表
lc_tool_calls = []
for idx, tc in enumerate(tool_calls):
lc_tool_calls.append(
ToolCall(
id=tc["id"],
name=tc["function"]["name"],
args=json.loads(tc["function"]["arguments"])
)
)
lc_msg.tool_calls = lc_tool_calls
# 清理内容中的 XML/JSON 工具调用痕迹
cleaned = pattern.sub("", content_text).strip()
lc_msg.content = cleaned if cleaned else ""
except Exception:
pass
return result
def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str:
"""Vision 便捷接口:单张图片 + 提示词 -> 文本描述。
输入:
image_path_or_url: 路径或 http(s)/data URL。
prompt: 用户侧提示。
输出:
模型描述文本。
系统定位:
工具或测试脚本直接调用,不经 LangGraph。
可扩展性:
可返回结构化 JSON(物体列表等)。
"""
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": image_path_or_url}},
{"type": "text", "text": prompt}
])
]
result = self.invoke(messages)
return result.content
def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str:
"""Vision 便捷接口:内存图片字节 -> 文本描述。
输入:
image_bytes: 图片二进制。
prompt: 提示词。
输出:
描述文本;处理失败返回固定错误文案。
系统定位:
截屏、工具 artifact 等无路径场景。
可扩展性:
与 describe_image 共用 VISION_SYSTEM_PROMPT。
"""
img_part = image_bytes_to_openai_image_url_part(image_bytes)
if img_part is None:
return "图片处理失败"
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[img_part, {"type": "text", "text": prompt}])
]
result = self.invoke(messages)
return result.content
llm = ChatQwen()
def reload_llm() -> None:
"""热重载:重新从环境变量创建 LLM 实例。"""
global llm
llm = ChatQwen()
if __name__ == "__main__":
import sys
# 使用原始字符串避免转义问题
TEST_IMAGE_PATH = r"C:\Users\86166\Desktop\2026-05-22\20260522-DSC01513.jpg"
if not os.path.exists(TEST_IMAGE_PATH):
print(f"❌ 图片不存在: {TEST_IMAGE_PATH}")
sys.exit(1)
# 测试1: 纯图片识别
print("=" * 50)
print("测试1: 纯图片识别(无文本)")
print("=" * 50)
result1 = llm.invoke([
HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}])
])
print(f"返回类型: {type(result1)}")
print(f"返回内容: {result1.content}")
print(f"所有属性: {[attr for attr in dir(result1) if not attr.startswith('_')]}")
# 测试2: 图片 + 文本
print("\n" + "=" * 50)
print("测试2: 图文混合输入")
print("=" * 50)
result2 = llm.invoke([
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}},
{"type": "text", "text": "这张图片里有什么?请详细描述"}
])
])
print(f"回答: {result2.content}")
+533
View File
@@ -0,0 +1,533 @@
"""多 Agent 体系 SupervisorGraph:一主多子 / 主从工作流编排。
系统定位:
为 "一主多子" 和 "主从" 架构提供统一的 LangGraph 图构建,
支持主 Agent 向子 Agent 委派任务,子 Agent 完成后回传结果。
图结构::
START -> supervisor
supervisor -> main_agent | END
main_agent -> (根据 tool_calls 中的 agent_call) 路由到子 agent | supervisor
sub_agent_N -> supervisor (子 agent 完成后回到 supervisor)
Agent 节点签名:
每个 agent 节点接收 MultiAgentState,返回 {"messages": [...], "agent_status": {...}, ...}
可扩展性:
- 子 Agent 可拥有独立图(自定义 build_graph),也可复用 ReAct。
- 可增加并行子 Agent 执行、超时、重试等策略。
"""
from __future__ import annotations
import json
import time
from typing import Any, Callable
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from agent.core.multi_agent_state import (
AgentStatusInfo,
GraphEvent,
MultiAgentState,
ToolCallEvent,
)
from agent.core.nodes import make_call_model_node, process_tool_artifact
from agent.core.routing import should_continue
# ---------- 事件发射 ----------
def _emit_event(state: dict, event: GraphEvent) -> None:
"""向 state 中追加一个图事件。"""
events: list[GraphEvent] = state.setdefault("graph_events", [])
# 从 dict 还原为 GraphEvent 对象
if isinstance(events, list):
events.append(event)
def _update_agent_status(state: dict, name: str, status: str, error_msg: str = "") -> None:
"""更新 state 中指定 agent 的运行状态。"""
agent_status: dict = state.setdefault("agent_status", {})
if name in agent_status:
info = agent_status[name]
if isinstance(info, dict):
info["status"] = status
if error_msg:
info["error_message"] = error_msg
elif isinstance(info, AgentStatusInfo):
info.status = status # type: ignore[misc]
if error_msg:
info.error_message = error_msg
def _record_tool_call(state: dict, agent_name: str, tool_name: str, args: dict, result_preview: str = "", status: str = "success") -> None:
"""记录工具调用到指定 agent 的工具调用历史。"""
history: dict = state.setdefault("tool_calls_history", {})
if agent_name not in history:
history[agent_name] = []
history[agent_name].append(ToolCallEvent(
tool_name=tool_name,
tool_args=args,
result_preview=result_preview[:200] if result_preview else "",
status=status, # type: ignore[arg-type]
timestamp=time.time(),
))
# ---------- Supervisor 节点(调度器) ----------
def make_supervisor_node(
agent_names: list[str],
main_agent_name: str = "main",
) -> Callable[[dict], dict]:
"""创建 supervisor 调度节点。
Supervisor 职责:
1. 首次进入:设定 active_agent 为 main,标记 main 为 running。
2. 子 agent 完成返回:若还有待处理,标记下一个 agent;否则标记 main 为 done。
3. 错误恢复:若子 agent 出错,回主 agent。
输入:
agent_names: 所有 agent 名称列表(含 main)。
main_agent_name: 主 agent 名称。
输出:
符合 LangGraph 节点签名的 supervisor 函数。
"""
sub_agent_names = [n for n in agent_names if n != main_agent_name]
def supervisor(state: dict) -> dict:
now = time.time()
agent_status: dict = state.get("agent_status", {})
messages: list = state.get("messages", [])
# 首次进入
current_active: str | None = state.get("active_agent")
if current_active is None:
# 启动主 agent
_update_agent_status(state, main_agent_name, "running")
_emit_event(state, GraphEvent(
event_type="agent_start",
agent_name=main_agent_name,
timestamp=now,
))
return {
"active_agent": main_agent_name,
"agent_status": agent_status,
"graph_events": state.get("graph_events", []),
}
# 主 agent 执行完毕回来
if current_active == main_agent_name:
# 检查最后一条消息是否含 agent_call(委派子 agent 的标记)
last_msg = messages[-1] if messages else None
delegated_agent = _extract_delegation(last_msg)
if delegated_agent and delegated_agent in sub_agent_names:
# 主 agent 委派子 agent
_update_agent_status(state, main_agent_name, "done")
_emit_event(state, GraphEvent(
event_type="agent_end",
agent_name=main_agent_name,
timestamp=now,
))
_update_agent_status(state, delegated_agent, "running")
_emit_event(state, GraphEvent(
event_type="agent_start",
agent_name=delegated_agent,
timestamp=now,
))
_emit_event(state, GraphEvent(
event_type="message_pass",
source_agent=main_agent_name,
target_agent=delegated_agent,
message_preview=f"主 Agent 委派任务给 {delegated_agent}",
timestamp=now,
))
return {
"active_agent": delegated_agent,
"agent_status": agent_status,
"graph_events": state.get("graph_events", []),
}
else:
# 主 agent 结束,整体完成
_update_agent_status(state, main_agent_name, "done")
_emit_event(state, GraphEvent(
event_type="agent_end",
agent_name=main_agent_name,
timestamp=now,
))
return {
"active_agent": "__end__",
"agent_status": agent_status,
"graph_events": state.get("graph_events", []),
}
# 子 agent 执行完毕回来
if current_active in sub_agent_names:
_update_agent_status(state, current_active, "done")
_emit_event(state, GraphEvent(
event_type="agent_end",
agent_name=current_active,
timestamp=now,
))
_emit_event(state, GraphEvent(
event_type="message_pass",
source_agent=current_active,
target_agent=main_agent_name,
message_preview=f"{current_active} 回传结果给主 Agent",
timestamp=now,
))
# 回到主 agent
_update_agent_status(state, main_agent_name, "running")
_emit_event(state, GraphEvent(
event_type="agent_start",
agent_name=main_agent_name,
timestamp=now,
))
return {
"active_agent": main_agent_name,
"agent_status": agent_status,
"graph_events": state.get("graph_events", []),
}
return {
"active_agent": "__end__",
"agent_status": agent_status,
"graph_events": state.get("graph_events", []),
}
return supervisor
def _extract_delegation(last_msg: Any) -> str | None:
"""从最后一条 AI 消息中提取 agent_call 委派信息。
委派格式:
- 在消息 content 中包含 ``[DELEGATE:agent_name]``
- 或在 tool_calls 中有一条名为 ``agent_call`` 的工具调用
"""
if last_msg is None:
return None
# 方式1: tool_calls 中的 agent_call
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
for tc in last_msg.tool_calls:
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
if tc_name == "agent_call":
args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
return args.get("agent_name") or args.get("agent")
# 方式2: 文本标记
content = getattr(last_msg, "content", "")
if isinstance(content, str) and "[DELEGATE:" in content:
import re
m = re.search(r"\[DELEGATE:(\w+)\]", content)
if m:
return m.group(1)
return None
# ---------- 路由函数 ----------
def supervisor_route(state: dict) -> str:
"""Supervisor 之后的路由:根据 active_agent 决定进入哪个 agent 节点或 END。"""
active = state.get("active_agent", "")
if active == "__end__":
return END
if active:
return active
return END
# ---------- 子 Agent 节点工厂 ----------
def make_sub_agent_node(
agent_graph: Any,
agent_name: str,
tools_by_name: dict[str, Any] | None = None,
) -> Callable[[dict], dict]:
"""创建一个子 Agent 的图节点。
子 Agent 节点:
1. 用自己的图执行推理
2. 发出 agent_start / tool_call / agent_end 事件
3. 更新自己的工具调用历史
输入:
agent_graph: 编译后的子 Agent LangGraph 图。
agent_name: 子 Agent 名称。
tools_by_name: 工具名 -> 工具实例映射(用于记录 tool_call)。
输出:
LangGraph 节点函数。
"""
tools_by_name = tools_by_name or {}
def sub_agent_node(state: dict) -> dict:
now = time.time()
messages: list = state.get("messages", [])
# 标记自己为 running
_update_agent_status(state, agent_name, "running")
_emit_event(state, GraphEvent(
event_type="agent_start",
agent_name=agent_name,
timestamp=now,
))
# 用子 Agent 图执行
try:
result = agent_graph.invoke({"messages": messages})
result_messages: list = result.get("messages", [])
except Exception as e:
_update_agent_status(state, agent_name, "error", str(e))
_emit_event(state, GraphEvent(
event_type="error",
agent_name=agent_name,
message_preview=str(e)[:200],
timestamp=time.time(),
))
return {
"messages": messages,
"agent_status": state.get("agent_status", {}),
"graph_events": state.get("graph_events", []),
"tool_calls_history": state.get("tool_calls_history", {}),
}
# 记录工具调用
for msg in result_messages:
if hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except Exception:
pass
_record_tool_call(state, agent_name, tc_name, tc_args or {})
_emit_event(state, GraphEvent(
event_type="tool_call",
agent_name=agent_name,
tool_name=tc_name,
tool_args=tc_args or {},
timestamp=time.time(),
))
_update_agent_status(state, agent_name, "done")
_emit_event(state, GraphEvent(
event_type="agent_end",
agent_name=agent_name,
timestamp=time.time(),
))
# 追加 agent 标记信息到消息
from langchain_core.messages import AIMessage
final_text = ""
for msg in reversed(result_messages):
if isinstance(msg, AIMessage) and not getattr(msg, "tool_calls", None):
content = msg.content
if isinstance(content, str):
final_text = content
break
tag_msg = AIMessage(
content=f"[AGENT:{agent_name}] {final_text}" if final_text else f"[AGENT:{agent_name}] 任务完成",
additional_kwargs={"agent_name": agent_name},
)
new_messages = list(messages) + list(result_messages) + [tag_msg]
return {
"messages": new_messages,
"agent_status": state.get("agent_status", {}),
"graph_events": state.get("graph_events", []),
"tool_calls_history": state.get("tool_calls_history", {}),
"agent_messages": state.get("agent_messages", {}),
}
return sub_agent_node
# ---------- 主 Agent 节点工厂(多 Agent 版) ----------
def make_main_agent_node(
model_with_tools: Any,
tools: list[Any],
agent_name: str = "main",
tools_by_name: dict[str, Any] | None = None,
) -> Callable[[dict], dict]:
"""创建主 Agent 的图节点(含完整的 ReAct 循环 + 委派能力)。
与单 agent 的 call_model 不同,主 agent 节点内含完整的
agent -> tools -> process_tool_artifact -> agent 循环,
并检测 agent_call 工具调用来委派子 agent。
输入:
model_with_tools: bind_tools 后的 LLM。
tools: 工具列表(含 agent_call 工具)。
agent_name: 主 agent 名称。
tools_by_name: 工具名映射。
输出:
LangGraph 节点函数。
"""
tools_by_name = tools_by_name or {}
def main_agent_node(state: dict) -> dict:
now = time.time()
messages: list = list(state.get("messages", []))
_update_agent_status(state, agent_name, "running")
# 执行完整的 ReAct 循环(限制步数防止无限循环)
max_steps = 50
for step in range(max_steps):
# 调用 LLM
response = model_with_tools.invoke(messages)
messages.append(response)
# 记录工具调用事件
if hasattr(response, "tool_calls") and response.tool_calls:
for tc in response.tool_calls:
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except Exception:
pass
# 检测 agent_call(委派)
if tc_name == "agent_call":
# 不在 ToolNode 中执行,由 supervisor 处理
_record_tool_call(state, agent_name, tc_name, tc_args or {}, "委派子 agent", "pending")
_emit_event(state, GraphEvent(
event_type="tool_call",
agent_name=agent_name,
tool_name=tc_name,
tool_args=tc_args or {},
timestamp=time.time(),
))
_update_agent_status(state, agent_name, "done")
return {
"messages": messages,
"agent_status": state.get("agent_status", {}),
"graph_events": state.get("graph_events", []),
"tool_calls_history": state.get("tool_calls_history", {}),
}
_record_tool_call(state, agent_name, tc_name, tc_args or {})
_emit_event(state, GraphEvent(
event_type="tool_call",
agent_name=agent_name,
tool_name=tc_name,
tool_args=tc_args or {},
timestamp=time.time(),
))
# 执行工具(非 agent_call)
for tc in response.tool_calls:
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
if tc_name == "agent_call":
continue
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", "")
tool = tools_by_name.get(tc_name)
if tool:
try:
result = tool.invoke(tc_args)
result_content = result[0] if isinstance(result, tuple) else str(result)
except Exception as e:
result_content = f"工具执行出错: {e}"
from langchain_core.messages import ToolMessage
messages.append(ToolMessage(
content=str(result_content),
tool_call_id=tc_id or f"tc_{step}",
name=tc_name,
))
_record_tool_call(state, agent_name, tc_name, tc_args or {}, str(result_content)[:200])
_emit_event(state, GraphEvent(
event_type="tool_call",
agent_name=agent_name,
tool_name=tc_name,
tool_args=tc_args or {},
tool_result_preview=str(result_content)[:200],
timestamp=time.time(),
))
else:
# 没有工具调用,主 agent 完成
break
_update_agent_status(state, agent_name, "done")
return {
"messages": messages,
"agent_status": state.get("agent_status", {}),
"graph_events": state.get("graph_events", []),
"tool_calls_history": state.get("tool_calls_history", {}),
}
return main_agent_node
# ---------- 多 Agent 图构建 ----------
def build_multi_agent_graph(
main_model_with_tools: Any,
main_tools: list[Any],
main_tools_by_name: dict[str, Any],
sub_agents: list[dict[str, Any]],
main_agent_name: str = "main",
) -> Any:
"""构建一主多子 SupervisorGraph。
输入:
main_model_with_tools: 主 Agent 的已 bind_tools LLM。
main_tools: 主 Agent 工具列表(含 agent_call)。
main_tools_by_name: 主 Agent 工具名映射。
sub_agents: 子 Agent 配置列表,每项含:
- name: str
- graph: 编译后的子 Agent LangGraph 图
- tools_by_name: dict(可选)
main_agent_name: 主 agent 名称。
输出:
编译后的 LangGraph CompiledGraph。
图结构::
START -> supervisor
supervisor --路由--> main | sub_1 | sub_2 | ... | END
main -> supervisor
sub_N -> supervisor
"""
workflow = StateGraph(MultiAgentState)
# 所有 agent 名称
all_agent_names = [main_agent_name] + [sa["name"] for sa in sub_agents]
# Supervisor 节点
workflow.add_node("supervisor", make_supervisor_node(all_agent_names, main_agent_name))
# 主 Agent 节点
workflow.add_node(
main_agent_name,
make_main_agent_node(main_model_with_tools, main_tools, main_agent_name, main_tools_by_name),
)
# 子 Agent 节点
for sa in sub_agents:
sa_name = sa["name"]
sa_graph = sa["graph"]
sa_tools_by_name = sa.get("tools_by_name", {})
workflow.add_node(
sa_name,
make_sub_agent_node(sa_graph, sa_name, sa_tools_by_name),
)
# 路由
workflow.add_edge(START, "supervisor")
route_map = {name: name for name in all_agent_names}
route_map[END] = END
workflow.add_conditional_edges("supervisor", supervisor_route, route_map)
# 所有 agent 节点完成后回到 supervisor
workflow.add_edge(main_agent_name, "supervisor")
for sa in sub_agents:
workflow.add_edge(sa["name"], "supervisor")
return workflow.compile()
+172
View File
@@ -0,0 +1,172 @@
"""多 Agent 体系共享状态:Agent 运行态、图事件、工具调用记录。
系统定位:
为一主多子 / 主从 Agent 体系提供统一的运行时状态类型,
被 ``multi_agent_graph`` 的 SupervisorGraph 使用。
状态字段:
- messages: 全局对话消息(所有 agent 共享)。
- agent_status: 各 agent 当前运行状态(idle/running/done/error)。
- active_agent: 当前正在执行的 agent 名称。
- graph_events: 图执行事件时间线,供前端 graph 面板渲染。
- tool_calls_history: 按 agent 分组的工具调用记录。
- agent_messages: 按 agent 分组的主-子消息传递记录。
可扩展性:
- 可增加 token 用量统计、执行耗时、错误堆栈等字段。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Annotated, Any, Literal, Sequence, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
# ---------- Agent 运行状态 ----------
AgentStatus = Literal["idle", "running", "done", "error"]
@dataclass
class AgentStatusInfo:
"""单个 Agent 的运行时状态信息。
字段:
name: Agent 名称。
status: 当前状态:idle(等待)/ running(执行中)/ done(已完成)/ error(出错)。
role: main | sub。
icon: 前端展示图标(默认 🧠)。
error_message: 出错时的错误信息。
"""
name: str = ""
status: AgentStatus = "idle"
role: Literal["main", "sub"] = "sub"
icon: str = "🧠"
error_message: str = ""
# ---------- 图事件 ----------
@dataclass
class GraphEvent:
"""图执行过程中的单个事件,供前端 graph 面板实时更新。
字段:
event_type: 事件类型(agent_start / agent_end / tool_call / message_pass / error)。
source_agent: 源 agent 名称(message_pass 时为主 agent)。
target_agent: 目标 agent 名称(message_pass 时为子 agent)。
agent_name: 当前 agent 名称(非 message_pass 事件)。
tool_name: 工具名称(tool_call 事件)。
tool_args: 工具参数(tool_call 事件)。
tool_result_preview: 工具返回摘要(tool_call 事件)。
timestamp: 事件发生时间戳。
message_preview: 消息传递内容摘要。
"""
event_type: Literal["agent_start", "agent_end", "tool_call", "message_pass", "error"] = "agent_start"
source_agent: str = ""
target_agent: str = ""
agent_name: str = ""
tool_name: str = ""
tool_args: dict[str, Any] = field(default_factory=dict)
tool_result_preview: str = ""
timestamp: float = 0.0
message_preview: str = ""
@dataclass
class ToolCallEvent:
"""工具调用记录(持久化到历史)。"""
tool_name: str = ""
tool_args: dict[str, Any] = field(default_factory=dict)
result_preview: str = ""
status: Literal["success", "error", "pending"] = "success"
timestamp: float = 0.0
# ---------- 多 Agent 状态 ----------
class MultiAgentState(TypedDict, total=False):
"""多 Agent 体系共享运行时状态。
字段:
messages: 全局对话消息序列(add_messages 合并)。
agent_status: {agent_name: AgentStatusInfo} 各 agent 运行状态。
active_agent: 当前正在执行的 agent 名称。
graph_events: 图执行事件时间线(追加)。
tool_calls_history: {agent_name: [ToolCallEvent]} 工具调用记录。
agent_messages: {agent_name: [dict]} 各 agent 收发的消息摘要。
user_query: 本轮用户原始查询。
final_response: 最终响应的文本内容。
"""
messages: Annotated[Sequence[BaseMessage], add_messages]
agent_status: dict[str, AgentStatusInfo]
active_agent: str | None
graph_events: list[GraphEvent]
tool_calls_history: dict[str, list[ToolCallEvent]]
agent_messages: dict[str, list[dict[str, Any]]]
user_query: str
final_response: str
# ---------- 状态初始化辅助 ----------
def init_agent_status(
agents: list[dict[str, str]],
) -> dict[str, AgentStatusInfo]:
"""根据 agent 配置列表初始化 agent_status。
输入:
agents: [{"name": "main", "role": "main", "icon": "🧠"}, ...]
输出:
{agent_name: AgentStatusInfo},所有 agent 初始为 idle。
"""
result: dict[str, AgentStatusInfo] = {}
for a in agents:
name = a.get("name", "")
result[name] = AgentStatusInfo(
name=name,
status="idle",
role=a.get("role", "sub"), # type: ignore[arg-type]
icon=a.get("icon", "🧠"),
)
return result
def init_multi_agent_state(
agents: list[dict[str, str]],
messages: list[BaseMessage] | None = None,
user_query: str = "",
) -> dict:
"""创建 MultiAgentState 的初始输入字典。
输入:
agents: agent 配置列表。
messages: 初始消息列表。
user_query: 用户查询文本。
输出:
可直接传入 graph.invoke() 的初始状态字典。
"""
return {
"messages": messages or [],
"agent_status": init_agent_status(agents),
"active_agent": None,
"graph_events": [],
"tool_calls_history": {},
"agent_messages": {},
"user_query": user_query,
"final_response": "",
}
def create_empty_multi_agent_state() -> MultiAgentState:
"""创建空的 MultiAgentState,用于类型标注时的默认值。"""
return MultiAgentState(
messages=[],
agent_status={},
active_agent=None,
graph_events=[],
tool_calls_history={},
agent_messages={},
user_query="",
final_response="",
)
+143
View File
@@ -0,0 +1,143 @@
"""LangGraph 图节点:模型调用与工具产物后处理。
系统定位:
实现 ReAct 循环中的 ``agent`` 节点工厂与 ``process_tool_artifact`` 节点,
被 ``graph_builder`` 注册到 StateGraph。
可扩展性:
- ``make_call_model_node`` 可注入 streaming、重试、用量统计回调。
- ``process_tool_artifact`` 可扩展 audio、文件、结构化 JSON 等 artifact 类型。
"""
from __future__ import annotations
import json
import time
from typing import Any, Callable
from langchain_core.messages import HumanMessage, ToolMessage
from agent.core.state import AgentState
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.tool_call_utils import normalize_tool_call
def make_call_model_node(model_with_tools: Any) -> Callable[[AgentState], dict]:
"""创建调用已绑定工具的 LLM 的图节点闭包。
输入:
model_with_tools: bind_tools 后的 LangChain 聊天模型。
输出:
符合 LangGraph 节点签名的 ``call_model(state) -> {"messages": [AIMessage]}``。
系统定位:
图中 ``agent`` 节点:读取 state.messages,调用模型,追加 AI 响应。
可扩展性:
可包装为 async 节点;可在此统一记录 token 用量到 session_meta。
"""
def call_model(state: AgentState) -> dict:
response = model_with_tools.invoke(state["messages"])
usage = response.usage_metadata if hasattr(response, "usage_metadata") else {}
if usage:
input_tokens = usage.get("input_tokens", "?")
output_tokens = usage.get("output_tokens", "?")
total_tokens = usage.get("total_tokens", "?")
print(f"[LLM] Token使用: input={input_tokens}, output={output_tokens}, total={total_tokens}")
if hasattr(response, "tool_calls") and response.tool_calls:
for tc in response.tool_calls:
tc_normalized = normalize_tool_call(tc)
tool_name = tc_normalized.get("name", "unknown")
tool_args = tc_normalized.get("args", {})
print(f"[LLM] 工具调用: {tool_name}, 参数: {tool_args}")
# 实时记录工具调用
try:
from agent.history.tool_call_recorder import record_tool_call_live, get_current_session
sid = get_current_session()
if sid:
record_tool_call_live(sid, tool_name, tool_args)
except Exception:
pass
return {"messages": [response]}
return call_model
def process_tool_artifact(state: AgentState) -> dict:
last_msg = state["messages"][-1]
if isinstance(last_msg, ToolMessage):
content_preview = str(last_msg.content)
if len(content_preview) > 200:
content_preview = content_preview[:200] + "..."
print(f"[Tool] 工具返回 (id={last_msg.tool_call_id}): {content_preview}")
# 实时记录工具返回结果
try:
from agent.history.tool_call_recorder import record_tool_result_live, get_current_session
sid = get_current_session()
if sid:
img_bytes = None
if last_msg.artifact:
if isinstance(last_msg.artifact, dict) and "image_bytes" in last_msg.artifact:
img_bytes = last_msg.artifact["image_bytes"]
elif isinstance(last_msg.artifact, list):
for item in last_msg.artifact:
if isinstance(item, dict) and "image_bytes" in item:
img_bytes = item["image_bytes"]
break
elif isinstance(item, bytes):
img_bytes = item
break
elif isinstance(last_msg.artifact, bytes):
img_bytes = last_msg.artifact
record_tool_result_live(sid, last_msg.name or "unknown", str(last_msg.content) if last_msg.content else "", img_bytes)
except Exception:
pass
if not isinstance(last_msg, ToolMessage) or not last_msg.artifact:
return {}
artifact = last_msg.artifact
img_bytes_list: list[bytes] = []
if isinstance(artifact, dict) and "image_bytes" in artifact:
img_bytes_list.append(artifact["image_bytes"])
elif isinstance(artifact, list):
for item in artifact:
if isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
elif isinstance(item, bytes):
img_bytes_list.append(item)
elif isinstance(artifact, bytes):
img_bytes_list.append(artifact)
if not img_bytes_list:
return {}
content_parts = []
failed_count = 0
for img_bytes in img_bytes_list:
data_url = image_bytes_to_openai_image_url_part(img_bytes)
if data_url is None:
failed_count += 1
continue
content_parts.append(data_url)
if not content_parts:
human_msg = HumanMessage(
content="工具返回了截图,但图片处理失败,请向用户说明。",
additional_kwargs={"synthetic": True},
)
return {"messages": [human_msg]}
content_parts.append({"type": "text", "text": "上面是工具返回的截图。"})
if failed_count > 0:
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
human_msg = HumanMessage(content=content_parts, additional_kwargs={"synthetic": True})
return {"messages": [human_msg]}
+77
View File
@@ -0,0 +1,77 @@
"""LangGraph 条件路由:工具循环 vs 结束 vs 待确认暂停。
系统定位:
连接 ``graph_builder`` 的条件边与 ``runtime.execute_agent`` 的
后处理逻辑,在模型输出 tool_calls 时决定图走向或提取 UI 待确认元数据。
可扩展性:
- ``should_continue`` 可扩展为多目标路由(如专用 RAG 节点)。
- ``first_pending_tool_meta`` 可支持多个并行待确认项的优先级队列。
"""
from __future__ import annotations
from typing import Any, Literal, Sequence
from langchain_core.messages import BaseMessage
from langgraph.graph import END
from agent.core.approvals import build_approval_meta, build_human_interaction_meta
from agent.core.state import AgentState
from agent.core.tool_policy import classify_tool_execution
from agent.utils.tool_call_utils import normalize_tool_call
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
"""根据最后一条 AI 消息中的 tool_calls 决定下一节点。
输入:
state: 含 messages 的 AgentState。
输出:
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(含需确认工具场景)。
系统定位:
``graph_builder`` 中 agent 节点的 conditional_edges 路由函数。
若任一 tool_call 策略为 confirm,则直接 END,由 runtime 展示确认 UI。
可扩展性:
可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。
"""
last_msg = state["messages"][-1]
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return END
return "tools"
return END
def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) -> dict[str, Any] | None:
"""从图执行结果中提取第一个需 UI 处理的待确认项元数据。
输入:
messages: 图 invoke 返回的完整消息列表。
session_id: 当前会话 ID,写入 PENDING_TOOL_APPROVALS。
输出:
含 ``pending_action`` 的 meta 字典;无待确认项时返回 None。
系统定位:
``runtime.execute_agent`` 在图结束后调用,决定是否追加确认型 assistant 消息。
可扩展性:
可返回列表以支持批量确认;可按工具类型定制 meta 结构。
"""
if not messages:
return None
last_msg = messages[-1]
if not hasattr(last_msg, "tool_calls") or not last_msg.tool_calls:
return None
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if tool_call.get("name") == "human_interaction":
return build_human_interaction_meta(session_id, tool_call, messages)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return build_approval_meta(session_id, tool_call, messages)
return None
+476
View File
@@ -0,0 +1,476 @@
"""Agent 运行时:对话执行、历史持久化与人工确认续跑。
系统定位:
Web 层(如 ``web.ui_session``)与 LangGraph 之间的编排层。
``agents/*`` 组装 ``AgentRuntime`` 实例,本模块提供统一执行 API。
主要流程:
execute_agent: 用户消息 -> summarizer 压缩 -> graph.invoke -> 响应/待确认
continue_after_human_action: 用户确认/拒绝 -> 工具执行或图续跑
可扩展性:
- ``AgentRuntime`` 可增加 checkpointer、tracing、多模态配置。
- 可将 team 模式拆为独立 executor 类。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.messages import messages_from_dict
from langgraph.errors import GraphRecursionError
from agent.core.approvals import PENDING_TOOL_APPROVALS, pending_assistant_message, remove_pending_ui_message
from agent.core.human_interaction import human_interaction_result
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, set_current_turn, set_current_session
from agent.memory.system_prompt import TEAM_MODE_PROMPT
from agent.core.routing import first_pending_tool_meta
from agent.core.tool_policy import classify_tool_execution
from agent.utils.agent_utils import assistant_text, get_history, get_session_meta, summarizer, update_session_meta
from agent.utils.tool_call_utils import normalize_tool_call
from web.ui_session import rewrite_user_message_files_to_session_dir, session_dir, sync_manifest_from_chat_history
AgentMode = Literal["agent", "team"]
@dataclass
class AgentRuntime:
"""单个 Agent 的运行时配置容器。
字段:
name: Agent 标识,用于 session_meta 与日志。
graph: 编译后的 LangGraph。
llm: 原始 LLM 实例(压缩、TTS 等)。
tools: 工具列表。
tools_by_name: 工具名 -> 工具实例,供确认后手动 invoke。
system_prompt: 系统提示词,传入 summarizer。
max_iterations: 最大迭代次数,超过则输出 OOM(默认 200)。
系统定位:
``agents/main_agent`` 等模块的装配结果,传给 execute_agent。
可扩展性:
可增加 default_mode、专属 checkpointer 等字段。
"""
name: str
graph: Any
llm: Any
tools: list[Any]
tools_by_name: dict[str, Any]
system_prompt: str
max_iterations: int = 200
_OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。请简化任务或增加 max_iterations 配置后重试。"
def _invoke_with_recursion_limit(
runtime: AgentRuntime,
graph: Any,
input_data: dict,
config: dict | None = None,
) -> dict:
"""包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。"""
cfg = dict(config or {})
cfg["recursion_limit"] = runtime.max_iterations
try:
return graph.invoke(input_data, config=cfg)
except GraphRecursionError:
print(f"[OOM] Agent '{runtime.name}' 达到最大迭代次数 {runtime.max_iterations},输出 OOM。")
return {"messages": [AIMessage(content=_OOM_MESSAGE)], "_oom": True}
def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None:
"""将本轮用户与助手消息写入 SQLite 聊天历史。
输入:
session_id: 会话 ID;空则跳过。
user_msg: 用户 HumanMessage;含 synthetic 标记时不写入。
assistant_msg: 助手 AIMessage。
输出:
无。
系统定位:
execute_agent 正常回复结束后持久化对话。
可扩展性:
可改为异步写入、双写向量库、按 agent_name 分表。
"""
if not session_id:
return
history = get_history(session_id)
if not user_msg.additional_kwargs.get("synthetic"):
history.add_message(user_msg)
history.add_message(assistant_msg)
def _message_content_equals(left: Any, right: Any) -> bool:
"""宽松比较消息 content,兼容纯文本与多模态列表。"""
if left == right:
return True
return assistant_text(left) == assistant_text(right)
def _pending_action_id(meta: dict[str, Any] | None) -> str | None:
"""从 assistant meta 中提取 pending_action.id。"""
pending_action = (meta or {}).get("pending_action") or {}
return pending_action.get("id")
def _remember_pending_user_content(pending_meta: dict[str, Any], user_content: Any) -> None:
"""将本轮用户输入缓存到 PENDING_TOOL_APPROVALS,供确认续跑时对齐历史。"""
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["user_content"] = user_content
def _persist_messages_from_pending_turn(
session_id: str,
messages: list[Any],
pending: dict[str, Any],
) -> None:
if not session_id or not messages:
return
# 找到本轮用户消息
user_content = pending.get("user_content")
start_index = 0
if user_content is not None:
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
if _message_content_equals(msg.content, user_content):
start_index = idx
break
# 只提取:
# - 用户消息(跳过 synthetic)
# - 最终一条不包含 tool_calls 的助手消息
user_msg = None
final_assistant = None
for msg in messages[start_index:]:
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
user_msg = msg
if isinstance(msg, AIMessage) and not msg.tool_calls:
final_assistant = msg
history = get_history(session_id)
if user_msg:
history.add_message(user_msg)
if final_assistant:
history.add_message(final_assistant)
def _persist_pending_rejection(session_id: str, pending: dict[str, Any], content: str) -> None:
"""将用户拒绝待确认操作这一轮写入历史,避免下一轮上下文断层。"""
prior_messages = messages_from_dict(pending.get("messages") or [])
prior_messages.append(AIMessage(content=content))
_persist_messages_from_pending_turn(session_id, prior_messages, pending)
def _invoke_after_manual_tool_result(
runtime: AgentRuntime,
messages: list[Any],
session_id: str,
*,
max_steps: int = 8,
) -> tuple[list[Any], dict[str, Any] | None]:
"""人工确认后继续推进图,并在遇到新的待确认项时暂停。"""
current_messages = messages
for _ in range(max_steps):
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages}, {"configurable": {"session_id": session_id}})
all_messages = result["messages"]
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
return all_messages, pending_meta
last_msg = all_messages[-1] if all_messages else None
if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls:
return all_messages, None
next_messages = [*all_messages]
progressed = False
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
if classify_tool_execution(tool_name, tool_args) != "direct":
continue
tool = runtime.tools_by_name.get(tool_name or "")
if not tool:
continue
result = tool.invoke(tool_args)
tool_content = result[0] if isinstance(result, tuple) else result
call_id = tool_call.get("id") or f"manual_{session_id}_{len(next_messages)}"
next_messages.append(ToolMessage(content=str(tool_content), tool_call_id=call_id, name=tool_name or "unknown_tool"))
progressed = True
if not progressed:
return all_messages, None
current_messages = next_messages
return current_messages, None
def execute_agent(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
session_id: str = "test",
output_type: str = "text",
agent_mode: AgentMode = "agent",
) -> list[dict[str, Any]]:
"""处理用户新一轮输入,驱动 Agent 图执行并更新 chat_history。
输入:
runtime: Agent 运行时配置。
chat_history: Web UI 聊天历史(dict 列表);末条须为 user。
session_id: 会话 ID,用于历史、文件目录、manifest 同步。
output_type: text | audio | both,传入图 metadata。
agent_mode: agent(默认)| team(占位提示)。
输出:
更新后的 chat_history;可能追加 assistant 消息或 pending_action。
系统定位:
对外主入口,由 Web/API 层在每轮用户发送后调用。
可扩展性:
可返回 generator 支持流式;可注入 middleware 做鉴权、限流。
"""
chat_history = chat_history or []
if not chat_history or chat_history[-1].get("role") != "user":
return chat_history
if session_id:
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
user_content = chat_history[-1]["content"]
if agent_mode == "team":
chat_history.append({
"role": "assistant",
"content": TEAM_MODE_PROMPT,
"meta": {"output_type": "text", "autoplay": False, "agent_mode": "team"},
})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
messages, context_exceeded = summarizer(
session_id=session_id,
agent_name=runtime.name,
system_prompt=runtime.system_prompt,
user_content=user_content,
llm=runtime.llm,
)
if context_exceeded:
chat_history.append({"role": "assistant", "content": "上下文已满,请新建会话继续对话。"})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
graph_config = {
"configurable": {"session_id": session_id},
"metadata": {
"output_type": output_type,
"audio_output_dir": session_dir(session_id) if session_id else None,
},
}
try:
# 开始实时记录(新 turn 隔离旧数据)
turn_id = ""
if session_id:
turn_id = start_live_turn(session_id)
set_current_session(session_id)
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages}, graph_config)
set_current_turn("")
set_current_session("")
all_messages = result["messages"]
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
end_live_turn(session_id)
_remember_pending_user_content(pending_meta, user_content)
chat_history.append(pending_assistant_message(pending_meta))
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
text_content = assistant_text(assistant_msg.content)
usage = assistant_msg.usage_metadata if hasattr(assistant_msg, "usage_metadata") else {}
total_tokens = usage.get("total_tokens", 0) if isinstance(usage, dict) else 0
if total_tokens > 0:
meta = get_session_meta(session_id, runtime.name)
update_session_meta(
session_id=session_id,
agent_name=runtime.name,
cursor_index=meta["cursor_index"],
compressed_content=meta["compressed_content"],
last_total_tokens=total_tokens,
)
output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text")
audio_data = runtime.llm.last_audio_kwargs or {}
if audio_data.get("output_type") != output_type_from_config:
audio_data = {}
assistant_meta = {
"output_type": output_type_from_config,
"autoplay": False,
"audio": audio_data.get("audio"),
}
if output_type_from_config == "audio" and assistant_meta.get("audio"):
assistant_content = []
elif output_type_from_config == "both" and assistant_meta.get("audio"):
assistant_content = text_content
else:
assistant_content = text_content if text_content else "(空回复)"
chat_history.append({"role": "assistant", "content": assistant_content, "meta": assistant_meta})
save_messages_to_history(session_id, HumanMessage(content=user_content), AIMessage(content=text_content))
runtime.llm.last_audio_kwargs = None
# 保存工具调用记录
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": user_content}, all_messages)
except Exception:
import traceback
set_current_turn("")
set_current_session("")
end_live_turn(session_id)
print("[ERROR] 调用模型时出错:")
traceback.print_exc()
chat_history.append({"role": "assistant", "content": "抱歉,我遇到了一些问题,请稍后再试。"})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
def continue_after_human_action(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit"],
instruction: str | None = None,
output_type: str = "text",
) -> list[dict[str, Any]]:
"""用户对待确认项做出决策后,继续执行或结束该轮交互。
输入:
runtime: Agent 运行时。
chat_history: 当前聊天历史。
session_id: 会话 ID。
approval_id: pending_action.id。
decision: approve | reject | edit(edit 用于 human_interaction selection 提交文本)。
instruction: selection 时为用户输入文本。
output_type: 续跑后的回复输出类型。
输出:
更新后的 chat_history。
系统定位:
UI 确认框回调入口;按 pending.type 分支:
human_interaction / tool_call。
可扩展性:
可增加 timeout 自动 reject;支持批量 approve 多个 tool_call。
"""
chat_history = chat_history or []
pending = PENDING_TOOL_APPROVALS.pop(approval_id, None)
if not pending:
chat_history.append({"role": "assistant", "content": "该待确认项已处理或已过期。", "meta": {"output_type": "text"}})
return chat_history
remove_pending_ui_message(chat_history, approval_id)
if decision == "reject" and pending.get("type") != "human_interaction":
content = "已拒绝,本次待确认操作不会执行。"
chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": "text"}})
_persist_pending_rejection(session_id, pending, content)
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
if pending.get("type") == "human_interaction":
try:
tool_call = dict(pending.get("tool_call") or {})
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"human_{approval_id}"
tool_content = human_interaction_result(pending, decision, instruction)
prior_messages.append(ToolMessage(content=tool_content, tool_call_id=call_id, name="human_interaction"))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id)
if pending_meta:
_remember_pending_user_content(pending_meta, pending.get("user_content"))
chat_history.append(pending_assistant_message(pending_meta))
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
persisted_messages = all_messages
except Exception:
import traceback
traceback.print_exc()
content = "处理人工交互结果时遇到问题。"
persisted_messages = messages_from_dict(pending.get("messages") or [])
chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": output_type, "autoplay": False}})
_persist_messages_from_pending_turn(session_id, persisted_messages, pending)
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
tool = runtime.tools_by_name.get(tool_name)
if not tool:
chat_history.append({"role": "assistant", "content": f"找不到工具:{tool_name}", "meta": {"output_type": "text"}})
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
try:
result = tool.invoke(tool_args)
tool_content = result[0] if isinstance(result, tuple) else result
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
prior_messages.append(ToolMessage(content=str(tool_content), tool_call_id=call_id, name=tool_name))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id)
if pending_meta:
_remember_pending_user_content(pending_meta, pending.get("user_content"))
chat_history.append(pending_assistant_message(pending_meta))
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
persisted_messages = all_messages
except Exception:
import traceback
traceback.print_exc()
content = "执行确认后的工具调用时遇到问题。"
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
prior_messages.extend([
ToolMessage(content=content, tool_call_id=call_id, name=tool_name or "unknown_tool"),
AIMessage(content=content),
])
persisted_messages = prior_messages
chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": output_type, "autoplay": False}})
_persist_messages_from_pending_turn(session_id, persisted_messages, pending)
if session_id:
save_turn(session_id, pending.get("user_content") or {}, persisted_messages)
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
+32
View File
@@ -0,0 +1,32 @@
"""LangGraph Agent 状态类型定义。
系统定位:
定义图运行时共享状态 ``AgentState``,被 ``graph_builder``、``nodes``、
``routing`` 作为 StateGraph 的 schema 使用。
可扩展性:
- 可在此增加 ``session_id``、``agent_mode``、``pending_approval`` 等字段,
需配合 reducer(如 add_messages)声明合并策略。
"""
from __future__ import annotations
from typing import Annotated, Sequence, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
"""LangGraph 图状态:消息列表为唯一状态字段。
字段:
messages: 对话消息序列,通过 ``add_messages`` 追加合并。
系统定位:
ReAct 循环(agent -> tools -> process_tool_artifact -> agent)的载体。
可扩展性:
扩展字段时保持 TypedDict 与 reducer 注解一致,避免并发更新冲突。
"""
messages: Annotated[Sequence[BaseMessage], add_messages]
+58
View File
@@ -0,0 +1,58 @@
[
{
"name": "web_search_class",
"display_name": "WebSearch",
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "date_query_class",
"display_name": "DateQuery",
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "screen_capture",
"display_name": "ScreenCapture",
"description": "截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "browser_control",
"display_name": "BrowserControl",
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "human_interaction",
"display_name": "HumanInteraction",
"description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。",
"permission": "confirm",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "software_control",
"display_name": "SoftwareControl",
"description": "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths,建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "directory_listing",
"display_name": "DirectoryListing",
"description": "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
}
]
+144
View File
@@ -0,0 +1,144 @@
"""工具执行安全策略:直接执行 vs 需人工确认。
系统定位:
在 ``core/routing.should_continue`` 中决定图是否进入 ToolNode,
或在遇到需确认工具时提前结束图执行,交由 UI 与 ``runtime`` 处理。
自动执行规则 (auto_execute_rules):
对于权限为 ``confirm`` 的工具,可通过 tool_config.json 中的
``auto_execute_rules`` 定义参数条件,匹配时自动升级为 ``direct``。
每条规则格式: ``{"parameter": "action", "operator": "equals", "value": "query"}``
含义: 当工具调用的 ``action`` 参数等于 ``"query"`` 时,跳过确认直接执行。
可扩展性:
- 可增加 operator 类型(contains、in、regex)。
- 可返回 ``Literal["direct", "confirm", "deny"]`` 支持完全禁止类工具。
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Literal
_FORCE_CONFIRM_TOOLS: ContextVar[bool] = ContextVar("force_confirm_tools", default=False)
# 从 tool_config.json 加载的自动执行规则缓存
_AUTO_EXECUTE_RULES_CACHE: dict[str, list[dict]] | None = None
# 从 tool_config.json 加载的工具权限缓存
_TOOL_PERMISSIONS_CACHE: dict[str, str] | None = None
def _get_tool_permissions() -> dict[str, str]:
"""懒加载 tool_config.json 中各工具的 permission 字段。
返回:
{tool_name: "direct"|"confirm"} 映射;加载失败返回空字典。
"""
global _TOOL_PERMISSIONS_CACHE
if _TOOL_PERMISSIONS_CACHE is not None:
return _TOOL_PERMISSIONS_CACHE
try:
from agent.core.config_manager import load_tool_configs
configs = load_tool_configs()
_TOOL_PERMISSIONS_CACHE = {}
for c in configs:
if c.enabled:
_TOOL_PERMISSIONS_CACHE[c.name] = c.permission
except Exception:
_TOOL_PERMISSIONS_CACHE = {}
return _TOOL_PERMISSIONS_CACHE
def _get_auto_execute_rules() -> dict[str, list[dict]]:
"""懒加载 tool_config.json 中的 auto_execute_rules。
返回:
{tool_name: [rule, ...]} 映射;加载失败返回空字典。
"""
global _AUTO_EXECUTE_RULES_CACHE
if _AUTO_EXECUTE_RULES_CACHE is not None:
return _AUTO_EXECUTE_RULES_CACHE
try:
from agent.core.config_manager import load_tool_configs
configs = load_tool_configs()
_AUTO_EXECUTE_RULES_CACHE = {}
for c in configs:
if c.enabled and c.auto_execute_rules:
_AUTO_EXECUTE_RULES_CACHE[c.name] = c.auto_execute_rules
except Exception:
_AUTO_EXECUTE_RULES_CACHE = {}
return _AUTO_EXECUTE_RULES_CACHE
def reload_auto_execute_rules() -> None:
"""清除缓存,下次 classify 时重新从 JSON 加载。"""
global _AUTO_EXECUTE_RULES_CACHE, _TOOL_PERMISSIONS_CACHE
_AUTO_EXECUTE_RULES_CACHE = None
_TOOL_PERMISSIONS_CACHE = None
@contextmanager
def force_confirm_tools():
token = _FORCE_CONFIRM_TOOLS.set(True)
try:
yield
finally:
_FORCE_CONFIRM_TOOLS.reset(token)
def _match_auto_execute_rule(rule: dict, args: dict) -> bool:
"""检查工具调用的 args 是否匹配单条自动执行规则。
输入:
rule: {"parameter": str, "operator": str, "value": str}
args: 工具调用的参数 dict。
输出:
匹配则返回 True。
支持的 operator:
- "equals": 参数值(转为字符串后)完全等于 rule value。
"""
param = rule.get("parameter", "")
op = rule.get("operator", "equals")
expected = str(rule.get("value", ""))
actual = str(args.get(param, ""))
if op == "equals":
return actual == expected
return False
def classify_tool_execution(tool_name: str | None, args: dict) -> Literal["direct", "confirm"]:
"""判定工具调用是否可在图内自动执行。
判定优先级:
1. 空名称 / force_confirm / human_interaction → 强制 confirm
2. tool_config.json 中 permission == "direct" → direct
3. tool_config.json 中 auto_execute_rules 匹配 → direct
4. 其余 → confirm
注意:
参数提取时使用安全默认值,避免 KeyError 或 None 导致的误判。
"""
if not tool_name:
return "confirm"
if _FORCE_CONFIRM_TOOLS.get():
return "confirm"
# human_interaction 必须人工确认,不可绕过
if tool_name == "human_interaction":
return "confirm"
# 读取 tool_config.json 中的权限(含 safe_tools 默认 direct,用户可改)
permissions = _get_tool_permissions()
if permissions.get(tool_name) == "direct":
return "direct"
# 检查 JSON 配置的 auto_execute_rules
rules = _get_auto_execute_rules().get(tool_name)
if rules:
for rule in rules:
if _match_auto_execute_rule(rule, args):
return "direct"
# 未匹配默认需确认
return "confirm"
Binary file not shown.
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "\"C:\\Users\\86166\\Desktop\\rag_test\",帮我把这个文件夹下的文档全部归入rag个人知识库"
}
]
},
"assistant": {
"text": "已成功将 `C:\\Users\\86166\\Desktop\\rag_test` 文件夹下的所有文档(共4个文件)添加到您的RAG个人知识库中,系统共提取并添加了11条知识块。\n\n包含的文件如下:\n- 曾嘉翔-用户画像.docx\n- 朱振宁-用户画像.docx\n- 赵永顺-今日行程安排.docx\n- 赵永顺-用户画像.docx\n\n如果您需要查询这些内容或进行其他操作,请随时告诉我!",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,56 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "赵永顺今天上午吃了什么"
}
]
},
"assistant": {
"text": "我无法知道赵永顺今天上午吃了什么,因为我不了解他的个人生活细节,也没有相关的实时信息。如果您是他本人或认识他,可以直接询问他本人。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "你可以去rag库查一查"
}
]
},
"assistant": {
"text": "根据工具返回的文本信息,赵永顺今天上午10点5分醒来后,吃了**两个酱肉包**。\n\n之后他进行了rag开发测试,并计划下午剪辑照片、召开讨论和分享会。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "那他的系统架构是什么样的呢"
}
]
},
"assistant": {
"text": "根据这张截图,赵永顺的系统架构是一个名为 **`chatbot_agent`** 的编排系统,其核心是一个多模态智能体,主要包含以下几个部分:\n\n1. **用户层 (User Layer)**\n * **Gradio 桌面 UI**: 提供桌面应用程序的交互界面。\n * **FastAPI Web 静态前端**: 提供基于Web的交互界面。\n * 这两个前端都作为用户入口,与核心的 `chatbot_agent` 进行交互。\n\n2. **chatbot_agent 编排 (Orchestration)**\n 这是系统的核心处理逻辑,具体流程如下:\n * **用户输入**: 接收来自 `MultimodalTextbox` 或 `Chatbot` 的消息。\n * **消息记录**: 通过 `add_message` 将用户消息写入对话记录。\n * **代理执行 (`execute_agent`)**: 这是核心逻辑处理单元。\n * **图片处理**: 首先判断输入中是否包含图片。\n * **有图片**: 调用 `describe_images` (基于 Qwen-VL 多模态模型) 生成图片描述 (`image_description`)。\n * **无图片**: 直接使用纯文本输入。\n * **输入组合**: 将图片描述(如果有)和用户查询 (`query`) 组合成纯文本 (`chain_input_from_image_and_query`)。\n * **文件处理**: 用户上传的文件会被复制到 `history/multi_inputs/(session_id)/` 目录下。\n * **上下文组合**: 将图片路径、图片描述和用户查询组合成最终的输入 (`chain_input`)。\n * **历史会话管理**:\n * **读取历史**: 从 SQLite 数据库中读取会话历史 (`summarize_history`)。\n * **摘要与截断**: 如果消息数量超过8条,系统会将旧消息生成一个对话摘要,并只保留最近的8条消息 (`chat_history`)。\n * **构建完整上下文**: 将系统提示词 (包含助手人设和摘要占位符) 和对话历史 (`chat_history`) 组合在一起 (`assign`)。\n * **绑定历史**: 使用 `RunnableWithMessageHistory` 将构建好的上下文与历史会话绑定,并准备将新的交互写回数据库。\n\n3. **模型与外部 (Model & External)**\n * 系统通过一个 **ChatOpenAI 兼容客户端** 与外部的 **Qwen35 网关** (以 OpenAI API 风格) 进行交互,调用大语言模型。\n\n4. **持久化 (Persistence)**\n * **SQLite**: 用于存储 `chat_history.db` 和 `message_store`。\n * **ui_messages.json**: 存储会话清单。\n * **multi_inputs 附件目录**: 存储用户上传的文件。\n\n**总结来说**,这是一个结构清晰的智能体应用架构,它通过LangChain进行编排,能够处理多模态输入(文本和图片),智能地管理长对话历史(通过摘要和截断),并持久化存储所有会话和附件信息,最终通过API调用外部的大模型来生成回复。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,39 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "赵永顺是谁,简要介绍一下"
}
]
},
"assistant": {
"text": "赵永顺通常指的是中国著名的**话剧导演**、**表演艺术家**,也是**北京人民艺术剧院**(北京人艺)的资深导演和演员。\n\n以下是关于他的简要介绍:\n\n1. **身份背景**:他是北京人民艺术剧院的导演、演员,也是中国戏剧家协会会员。他在北京人艺工作多年,是该院艺术创作的中坚力量之一。\n2. **代表作品**:\n * **导演作品**:他执导过多部具有影响力的话剧,包括《天下第一楼》(复排)、《小井胡同》(复排)、《万家灯火》、《全家福》、《玩家》等。其中,《天下第一楼》和《万家灯火》等作品深受观众喜爱,展现了老北京的风土人情和时代变迁。\n * **表演作品**:作为演员,他也曾在多部人艺经典剧目中担任重要角色,如《茶馆》、《雷雨》等。\n3. **艺术风格**:赵永顺的导演风格细腻、严谨,擅长挖掘人物内心,注重舞台调度的生活化和真实感。他继承和发扬了北京人艺“现实主义”的优良传统,致力于塑造鲜活、立体的中国老百姓形象。\n4. **贡献**:他在传承北京人艺艺术风格、培养青年演员以及推动中国话剧发展方面做出了重要贡献。\n\n*注:如果您查询的是其他同名人物(如某位学者、地方官员或特定领域的专家),请提供更多背景信息,以便我进行更精准的查询。*",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "去rag库中找一下赵永顺的信息再回复我"
}
]
},
"assistant": {
"text": "根据您提供的截图(`chatbot_agent` 编排流程图)以及RAG库中检索到的信息,这里的“赵永顺”并非指北京人艺的导演,而是**山东大学信息科学与工程学院的一位优秀学生**。\n\n以下是基于RAG库内容的详细介绍:\n\n**1. 基本身份**\n* **姓名**:赵永顺\n* **院校/专业**:山东大学(青岛)信息科学与工程学院,2022级**电子科学与技术**专业本科生。\n* **核心标签**:顶尖学术型、**双国奖得主**(连续两年获得国家奖学金)、学生工作骨干。\n\n**2. 学业与荣誉**\n* **学业表现**:专业排名前列,是学院“榜样的力量”学院榜样人物。\n* **核心荣誉**:\n * 2023—2024、2024—2025 连续两年获得**国家奖学金**。\n * 2025年青岛市千名优秀大学生。\n * 信息学院优秀共青团员。\n* **专业方向**:电子电路基础、半导体物理扎实,适配半导体、微电子、集成电路方向。\n\n**3. 能力与特质**\n* **硬技能**:掌握C/C++、Python、数据处理、学术文档撰写及科研辅助实验。\n* **软技能**:严谨自律、执行力强、沟通高效、抗压能力强。\n* **性格**:沉稳务实、目标导向、低调肯干,属于典型的“科研/技术潜力股”。\n\n**4. 近期动态与项目(结合截图信息)**\n* **项目参与**:根据截图内容,赵永顺近期正在参与 **chatbot_agent**(智能体)的**RAG(检索增强生成)开发测试**。\n * 该截图详细展示了该Agent的架构,包括用户层(Gradio/FastAPI)、核心编排逻辑(图片处理、上下文记忆管理、历史摘要)、以及持久化存储(SQLite)。\n * 这表明他具备较强的工程落地能力和对前沿AI技术(如多模态处理、LangChain编排)的掌握。\n* **行程安排**:\n * 近期计划包括解决代码Bug、模型训练、参数调优等**技术攻坚**工作。\n * 正在完善论文结构、制作答辩PPT,准备**学术输出**。\n * 曾计划与曾嘉翔、朱振宁、刘殿阳等同学进行成果分享会。\n\n**总结**\n这里的赵永顺是一位在**微电子/半导体**领域有深厚积累,同时具备**AI工程开发能力**的山东大学顶尖本科生。他不仅在学术上成绩斐然(双国奖),还积极投身于具体的技术研发项目(如截图中的chatbot_agent系统开发),是典型的复合型技术人才。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,49 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "file",
"relpath": "20260527_215121/英语演讲——我的故事_20260527_215121_1.pdf",
"name": "英语演讲——我的故事.pdf"
},
{
"type": "text",
"text": "帮我再精简一下"
}
]
},
"assistant": {
"text": "这份演讲稿的核心情感非常真挚,但在语法、用词准确性和口语流畅度上还有提升空间。原稿中存在一些中式英语表达(如 \"throw reading\" 应为 \"through reading\")、重复用词以及部分句子结构松散的问题。\n\n以下是为您精简并润色后的版本,保留了原意,但语言更地道、有力,更适合演讲:\n\n### 精简润色版 (Polished & Concise Version)\n\n**Title: My Story and the Chinese Dream**\n\nGood morning, everyone.\n\nIf you ask me, \"What is the Chinese Dream?\" I would tell you: \"It is a dream belonging to all Chinese people.\"\n\nIt may seem distant, but let me explain it through my own story.\n\nI come from Liaocheng, once one of the poorest cities in Shandong. Growing up, we were told to be shrewd and vigilant because survival was a daily struggle. While city children were reading and writing, we farmers' kids spent our days working in the fields. We wanted to change our lives through education, but the opportunity was out of reach. Back then, my only dream was to leave and never return.\n\nHowever, things have changed. Today, our country encourages young people to return home and contribute. Success stories are bringing knowledge and hope back to our hometowns. Although I am away now, I will return again and again to dedicate my strength, my heart, and everything I have to make my hometown better. As the saying goes, \"He who has been through the rain wants to hold an umbrella for others.\"\n\nA profound realization has hit me: the ground beneath us is our hometown, called **China**. The dream within us is our dream, called the **Chinese Dream**.\n\nDo you see it? The Chinese Dream is neither mysterious nor distant. Unlike the self-serving actions some nations have taken in the name of democracy, our dream is simple: to widen our roads, clear our skies, refresh our air, and bring happiness to our people.\n\nThis is the Chinese Dream in my heart: a dream of peace and harmony. And it is a dream shared by people all over the world.\n\nThank you.\n\n---\n\n### 主要修改点说明 (Key Changes):\n\n1. **修正语法与拼写**:\n * \"throw reading\" -> \"through education\" (更正式且准确)。\n * \"cit ies\" -> \"cities\", \"glib\" (圆滑通常含贬义) -> \"shrewd\" (精明) 或直接调整语境。\n * \"donate my body, my blood...\" -> \"dedicate my strength, my heart...\" (原表达在英语中听起来过于血腥或医疗化,改为“奉献”更得体)。\n\n2. **提升流畅度与节奏**:\n * 将 \"never never never back again\" 简化为 \"never return\",在演讲中更有力。\n * 将 \"More and more people who succeed in their domains...\" 简化为 \"Success stories are bringing...\",减少冗余。\n\n3. **优化对比表达**:\n * 关于美国/西方的部分,原句 \"in Garza\" 可能是拼写错误或指代不明(可能是指 Gaza 加沙,或者是特定的地缘政治事件,但在演讲中容易引发歧义或过于具体)。修改版将其概括为 \"self-serving actions some nations have taken...\",既保留了原意(反对以民主自由为名的损人利己),又让表达更通用、安全且有力。\n\n4. **强化结尾**:\n * 将 \"road wider, sky bluer...\" 的排比句保留并理顺,使其朗朗上口。\n\n这个版本更适合口语表达,听众更容易抓住重点,同时保留了演讲者从贫困到奋斗再到回馈家乡的感人逻辑。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "image_url",
"relpath": "20260527_215121/后端建议_20260527_215121_1.png",
"name": "后端建议.png"
},
{
"type": "text",
"text": "帮我总结一下这张图片的内容"
}
]
},
"assistant": {
"text": "这张图片是一份关于**后端编程语言推荐**的个人建议清单,内容如下:\n\n**标题**:后端编程语言推荐 \n**副标题**:以下仅代表我个人建议\n\n**具体建议**:\n\n1. **Java**:如果你打算未来深耕 Web 后端开发,推荐选择 Java。理由是 Java 在国内 Web 开发领域的地位非常稳固,难以撼动。\n2. **Node.js**:如果你是前端程序员,想转行做后端,推荐选择 Node.js(因为前后端技术栈接近,上手快)。\n3. **Python**:除上述两种情况外,其他场景都推荐 Python。其最大优点是**简单、效率高**。\n4. **PHP**:明确建议“千万不要学 PHP”,表达了一种强烈的否定态度。\n\n✅ 总结: \n这是一份带有强烈个人倾向的后端语言选择指南,核心逻辑是:\n- 想稳扎稳打做企业级 Web 后端 → 选 Java;\n- 前端转后端 → 选 Node.js;\n- 其他通用或快速开发场景 → 选 Python;\n- PHP → 不推荐。\n\n⚠️ 注意:图中明确标注“仅代表我个人建议”,说明这不是权威结论,而是作者基于自身经验或市场观察给出的主观推荐。\n\n(注:虽然图中未提及 Go、C#、Rust 等现代后端语言,但这是“个人建议”,并非全面技术选型指南。)",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

@@ -1,27 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "file",
"relpath": "20260527_215307/22级-25年优秀学生干部_20260527_215307_1.xlsx",
"name": "22级-25年优秀学生干部.xlsx"
},
{
"type": "text",
"text": "帮我提取内容并加以说明"
}
]
},
"assistant": {
"text": "这份文件是一份关于**22级学生评选2025年优秀学生干部**的名单汇总表。\n\n### 📋 内容提取与整理\n\n该表格共包含9个班级/项目,每个单位评选出了一名**校级**优秀学生干部和一名**院级**优秀学生干部。具体名单如下:\n\n| 班级/项目 | 校级优秀学生干部 | 院级优秀学生干部 |\n| :--- | :--- | :--- |\n| **通信一班** | 刘和宝 | 郭思宇 |\n| **通信二班** | 董卓然 | 王雨萌 |\n| **通信三班** | 朱洪亿 | 李爽爽 |\n| **电科一班** | 李皓威 | 邱浩原 |\n| **电科二班** | 赵永顺 | 王子瑶 |\n| **光信** | 纪天泽 | 温致涵 |\n| **光创** | 王志成 | 曹晶晶 |\n| **微纳** | 王隆昊 | 张加祥 |\n| **崇新** | 庞开升 | 刘希言 |\n\n---\n\n### 💡 说明与分析\n\n1. **评选层级**:\n * **校级**:代表该班级/项目中最优秀的学生干部,将代表学院参加学校层面的评选或表彰。\n * **院级**:代表学院内部评选出的优秀学生干部,是校级评选的后备或并列荣誉。\n\n2. **涉及专业/班级**:\n * 名单涵盖了**通信工程**(分三个班)、**电子信息科学与技术**(分两个班)以及**光电信息**相关方向(光信、光创、微纳、崇新等创新班或项目班)。这表明该评选主要面向电子信息类相关专业的大二学生(22级)。\n\n3. **文件命名含义**:\n * `22级-25年优秀学生干部`:表明这是针对2022级入学学生,在2025年进行的优秀学生干部评选(通常评选周期可能跨越学年,或指2025年度的荣誉)。\n * `20260527_215307`:文件生成的时间戳显示为 **2026年5月27日 21:53:07**。这暗示该文件可能是基于未来时间设定的模拟数据,或者是系统时间设置在未来/测试环境下的产物。\n\n4. **数据用途**:\n * 该表格通常用于公示评选结果、录入教务系统、制作荣誉证书或作为档案留存。\n\n如果您需要将这些数据转换为特定的格式(如CSV、JSON)或进行统计分析,请随时告诉我!",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,39 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,39 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "帮我打开b站"
}
]
},
"assistant": {
"text": "<tool_call>\n<function=browser_control>\n<parameter=action>\nopen\n</parameter>\n<parameter=url>\nhttps://www.bilibili.com\n</parameter>\n</function>\n</tool_call>",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "现在几点了"
}
]
},
"assistant": {
"text": "<tool_call>\n<function=date_query_class>\n</function>\n</tool_call>",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -1,39 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "现在几点了"
}
]
},
"assistant": {
"text": "现在是 2026年6月3日 17:56:36。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "帮我打开b站和虎牙"
}
]
},
"assistant": {
"text": "好的!从截图中可以看到:\n\n1. **B站已经打开了**:第5个标签页显示的是\"啊哈啊哈! - bilibili\"\n2. **虎牙也打开了**:当前页面正在浏览虎牙直播网站,可以看到虎牙的Logo、导航栏(首页、直播、分类、视频、游戏等),以及正在播放的直播内容和右侧的推荐直播间\n\n两个网站都已经成功打开。当前显示的是虎牙直播页面,如果您想查看B站,可以点击第5个标签页切换到B站。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
+263
View File
@@ -0,0 +1,263 @@
"""工具调用历史记录器:按 session/turn 存储及实时推送。
设计:
每次用户输入 → 新建一个 turn,图节点执行期间通过 live 字典实时推送,
前端轮询显示;turn 结束后 persist 到 JSON 并清除 live 记录。
不同 turn 间数据完全隔离,不会泄漏到新对话中。
"""
from __future__ import annotations
import json
import os
import base64
import time
import threading
from contextvars import ContextVar
from datetime import datetime
from pathlib import Path
from typing import Any
from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, ToolMessage
_HISTORY_ROOT = Path(__file__).resolve().parent
TOOL_CALLING_ROOT = _HISTORY_ROOT / "tool_calling"
# ---------- 当前 turn 上下文 ----------
_current_turn_id: ContextVar[str] = ContextVar("current_turn_id", default="")
_current_session_id: ContextVar[str] = ContextVar("current_session_id", default="")
# ---------- 实时记录(线程安全) ----------
_live_lock = threading.Lock()
_live_records: dict[str, list[dict[str, Any]]] = {} # key = session_id
def set_current_turn(turn_id: str) -> None:
_current_turn_id.set(turn_id)
def get_current_turn() -> str:
return _current_turn_id.get()
def set_current_session(session_id: str) -> None:
_current_session_id.set(session_id)
def get_current_session() -> str:
return _current_session_id.get()
def start_live_turn(session_id: str) -> str:
"""开始一个新 turn 并返回 turn_id。清除该 session 旧 live 记录。"""
turn_id = datetime.now().strftime("%Y%m%d_%H%M%S")
set_current_turn(turn_id)
with _live_lock:
_live_records[session_id] = []
return turn_id
def record_tool_call_live(session_id: str, name: str, args: dict[str, Any]) -> None:
"""图节点中调用:记录工具调用开始。"""
with _live_lock:
if session_id not in _live_records:
_live_records[session_id] = []
_live_records[session_id].append({
"name": name,
"args_summary": _short_args(args),
"status": "running",
"result_text": "",
"timestamp": time.time(),
})
def record_tool_result_live(session_id: str, name: str, result_text: str,
image_bytes: bytes | None = None) -> None:
"""图节点中调用:记录工具返回结果。"""
with _live_lock:
records = _live_records.get(session_id, [])
for rec in reversed(records):
if rec["name"] == name and rec["status"] == "running":
rec["status"] = "done"
rec["result_text"] = (result_text or "")[:500]
if image_bytes:
rec["image_base64"] = base64.standard_b64encode(image_bytes).decode("ascii")
return
def end_live_turn(session_id: str) -> list[dict[str, Any]]:
"""结束实时记录并返回该 turn 所有记录。"""
set_current_turn("")
with _live_lock:
return _live_records.pop(session_id, [])
def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]:
"""轮询接口:当前执行中 turn 的实时工具调用列表。"""
with _live_lock:
return list(_live_records.get(session_id, []))
def _short_args(args: dict[str, Any], max_len: int = 40) -> str:
s = json.dumps(args, ensure_ascii=False)
if len(s) > max_len:
return s[:max_len] + "..."
return s
# ---------- 持久化 ----------
def _ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def session_tool_dir(session_id: str) -> Path:
return TOOL_CALLING_ROOT / session_id
def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[str, Any]]:
"""从图执行后的消息列表中提取完整的工具调用链。"""
records: list[dict[str, Any]] = []
pending: dict[str, dict[str, Any]] = {}
for msg in messages:
if isinstance(msg, AIMessage) and msg.tool_calls:
for tc in msg.tool_calls:
d = tc if isinstance(tc, dict) else {"name": tc.name, "args": tc.args, "id": tc.id}
if d.get("id"):
pending[d["id"]] = {
"name": d.get("name", "unknown"),
"args": _json_safe(d.get("args", {})),
"result_text": "",
"result_files": [],
}
elif isinstance(msg, ToolMessage) and msg.tool_call_id:
cid = msg.tool_call_id
if cid in pending:
entry = pending.pop(cid)
entry["result_text"] = str(msg.content) if msg.content else ""
if msg.artifact:
entry["result_files"] = _save_artifact_files(msg.artifact, msg.tool_call_id)
records.append(entry)
for entry in pending.values():
records.append(entry)
return records
def _json_safe(value: Any) -> Any:
try:
json.dumps(value, ensure_ascii=False)
return value
except TypeError:
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return str(value)
def _save_artifact_files(artifact: Any, base_name: str) -> list[str]:
files: list[str] = []
if isinstance(artifact, dict) and "image_bytes" in artifact:
files.append(f"_artifact_{base_name}_0.png")
elif isinstance(artifact, list):
for i, item in enumerate(artifact):
if isinstance(item, dict) and "image_bytes" in item:
files.append(f"_artifact_{base_name}_{i}.png")
elif isinstance(item, bytes):
files.append(f"_artifact_{base_name}_{i}.png")
elif isinstance(artifact, bytes):
files.append(f"_artifact_{base_name}.png")
return files
def _find_artifact_bytes(messages: list[BaseMessage], fname: str) -> bytes | None:
for msg in messages:
if isinstance(msg, ToolMessage) and msg.artifact:
a = msg.artifact
if isinstance(a, dict) and "image_bytes" in a:
return a["image_bytes"]
if isinstance(a, list):
for item in a:
if isinstance(item, dict) and "image_bytes" in item:
return item["image_bytes"]
if isinstance(item, bytes):
return item
if isinstance(a, bytes):
return a
return None
def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any],
messages: list[BaseMessage]) -> str | None:
"""持久化一轮完整的工具调用记录。"""
tool_calls = extract_tool_calls_from_messages(messages)
if not tool_calls:
return None
assistant_response = ""
for msg in reversed(messages):
if isinstance(msg, AIMessage) and not msg.tool_calls:
assistant_response = msg.content if isinstance(msg.content, str) else str(msg.content)
break
session_dir = session_tool_dir(session_id)
turn_dir = session_dir / turn_id
_ensure_dir(turn_dir)
for tc in tool_calls:
for fname in list(tc.get("result_files", [])):
img_bytes = _find_artifact_bytes(messages, fname)
if img_bytes:
(turn_dir / fname).write_bytes(img_bytes)
else:
tc["result_files"].remove(fname)
record = {
"session_id": session_id,
"turn_id": turn_id,
"user_message": _json_safe(user_message),
"tool_calls": tool_calls,
"assistant_response": assistant_response,
"timestamp": turn_id,
}
json_path = session_dir / f"turn_{turn_id}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
return str(json_path)
def list_turns(session_id: str) -> list[dict[str, Any]]:
"""列出会话下所有 turn(按时间倒序,最近5轮)。"""
session_dir = session_tool_dir(session_id)
if not session_dir.is_dir():
return []
turns = []
for f in sorted(session_dir.glob("turn_*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
turns.append(json.load(fh))
except (json.JSONDecodeError, OSError):
pass
return turns[:5]
def get_latest_turn(session_id: str) -> dict[str, Any] | None:
"""获取最近一个 turn 的完整记录。"""
turns = list_turns(session_id)
return turns[0] if turns else None
def get_tool_calls_for_latest_turn(session_id: str) -> list[dict[str, Any]]:
"""获取最近一个 turn 的工具调用(供 UI 渲染)。"""
latest = get_latest_turn(session_id)
if not latest:
return []
turn_id = latest.get("turn_id", "")
result = []
for tc in latest.get("tool_calls", []):
entry = dict(tc)
full_files = []
for fname in tc.get("result_files", []):
full_files.append(str(session_tool_dir(session_id) / turn_id / fname))
entry["result_files_full"] = full_files
entry["turn_id"] = turn_id
result.append(entry)
return result
+2 -488
View File
@@ -1,489 +1,3 @@
# agent/llm.py
import io
import os
import tempfile
import wave
import pyaudio
from contextlib import closing
from typing import Any, List, Dict, Optional
from pydantic import Field
import re, json
from datetime import datetime as dt
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall
from langchain_core.outputs import ChatResult, ChatGeneration
from langchain_openai import ChatOpenAI
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, TTS_BASE_URL, ASR_BASE_URL
from agent.utils.agent_utils import assistant_text, Documents_process, split_text, merge_wavs
from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
class ChatQwen(ChatOpenAI):
output_type: str = Field(default="text")
stream_play: bool = Field(default=False)
last_audio_kwargs: Optional[dict] = Field(default=None)
def __init__(self, **kwargs: Any) -> None:
super().__init__(
model=kwargs.pop("model", LLM_MODEL),
api_key=kwargs.pop("api_key", LLM_API_KEY),
base_url=kwargs.pop("base_url", LLM_BASE_URL),
timeout=kwargs.pop("timeout", LLM_TIMEOUT),
max_retries=kwargs.pop("max_retries", 0),
**kwargs
)
@staticmethod
def play_audio_bytes(audio_bytes: bytes, chunk_size: int = 1024) -> None:
if not audio_bytes:
return
wf = wave.open(io.BytesIO(audio_bytes), 'rb')
p = pyaudio.PyAudio()
stream = p.open(
format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
)
data = wf.readframes(chunk_size)
while data:
stream.write(data)
data = wf.readframes(chunk_size)
stream.stop_stream()
stream.close()
p.terminate()
wf.close()
@staticmethod
def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]:
image_type = part.get("type")
if image_type != "image_url":
return part
image_url = part.get("image_url", {})
url = image_url.get("url", "")
if url.startswith(("http://", "https://", "data:")):
return part
if isinstance(url, str) and os.path.exists(url):
ext = os.path.splitext(url)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
result = image_path_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
if isinstance(url, bytes):
result = image_bytes_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
return part
@staticmethod
def _convert_audio_to_wav(audio_path: str) -> str:
ext = os.path.splitext(audio_path)[1].lower()
if ext == ".wav":
return audio_path
try:
from pydub import AudioSegment
except ImportError as exc:
raise RuntimeError("当前 ASR 仅支持 WAV,但未安装 pydub/ffmpeg") from exc
try:
audio = AudioSegment.from_file(audio_path)
except Exception as exc:
raise RuntimeError(f"无法读取音频文件: {exc}") from exc
fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="asr_")
os.close(fd)
try:
audio.export(wav_path, format="wav")
return wav_path
except Exception:
if os.path.exists(wav_path):
os.remove(wav_path)
raise
def _audio_to_text(self, audio_path: str) -> str:
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
wav_path = self._convert_audio_to_wav(audio_path)
cleanup_wav = (wav_path != audio_path)
try:
# 读取音频文件二进制内容
with open(wav_path, "rb") as f:
audio_bytes = f.read()
# 构建 Data URL
import base64
b64 = base64.b64encode(audio_bytes).decode("ascii")
mime_type = "audio/wav" # 我们已经转换为 wav
data_url = f"data:{mime_type};base64,{b64}"
payload = {
"messages": [
{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": data_url}}
]
}
]
}
endpoint = f"{ASR_BASE_URL.rstrip('/')}/chat/completions"
resp = requests.post(
endpoint,
headers={"Content-Type": "application/json"},
json=payload,
timeout=120
)
resp.raise_for_status()
result = resp.json()
content = result["choices"][0]["message"]["content"]
return content.strip()
finally:
if cleanup_wav and os.path.exists(wav_path):
os.remove(wav_path)
def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]:
new_messages = []
for msg in messages:
if isinstance(msg, HumanMessage) and isinstance(msg.content, str):
text = msg.content.strip()
if text.startswith("[") and text.endswith("]"):
try:
possible_list = json.loads(text)
if isinstance(possible_list, list):
msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs)
except (json.JSONDecodeError, ValueError):
pass
if isinstance(msg, HumanMessage) and isinstance(msg.content, dict):
# 尝试从 dict 中提取文本,否则序列化为字符串
text = msg.content.get("text") or msg.content.get("content") or ""
if text:
msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs)
elif isinstance(msg, HumanMessage) and isinstance(msg.content, list):
new_content: List[Dict[str, Any] | str] = []
for part in msg.content:
if isinstance(part, dict):
if part.get("type") == "audio":
audio_path = part.get("audio_path")
if audio_path:
try:
text = self._audio_to_text(audio_path)
new_content.append({"type": "text", "text": text})
except Exception as e:
new_content.append({"type": "text", "text": f"[语音识别失败: {e}]"})
elif part.get("type") == "image_url":
new_content.append(self._process_image_content(part))
elif part.get("type") == "text":
text = (part.get("text") or "").strip()
if text:
new_content.append({"type": "text", "text": text})
elif part.get("path") or (part.get("file") or {}).get("path"):
file_path = part.get("path") or (part.get("file") or {}).get("path")
if isinstance(file_path, str) and os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
normalized_path = file_path.replace("\\", "/")
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
try:
file_text, file_images = Documents_process.process(file_path)
new_content.append({"type": "text", "text": f"用户上传了一个名为\"{normalized_path}\"的文件。"})
if file_text.strip():
new_content.append({
"type": "text",
"text": f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}"
})
for image_bytes in file_images or []:
image_part = image_bytes_to_openai_image_url_part(image_bytes)
if image_part is not None:
new_content.append(image_part)
if not file_text.strip() and not file_images:
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {normalized_path}]"})
except Exception as e:
new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"})
else:
text = assistant_text(part)
if text:
new_content.append({"type": "text", "text": text})
elif isinstance(part, str):
text = part.strip()
if text:
new_content.append(text)
if len(new_content) == 1 and isinstance(new_content[0], str):
msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs)
new_messages.append(msg)
return new_messages
def _text_to_audio(self, text: str) -> bytes:
chunks = split_text(text, min_effective_chars=20)
if len(chunks) == 1:
resp = requests.get(f"{TTS_BASE_URL}/tts", params={"text": chunks[0]})
resp.raise_for_status()
return resp.content
# 多块并行请求
audio_parts = [None] * len(chunks)
with ThreadPoolExecutor(max_workers=min(len(chunks), 4)) as pool:
future_to_idx = {
pool.submit(
lambda t: requests.get(
f"{TTS_BASE_URL}/tts", params={"text": t}
).content,
chunk
): idx for idx, chunk in enumerate(chunks)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
audio_parts[idx] = future.result()
except Exception as e:
raise RuntimeError(f"TTS request failed for chunk {idx}: {e}")
return merge_wavs(audio_parts)
@staticmethod
def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None:
if not audio_bytes:
return None
try:
with closing(wave.open(io.BytesIO(audio_bytes), "rb")) as wf:
frame_rate = wf.getframerate()
frame_count = wf.getnframes()
if frame_rate <= 0:
return None
return round(frame_count / frame_rate, 2)
except wave.Error:
return None
def _apply_tts(self, result: ChatResult, output_type: str, audio_output_dir: str | None = None) -> ChatResult:
if output_type not in ("audio", "both") or not audio_output_dir:
return result
os.makedirs(audio_output_dir, exist_ok=True)
for idx, gen in enumerate(result.generations):
if not isinstance(gen, ChatGeneration):
continue
msg = gen.message
if not isinstance(msg, AIMessage):
continue
text = assistant_text(msg.content)
if not text.strip():
continue
try:
audio_bytes = self._text_to_audio(text)
timestamp = dt.now().strftime("%H%M%S")
audio_path = os.path.join(audio_output_dir, f"assistant_tts_{timestamp}_{idx}.wav")
with open(audio_path, "wb") as f:
f.write(audio_bytes)
duration_seconds = self._get_wav_duration_seconds(audio_bytes)
# 仅保存到实例属性,不再写入 additional_kwargs
if idx == 0:
self.last_audio_kwargs = {
"audio": {
"sample_rate": 24000,
"format": "wav",
"path": audio_path,
"duration_seconds": duration_seconds,
},
"output_type": output_type,
}
if self.stream_play:
self.play_audio_bytes(audio_bytes)
if output_type == "audio":
msg.content = ""
except Exception as e:
print(f"[TTS ERROR] 合成语音失败: {e}")
# 发生错误时也清空 last_audio_kwargs,避免污染
self.last_audio_kwargs = None
return result
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[Any] = None,
**kwargs: Any,
) -> ChatResult:
metadata = {}
if run_manager:
metadata = getattr(run_manager, 'metadata', {})
output_type = metadata.get("output_type", self.output_type)
audio_output_dir = metadata.get("audio_output_dir")
# 移除自定义参数,防止它们传入 OpenAI API
kwargs.pop("output_type", None)
kwargs.pop("audio_output_dir", None)
messages = self._preprocess_messages(messages)
result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
if output_type in ("audio", "both"):
result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir)
if output_type == "audio":
for gen in result.generations:
if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage):
gen.message.content = ""
return result
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
# ---------- 打印原始 tool_calls ----------
try:
raw = response if isinstance(response, dict) else response.model_dump()
print("[RAW_RESPONSE]", json.dumps(raw, indent=2, ensure_ascii=False))
choices = raw.get("choices", [])
if choices:
msg = choices[0].get("message", {})
tool_calls = msg.get("tool_calls")
if tool_calls:
print("[RAW_TOOLCALLS]", json.dumps(tool_calls, indent=2, ensure_ascii=False))
except Exception:
pass
result = super()._create_chat_result(response, generation_info)
try:
dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}})
for i, choice in enumerate(dumped.get("choices") or []):
if i >= len(result.generations):
break
lc_msg = result.generations[i].message
msg = choice.get("message") or {}
ak = lc_msg.additional_kwargs
# 优先处理 reasoning 内容
merged = ""
for key in ("reasoning_content", "reasoning_details", "reasoning"):
merged = assistant_text(msg.get(key))
if merged:
break
if merged:
ak.setdefault("reasoning_content", merged)
raw_content = assistant_text(msg.get("content"))
if not raw_content and merged:
lc_msg.content = merged
elif raw_content:
lc_msg.content = raw_content
content_text = lc_msg.content if isinstance(lc_msg.content, str) else ""
# 匹配 <tool_call>...</tool_call> 块
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', re.DOTALL)
matches = pattern.findall(content_text)
if matches:
tool_calls = []
for idx, match in enumerate(matches):
# 先尝试 JSON 解析(兼容旧格式)
try:
call_data = json.loads(match)
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": call_data.get("name", ""),
"arguments": json.dumps(call_data.get("arguments", {}))
}
})
continue # 解析成功,跳过 XML 尝试
except json.JSONDecodeError:
pass
# 再尝试 XML 解析
func_match = re.search(r'<function=(\w+)>', match)
if not func_match:
continue
func_name = func_match.group(1)
params = {}
for param_match in re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', match, re.DOTALL):
key = param_match.group(1)
value = param_match.group(2).strip()
# 可选:智能类型转换,先保持简单
params[key] = value
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": func_name,
"arguments": json.dumps(params)
}
})
if tool_calls:
ak["tool_calls"] = tool_calls
# 构造 LangChain 的 ToolCall 对象列表
lc_tool_calls = []
for idx, tc in enumerate(tool_calls):
lc_tool_calls.append(
ToolCall(
id=tc["id"],
name=tc["function"]["name"],
args=json.loads(tc["function"]["arguments"])
)
)
lc_msg.tool_calls = lc_tool_calls
# 清理内容中的 XML/JSON 工具调用痕迹
cleaned = pattern.sub("", content_text).strip()
lc_msg.content = cleaned if cleaned else ""
except Exception:
pass
return result
def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str:
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": image_path_or_url}},
{"type": "text", "text": prompt}
])
]
result = self.invoke(messages)
return result.content
def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str:
img_part = image_bytes_to_openai_image_url_part(image_bytes)
if img_part is None:
return "图片处理失败"
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[img_part, {"type": "text", "text": prompt}])
]
result = self.invoke(messages)
return result.content
llm = ChatQwen()
if __name__ == "__main__":
import sys
# 使用原始字符串避免转义问题
TEST_IMAGE_PATH = r"C:\Users\86166\Desktop\4.png"
if not os.path.exists(TEST_IMAGE_PATH):
print(f"❌ 图片不存在: {TEST_IMAGE_PATH}")
sys.exit(1)
# 测试1: 纯图片识别
print("=" * 50)
print("测试1: 纯图片识别(无文本)")
print("=" * 50)
result1 = llm.invoke([
HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}])
])
print(f"返回类型: {type(result1)}")
print(f"返回内容: {result1.content}")
print(f"所有属性: {[attr for attr in dir(result1) if not attr.startswith('_')]}")
# 测试2: 图片 + 文本
print("\n" + "=" * 50)
print("测试2: 图文混合输入")
print("=" * 50)
result2 = llm.invoke([
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}},
{"type": "text", "text": "这张图片里有什么?请详细描述"}
])
])
print(f"回答: {result2.content}")
from agent.core.llm import ChatQwen, llm
__all__ = ["ChatQwen", "llm"]
+9 -4
View File
@@ -5,7 +5,7 @@ MAIN_SYSTEM_PROMPT = """你是一个多模态助手,能够直接理解用户
- 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。
- 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。
- 违反上述规则是一种严重错误。
"""
""".strip()
SUMMARY_SYSTEM_PROMPT ="""
你是一个名为“Summary Agent”的专业摘要助手,专门负责对用户提供的文本内容进行高度凝练、准确的总结。
@@ -34,7 +34,7 @@ SUMMARY_SYSTEM_PROMPT ="""
你输出:“某公司于2026年5月发布了新一代量子芯片,性能较前代提升10倍,预计2027年量产。此举被业界视为量子计算商业化的关键突破,同时引发了对加密安全的讨论。”
现在,请以 Summary Agent 的身份,完成用户的摘要请求。
"""
""".strip()
VISION_SYSTEM_PROMPT = """
@@ -44,7 +44,7 @@ VISION_SYSTEM_PROMPT = """
如果用户提供了多张图片,请分别编号描述(如「图1」「图2」等),便于后续问答引用。
看不清的内容请如实说明,不要臆测。
其次请认真回答用户的问题,结合图片内容进行分析推理,给出具体、简短、清晰、实用的回复;
"""
""".strip()
DB_SYSTEM_PROMT = """
你是一位敏捷的数据库分析助手,能够高效利用内置功能帮助用户从PostgreSQL数据库中获取答案。
@@ -65,4 +65,9 @@ DB_SYSTEM_PROMT = """
5. **非数据库问题快速响应**:遇到问候、常识性问题或超出数据库范围的内容时,可以直接友好回应,不必强制调用功能。
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
"""
""".strip()
TEAM_MODE_PROMPT = """
team 模式目前仅作为多 agent 协同的预留模式,尚未实现。
请简短说明当前无法启动 team 协同,并建议用户改用 agent 模式。
""".strip()
+26 -22
View File
@@ -1,34 +1,38 @@
from .tool import *
# from .tool_rag import RAGTool
# from .tool_database import *
# from utils.database_utils import PostgreSQL_Manager
# 创建数据库管理器实例
# connection = "postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller"
# connection = "sqlite:///Agent/src/agent/history/data_base/chat_history.db"
# _db_manager = PostgreSQL_Manager(connection)
# # 创建工具实例
# _all_database_tools = [
# Websearchclass(),
# DateQueryClass(),
# ListTableComment(db_manager=_db_manager),
# ListTableSchema(db_manager=_db_manager),
# SQLQuery(db_manager=_db_manager),
# SQLQueryChecker(db_manager=_db_manager)
# ]
# def get_sql_tools():
# return _all_database_tools
from .tool_software_control import SoftwareControl
from .tool_rag import RAGTool
_all_tools = [
WebSearch(),
DateQuery(),
ScreenCapture(),
BrowserControl(),
# RAGTool(),
HumanInteraction(),
SoftwareControl(),
RAGTool(),
DirectoryListing(),
TodoList(),
]
# 注册所有可用工具名称(供前端配置面板展示),从 _all_tools 实例推导
_ALL_AVAILABLE_TOOL_CLASSES = {tool.name: type(tool) for tool in _all_tools}
def get_tools():
"""返回当前 Agent 注册的全部 LangChain 工具列表。"""
return _all_tools
def get_all_available_tool_names():
"""返回所有可用工具名称(含数据库工具,不管当前是否实例化)。"""
return list(_ALL_AVAILABLE_TOOL_CLASSES.keys())
def get_all_available_tool_descriptions():
"""返回所有可用工具的名称和描述映射(用于前端展示)。"""
result = {}
for name, cls in _ALL_AVAILABLE_TOOL_CLASSES.items():
inst = cls()
desc = getattr(inst, "description", "")
result[name] = (cls.__name__, (desc or "").split("\n")[0][:200])
return result
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more