* feat(web): add PageEmpty component
* feat(web): add PageTabs component
* feat(web): add PageEmpty component
* feat(web): add PageTabs component
* feat(prompt): add history tracking for prompt releases
* feat(web): add prompt menu
* refactor: The PageScrollList component supports two generic parameters
* feat(web): BodyWrapper compoent update PageLoading
* feat(web): add Ontology menu
* feat(web): memory management add scene
* feat(tasks): add celery task configuration for periodic jobs
- Add ignore_result=True to prevent storing results for periodic tasks
- Set max_retries=0 to skip failed periodic tasks without retry attempts
- Configure acks_late=False for immediate acknowledgment in beat tasks
- Add time_limit and soft_time_limit to regenerate_memory_cache task (3600s/3300s)
- Add time_limit and soft_time_limit to workspace_reflection_task (300s/240s)
- Add time_limit and soft_time_limit to run_forgetting_cycle_task (7200s/7000s)
- Improve task reliability and resource management for scheduled jobs
* feat(sandbox): add Node.js code execution support to sandbox
* Release/v0.2.2 (#260)
* [modify] migration script
* [add] migration script
* fix(web): change form message
* fix(web): the memoryContent field is compatible with numbers and strings
* feat(web): code node hidden
* fix(model):
1. create a basic model to check if the name and provider are duplicated.
2. The result shows error models because the provider created API Keys for all matching models.
---------
Co-authored-by: Mark <zhuwenhui5566@163.com>
Co-authored-by: zhaoying <yzhao96@best-inc.com>
Co-authored-by: yingzhao <zhaoyingyz@126.com>
Co-authored-by: Timebomb2018 <18868801967@163.com>
* Feature/ontology class clean (#249)
* [add] Complete ontology engineering feature implementation
* [add] Add ontology feature integration and validation utilities
* [add] Add OWL validator and validation utilities
* [fix] Add missing render_ontology_extraction_prompt function
* [fix]Add dependencies, fix functionality
* [add] migration script
* feat(celery): add dedicated periodic tasks worker and queue (#261)
* fix(web): conflict resolve
* Fix/v022 bug (#263)
* [fix]Fix the issue of inconsistent language in explicit and episodic memory.
* [fix]Fix the issue of inconsistent language in explicit and episodic memory.
* [add]Add scene_id
* [fix]Based on the AI review to fix the code
* Fix/develop memory reflex (#265)
* 遗漏的历史映射
* 遗漏的历史映射
* 反思后台报错处理
* [add] migration script
* fix: chat conversation_id add node_start
* feat(web): show code node
* fix(web): Restructure the CustomSelect component, repair the interface that is called multiple times when the form is updated
* feat(web): RadioGroupCard support block mode
* feat(web): create space add icon
* feat(app and model): token consumption statistics
* Add/develop memory (#264)
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 遗漏的历史映射
* 新增长期记忆功能
* 新增长期记忆功能
* 新增长期记忆功能
* 知识库检索多余字段
* 长期
* feat(app and model): token consumption statistics of the cluster
* memory_BUG_fix
* fix(web): prompt history remove pageLoading
* fix(prompt): remove hard-coded import of prompt file paths (#279)
* Fix/develop memory bug (#274)
* 遗漏的历史映射
* 遗漏的历史映射
* fix_timeline_memories
* fix(web): update retrieve_type key
* Fix/develop memory bug (#276)
* 遗漏的历史映射
* 遗漏的历史映射
* fix_timeline_memories
* fix_timeline_memories
* write_gragp/bug_fix
* write_gragp/bug_fix
* write_gragp/bug_fix
* chore(celery): disable periodic task scheduling
* fix(prompt): remove hard-coded import of prompt file paths
---------
Co-authored-by: lixinyue11 <94037597+lixinyue11@users.noreply.github.com>
Co-authored-by: zhaoying <yzhao96@best-inc.com>
Co-authored-by: yingzhao <zhaoyingyz@126.com>
Co-authored-by: Ke Sun <kesun5@illinois.edu>
* fix(web): remove delete confirm content
* refactor(workflow): relocate template directory into workflow
* feat(memory): add long-term storage task routing and batching
* fix(web): PageScrollList loading update
* fix(web): PageScrollList loading update
* Ontology v1 bug (#291)
* [changes]Add 'id' as the secondary sorting key, and 'scene_id' now returns a UUID object
* [fix]Fix the "end_user" return to be sorted by update time.
* [fix]Set the default values of the memory configuration model based on the spatial model.
* [fix]Remove the entity extraction check combination model, read the configuration list, and add the return of scene_id
* [fix]Fix the "end_user" return to be sorted by update time.
* [fix]
* fix(memory): add Redis session validation
- Add macOS fork() safety configuration in celery_app.py to prevent initialization issues
- Add null/False checks for Redis session queries in term_memory_save to handle missing sessions gracefully
- Add null/False checks in memory_long_term_storage to prevent processing empty Redis results
- Add null/False checks in aggregate_judgment before format_parsing to avoid errors on missing data
- Initialize redis_messages variable in window_dialogue for consistency
- Add debug logging when no existing session found in Redis for better troubleshooting
- Add TODO comments for magic numbers (scope=6, time=5) to be extracted as constants
- Improve error handling when Redis returns False or empty results instead of crashing
* fix(web): PageScrollList style update
* fix(workflow): fix argument passing in code execution nodes
* fix(web): prompt add disabled
* fix(web): space icon required
* feat(app): modify the key of the token
* fix(fix the key of the app's token):
* fix(workflow): switch code input encoding to base64+URL encoding
* [add]The main project adds multi-API Key load balancing.
* [changes]Attribute security access, secure numerical conversion, unified use of local variables
* fix(web): save add session update
* fix(web): language editor support paste
* [changes]Active status filtering logic, API Key selection strategy
* memory_BUG
* memory_BUG_long_term
* [changes]
* memory_BUG_long_term
* memory_BUG_long_term
* Fix/release memory bug (#306)
* memory_BUG_fix
* memory_BUG
* memory_BUG_long_term
* memory_BUG_long_term
* memory_BUG_long_term
* knowledge_retrieval/bug/fix
* knowledge_retrieval/bug/fix
* knowledge_retrieval/bug/fix
* [fix]1.The "read_all_config" interface returns "scene_name";2.Memory configuration for lightweight query ontology scenarios
* fix(web): replace code editor
* [changes]Modify the description of the time for the recent event
* [changes]Modify the code based on the AI review
* feat(web): update memory config ontology api
* fix(web): ui update
* knowledge_retrieval/bug/fix
* knowledge_retrieval/bug/fix
* knowledge_retrieval/bug/fix
* feat(workflow): add token usage statistics for question classifier and parameter extraction
* feat(web): move prompt menu
* Multiple independent transactions - single transaction
* Multiple independent transactions - single transaction
* Multiple independent transactions - single transaction
* Multiple independent transactions - single transaction
* Write Missing None (#321)
* Write Missing None
* Write Missing None
* Write Missing None
* Apply suggestion from @sourcery-ai[bot]
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Write Missing None
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Fix/release memory bug (#324)
* Write Missing None
* Write Missing None
* Write Missing None
* Apply suggestion from @sourcery-ai[bot]
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Write Missing None
* redis update
* redis update
* redis update
* redis update
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Fix/writer memory bug (#326)
* [fix]Fix the bug
* [fix]Fix the bug
* [fix]Correct the direction indication.
* fix(web): markdown table ui update
* Fix/release memory bug (#332)
* Write Missing None
* Write Missing None
* Write Missing None
* Apply suggestion from @sourcery-ai[bot]
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Write Missing None
* redis update
* redis update
* redis update
* redis update
* writer_dup_bug/fix
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Fix/fact summary (#333)
* [fix]Disable the contents related to fact_summary
* [fix]Disable the contents related to fact_summary
* [fix]Modify the code based on the AI review
* Fix/release memory bug (#335)
* Write Missing None
* Write Missing None
* Write Missing None
* Apply suggestion from @sourcery-ai[bot]
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Write Missing None
* redis update
* redis update
* redis update
* redis update
* writer_dup_bug/fix
* writer_graph_bug/fix
* writer_graph_bug/fix
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Revert "feat(web): move prompt menu"
This reverts commit 9e6e8f50f8.
* fix(web): ui update
* fix(web): update text
* fix(web): ui update
* fix(model): change the "vl" model type of dashscope to "chat"
* fix(model): change the "vl" model type of dashscope to "chat"
---------
Co-authored-by: zhaoying <yzhao96@best-inc.com>
Co-authored-by: Eternity <1533512157@qq.com>
Co-authored-by: Mark <zhuwenhui5566@163.com>
Co-authored-by: yingzhao <zhaoyingyz@126.com>
Co-authored-by: Timebomb2018 <18868801967@163.com>
Co-authored-by: 乐力齐 <162269739+lanceyq@users.noreply.github.com>
Co-authored-by: lixinyue11 <94037597+lixinyue11@users.noreply.github.com>
Co-authored-by: lixinyue <2569494688@qq.com>
Co-authored-by: Eternity <61316157+myhMARS@users.noreply.github.com>
Co-authored-by: lanceyq <1982376970@qq.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
961 lines
41 KiB
Python
961 lines
41 KiB
Python
"""Handoffs 服务 - 基于 LangGraph 的多 Agent 协作"""
|
||
import json
|
||
import uuid
|
||
from typing import List, Dict, Any, Optional, AsyncGenerator, Annotated
|
||
from typing_extensions import TypedDict
|
||
|
||
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, AIMessageChunk
|
||
from langgraph.graph import StateGraph, START, END
|
||
from langgraph.types import Command
|
||
from langgraph.checkpoint.memory import MemorySaver
|
||
from langchain_core.tools import tool
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
import operator
|
||
|
||
from app.core.logging_config import get_business_logger
|
||
from app.core.models import RedBearLLM, RedBearModelConfig
|
||
from app.models.models_model import ModelType
|
||
from app.services.model_service import ModelApiKeyService
|
||
|
||
logger = get_business_logger()
|
||
|
||
|
||
# ==================== Reducer 函数 ====================
|
||
|
||
def replace_value(current, new):
|
||
"""替换值的 reducer - 总是使用新值"""
|
||
return new
|
||
|
||
|
||
# ==================== 状态定义 ====================
|
||
|
||
class HandoffState(TypedDict):
|
||
"""Handoff 状态"""
|
||
messages: Annotated[List[BaseMessage], operator.add] # 消息列表追加
|
||
active_agent: Annotated[Optional[str], replace_value]
|
||
handoff_count: Annotated[int, replace_value]
|
||
handoff_history: Annotated[List[str], replace_value]
|
||
pending_question: Annotated[Optional[str], replace_value]
|
||
previous_answer: Annotated[Optional[str], replace_value]
|
||
|
||
|
||
# ==================== 常量 ====================
|
||
|
||
MAX_HANDOFFS = 5 # 最大 handoff 次数
|
||
|
||
|
||
# ==================== 工具输入模型 ====================
|
||
|
||
class TransferInput(BaseModel):
|
||
"""转移工具的输入参数"""
|
||
reason: str = Field(description="转移原因,说明为什么需要转交")
|
||
unhandled_question: str = Field(
|
||
description="需要转交给其他专家处理的具体问题。注意:只转交你无法回答的部分,不要转交整个原始问题"
|
||
)
|
||
your_answer: str = Field(
|
||
default="",
|
||
description="你已经回答的内容摘要(如果有的话)。如果你已经回答了部分问题,在这里简要说明"
|
||
)
|
||
|
||
|
||
# ==================== 工具创建 ====================
|
||
|
||
def create_transfer_tool(target_agent: str, description: str):
|
||
"""动态创建转移工具
|
||
|
||
Args:
|
||
target_agent: 目标 Agent 名称
|
||
description: 工具描述
|
||
|
||
Returns:
|
||
转移工具函数
|
||
"""
|
||
tool_name = f"transfer_to_{target_agent}"
|
||
|
||
@tool(tool_name, args_schema=TransferInput)
|
||
def transfer_tool(reason: str, unhandled_question: str, your_answer: str = "") -> Command:
|
||
"""动态生成的转移工具
|
||
|
||
Args:
|
||
reason: 转移原因
|
||
unhandled_question: 需要转交的具体问题(只转交未处理的部分)
|
||
your_answer: 你已经回答的内容摘要
|
||
"""
|
||
return Command(
|
||
goto=target_agent,
|
||
update={
|
||
"active_agent": target_agent,
|
||
"pending_question": unhandled_question, # 存储要转交的具体问题
|
||
"previous_answer": your_answer, # 存储之前的回答
|
||
# handoff_count 和 handoff_history 在 agent_node 中更新
|
||
},
|
||
)
|
||
|
||
transfer_tool.__doc__ = description
|
||
transfer_tool.description = description
|
||
return transfer_tool
|
||
|
||
|
||
def create_tools_for_agent(agent_name: str, configs: Dict) -> List:
|
||
"""根据 Agent 配置动态创建其可用的转移工具
|
||
|
||
Args:
|
||
agent_name: 当前 Agent 名称
|
||
configs: Agent 配置字典
|
||
|
||
Returns:
|
||
该 Agent 可用的工具列表
|
||
"""
|
||
config = configs.get(agent_name, {})
|
||
can_transfer_to = config.get("can_transfer_to", [])
|
||
|
||
tools = []
|
||
for target_agent in can_transfer_to:
|
||
target_config = configs.get(target_agent, {})
|
||
description = target_config.get("description", f"转移到 {target_agent}")
|
||
tools.append(create_transfer_tool(target_agent, description))
|
||
|
||
return tools
|
||
|
||
|
||
# ==================== Agent 节点创建 ====================
|
||
|
||
def create_agent_node(agent_name: str, system_prompt: str, tools: List,
|
||
model_config: RedBearModelConfig):
|
||
"""创建 Agent 节点(非流式)"""
|
||
llm = RedBearLLM(model_config, type=ModelType.CHAT)
|
||
|
||
# 绑定工具
|
||
if tools:
|
||
llm = llm.bind_tools(tools)
|
||
|
||
async def agent_node(state: HandoffState) -> Dict[str, Any]:
|
||
"""Agent 节点执行函数"""
|
||
logger.debug(f"Agent {agent_name} 执行, active_agent: {state.get('active_agent')}")
|
||
|
||
# 获取当前 handoff 状态
|
||
handoff_count = state.get("handoff_count", 0)
|
||
handoff_history = state.get("handoff_history", [])
|
||
pending_question = state.get("pending_question")
|
||
previous_answer = state.get("previous_answer", "")
|
||
|
||
# 检查是否达到最大 handoff 次数
|
||
if handoff_count >= MAX_HANDOFFS:
|
||
logger.warning(f"Agent {agent_name}: 达到最大 handoff 次数,直接回复")
|
||
return {
|
||
"messages": [AIMessage(content="抱歉,我无法继续处理这个请求。请尝试重新提问。")],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
}
|
||
|
||
messages = state.get("messages", [])
|
||
|
||
# 如果有 pending_question,构建新的消息上下文
|
||
if pending_question and handoff_count > 0:
|
||
# 构建包含上下文的消息
|
||
context_msg = f"【来自其他专家的转交】\n"
|
||
if previous_answer:
|
||
context_msg += f"之前的专家已经回答了: {previous_answer}\n\n"
|
||
context_msg += f"现在需要你回答的问题是: {pending_question}\n\n"
|
||
if handoff_history:
|
||
context_msg += f"【注意】以下专家已经处理过这个问题,不能再转交给他们: {', '.join(handoff_history)}"
|
||
|
||
# 使用转交的具体问题,而不是原始消息
|
||
effective_messages = [HumanMessage(content=context_msg)]
|
||
logger.info(f"Agent {agent_name} 收到转交问题(非流式): {pending_question[:100]}...")
|
||
else:
|
||
effective_messages = messages
|
||
|
||
full_messages = [{"role": "system", "content": system_prompt}] + effective_messages
|
||
|
||
response = await llm.ainvoke(full_messages)
|
||
|
||
# 检查工具调用
|
||
if hasattr(response, 'tool_calls') and response.tool_calls:
|
||
tool_call = response.tool_calls[0]
|
||
tool_name = tool_call["name"] if isinstance(tool_call, dict) else tool_call.name
|
||
tool_args = tool_call["args"] if isinstance(tool_call, dict) else tool_call.args
|
||
|
||
if isinstance(tool_args, str):
|
||
try:
|
||
tool_args = json.loads(tool_args)
|
||
except (json.JSONDecodeError, ValueError):
|
||
tool_args = {}
|
||
|
||
# 确保必要的参数存在
|
||
if not tool_args.get("reason"):
|
||
tool_args["reason"] = "用户请求转移"
|
||
|
||
# 获取 LLM 提供的 unhandled_question
|
||
llm_unhandled_question = tool_args.get("unhandled_question", "")
|
||
|
||
# 提取目标 agent
|
||
target_agent = tool_name.replace("transfer_to_", "")
|
||
|
||
# 检查是否会形成循环:目标 Agent 是否已经在 handoff_history 中
|
||
if target_agent in handoff_history:
|
||
logger.warning(f"Agent {agent_name} 尝试移交给已处理过的 {target_agent},强制直接回复")
|
||
return {
|
||
"messages": [AIMessage(content="抱歉,这个问题超出了我的专业范围,我无法回答。")],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
}
|
||
|
||
# 第一次转交,检查是否提供了 unhandled_question
|
||
if not llm_unhandled_question:
|
||
# 使用原始消息
|
||
last_human_msg = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None)
|
||
llm_unhandled_question = last_human_msg.content if last_human_msg else ""
|
||
|
||
tool_args["unhandled_question"] = llm_unhandled_question
|
||
|
||
for t in tools:
|
||
if t.name == tool_name:
|
||
# 提取目标 agent
|
||
target_agent = tool_name.replace("transfer_to_", "")
|
||
new_history = handoff_history + [agent_name]
|
||
|
||
logger.info(f"Agent {agent_name} handoff 到 {target_agent} (count: {handoff_count + 1}), 转交问题: {tool_args.get('unhandled_question', '')[:50]}...")
|
||
|
||
# 返回 Command 并更新 handoff 状态
|
||
return Command(
|
||
goto=target_agent,
|
||
update={
|
||
"active_agent": target_agent,
|
||
"handoff_count": handoff_count + 1,
|
||
"handoff_history": new_history,
|
||
"pending_question": tool_args.get("unhandled_question", ""),
|
||
"previous_answer": tool_args.get("your_answer", "")
|
||
}
|
||
)
|
||
|
||
return {
|
||
"messages": [response],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None, # 清除 pending_question
|
||
"previous_answer": ""
|
||
}
|
||
|
||
return agent_node
|
||
|
||
|
||
def create_streaming_agent_node(agent_name: str, system_prompt: str, tools: List,
|
||
model_config: RedBearModelConfig):
|
||
"""创建支持流式输出的 Agent 节点"""
|
||
llm = RedBearLLM(model_config, type=ModelType.CHAT)
|
||
|
||
# 绑定工具
|
||
if tools:
|
||
llm = llm.bind_tools(tools)
|
||
|
||
async def agent_node(state: HandoffState):
|
||
"""Agent 节点执行函数(流式)"""
|
||
logger.debug(f"Agent {agent_name} 流式执行, active_agent: {state.get('active_agent')}")
|
||
|
||
# 获取当前 handoff 状态
|
||
handoff_count = state.get("handoff_count", 0)
|
||
handoff_history = state.get("handoff_history", [])
|
||
pending_question = state.get("pending_question")
|
||
previous_answer = state.get("previous_answer", "")
|
||
|
||
logger.info(f"Agent {agent_name} 状态: handoff_count={handoff_count}, pending_question={pending_question}, previous_answer={previous_answer[:50] if previous_answer else ''}")
|
||
|
||
# 检查是否达到最大 handoff 次数
|
||
if handoff_count >= MAX_HANDOFFS:
|
||
logger.warning(f"Agent {agent_name}: 达到最大 handoff 次数,直接回复")
|
||
return {
|
||
"messages": [AIMessage(content="抱歉,我无法继续处理这个请求。请尝试重新提问。")],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
}
|
||
|
||
messages = state.get("messages", [])
|
||
|
||
# 如果有 pending_question,构建新的消息上下文
|
||
if pending_question and handoff_count > 0:
|
||
# 构建包含上下文的消息
|
||
context_msg = f"【来自其他专家的转交】\n"
|
||
if previous_answer:
|
||
context_msg += f"之前的专家已经回答了: {previous_answer}\n\n"
|
||
context_msg += f"现在需要你回答的问题是: {pending_question}\n\n"
|
||
if handoff_history:
|
||
context_msg += f"【注意】以下专家已经处理过这个问题,不能再转交给他们: {', '.join(handoff_history)}"
|
||
|
||
# 使用转交的具体问题,而不是原始消息
|
||
effective_messages = [HumanMessage(content=context_msg)]
|
||
logger.info(f"Agent {agent_name} 收到转交问题(流式): {pending_question[:100]}...")
|
||
else:
|
||
effective_messages = messages
|
||
|
||
full_messages = [{"role": "system", "content": system_prompt}] + effective_messages
|
||
|
||
full_content = ""
|
||
collected_tool_calls = {}
|
||
|
||
async for chunk in llm.astream(full_messages):
|
||
if hasattr(chunk, 'content') and chunk.content:
|
||
full_content += chunk.content
|
||
|
||
# 收集工具调用
|
||
if hasattr(chunk, 'tool_calls') and chunk.tool_calls:
|
||
for tc in chunk.tool_calls:
|
||
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, 'id', "0")
|
||
tc_id = tc_id or "0"
|
||
if tc_id not in collected_tool_calls:
|
||
collected_tool_calls[tc_id] = {"id": tc_id, "name": "", "args": ""}
|
||
|
||
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, 'name', None)
|
||
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, 'args', None)
|
||
|
||
if tc_name:
|
||
collected_tool_calls[tc_id]["name"] = tc_name
|
||
if tc_args:
|
||
if isinstance(tc_args, dict):
|
||
collected_tool_calls[tc_id]["args"] = tc_args
|
||
elif isinstance(tc_args, str):
|
||
if isinstance(collected_tool_calls[tc_id]["args"], str):
|
||
collected_tool_calls[tc_id]["args"] += tc_args
|
||
|
||
# 处理 tool_call_chunks
|
||
if hasattr(chunk, 'tool_call_chunks') and chunk.tool_call_chunks:
|
||
for tc_chunk in chunk.tool_call_chunks:
|
||
idx = str(tc_chunk.get("index", 0) if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'index', 0))
|
||
if idx not in collected_tool_calls:
|
||
tc_id = tc_chunk.get("id", idx) if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'id', idx)
|
||
collected_tool_calls[idx] = {"id": tc_id, "name": "", "args": ""}
|
||
|
||
tc_id = tc_chunk.get("id") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'id', None)
|
||
tc_name = tc_chunk.get("name") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'name', None)
|
||
tc_args = tc_chunk.get("args") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'args', None)
|
||
|
||
if tc_id:
|
||
collected_tool_calls[idx]["id"] = tc_id
|
||
if tc_name:
|
||
collected_tool_calls[idx]["name"] = tc_name
|
||
if tc_args:
|
||
if isinstance(collected_tool_calls[idx]["args"], str):
|
||
collected_tool_calls[idx]["args"] += tc_args
|
||
|
||
# 解析工具调用
|
||
tool_calls_list = list(collected_tool_calls.values())
|
||
for tc in tool_calls_list:
|
||
if isinstance(tc.get("args"), str) and tc["args"]:
|
||
try:
|
||
tc["args"] = json.loads(tc["args"])
|
||
except (json.JSONDecodeError, ValueError):
|
||
tc["args"] = {}
|
||
elif not tc.get("args"):
|
||
tc["args"] = {}
|
||
|
||
# 执行工具调用 - 选择参数最完整的工具调用
|
||
if tool_calls_list:
|
||
# 找到参数最完整的 transfer 工具调用
|
||
best_tool_call = None
|
||
best_args_len = -1
|
||
|
||
for tc in tool_calls_list:
|
||
tc_name = tc.get("name", "")
|
||
if tc_name.startswith("transfer_to_"):
|
||
tc_args = tc.get("args", {})
|
||
if isinstance(tc_args, str):
|
||
try:
|
||
tc_args = json.loads(tc_args)
|
||
except:
|
||
tc_args = {}
|
||
# 计算参数完整度
|
||
args_len = len(str(tc_args.get("unhandled_question", ""))) + len(str(tc_args.get("reason", "")))
|
||
if args_len > best_args_len:
|
||
best_args_len = args_len
|
||
best_tool_call = (tc_name, tc_args)
|
||
|
||
if best_tool_call:
|
||
tool_name, tool_args = best_tool_call
|
||
|
||
# 确保必要的参数存在
|
||
if not tool_args.get("reason"):
|
||
tool_args["reason"] = "用户请求转移"
|
||
|
||
# 获取 LLM 提供的 unhandled_question
|
||
llm_unhandled_question = tool_args.get("unhandled_question", "")
|
||
|
||
# 提取目标 agent
|
||
target_agent = tool_name.replace("transfer_to_", "")
|
||
|
||
# 检查是否会形成循环:目标 Agent 是否已经在 handoff_history 中
|
||
if target_agent in handoff_history:
|
||
logger.warning(f"Agent {agent_name} 尝试移交给已处理过的 {target_agent},强制直接回复")
|
||
return {
|
||
"messages": [AIMessage(content=full_content if full_content else "抱歉,这个问题超出了我的专业范围,我无法回答。")],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
}
|
||
|
||
# 检查是否提供了 unhandled_question
|
||
if not llm_unhandled_question:
|
||
# 使用原始消息
|
||
last_human_msg = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None)
|
||
llm_unhandled_question = last_human_msg.content if last_human_msg else ""
|
||
|
||
new_history = handoff_history + [agent_name]
|
||
|
||
logger.info(f"Agent {agent_name} handoff 到 {target_agent} (count: {handoff_count + 1}), 转交问题: {llm_unhandled_question[:100]}...")
|
||
|
||
# 返回 Command 并更新 handoff 状态
|
||
return Command(
|
||
goto=target_agent,
|
||
update={
|
||
"active_agent": target_agent,
|
||
"handoff_count": handoff_count + 1,
|
||
"handoff_history": new_history,
|
||
"pending_question": llm_unhandled_question,
|
||
"previous_answer": tool_args.get("your_answer", "")
|
||
}
|
||
)
|
||
|
||
return {
|
||
"messages": [AIMessage(content=full_content)],
|
||
"handoff_count": handoff_count,
|
||
"handoff_history": handoff_history,
|
||
"pending_question": None, # 清除 pending_question
|
||
"previous_answer": ""
|
||
}
|
||
|
||
return agent_node
|
||
|
||
|
||
# ==================== 路由函数 ====================
|
||
|
||
def create_route_initial(default_agent: str):
|
||
"""创建初始路由函数"""
|
||
def route_initial(state: HandoffState) -> str:
|
||
active = state.get("active_agent")
|
||
if active:
|
||
return active
|
||
return default_agent
|
||
return route_initial
|
||
|
||
|
||
def route_after_agent(state: HandoffState) -> str:
|
||
"""Agent 执行后的路由
|
||
|
||
检查:
|
||
1. 是否达到最大 handoff 次数
|
||
2. 是否形成循环(连续在两个 Agent 之间切换)
|
||
3. 最后一条消息是否有 tool_calls
|
||
"""
|
||
messages = state.get("messages", [])
|
||
handoff_count = state.get("handoff_count", 0)
|
||
handoff_history = state.get("handoff_history", [])
|
||
|
||
# 检查是否达到最大 handoff 次数
|
||
if handoff_count >= MAX_HANDOFFS:
|
||
logger.warning(f"达到最大 handoff 次数 ({MAX_HANDOFFS}),强制结束")
|
||
return END
|
||
|
||
# 检查是否形成循环(A -> B -> A -> B 模式)
|
||
if len(handoff_history) >= 4:
|
||
# 检查最近 4 次是否形成 A-B-A-B 循环
|
||
recent = handoff_history[-4:]
|
||
if recent[0] == recent[2] and recent[1] == recent[3] and recent[0] != recent[1]:
|
||
logger.warning(f"检测到循环 handoff: {recent},强制结束")
|
||
return END
|
||
|
||
# 检查最后一条消息
|
||
if messages:
|
||
last_msg = messages[-1]
|
||
if isinstance(last_msg, AIMessage) and not getattr(last_msg, 'tool_calls', None):
|
||
return END
|
||
|
||
return state.get("active_agent", END)
|
||
|
||
|
||
# ==================== 配置转换 ====================
|
||
|
||
def convert_multi_agent_config_to_handoffs(
|
||
multi_agent_config: Dict,
|
||
db: Session
|
||
) -> Dict[str, Dict]:
|
||
"""将 multi_agent_config 转换为 handoffs 配置格式
|
||
|
||
Args:
|
||
multi_agent_config: 数据库中的多 Agent 配置
|
||
db: 数据库会话
|
||
|
||
Returns:
|
||
agent_configs 字典,每个 Agent 包含自己的 model_config
|
||
"""
|
||
from app.models import AppRelease, App
|
||
|
||
sub_agents = multi_agent_config.get("sub_agents", [])
|
||
agent_configs = {}
|
||
agent_names = []
|
||
|
||
# 遍历子 Agent,构建配置
|
||
for sub_agent in sub_agents:
|
||
agent_id = sub_agent.get("agent_id") # 可能是 release_id 或 app_id
|
||
agent_name = sub_agent.get("name", f"agent_{agent_id[:8] if agent_id else 'unknown'}")
|
||
# 使用安全的 agent name(去除特殊字符)
|
||
safe_name = agent_name.replace(" ", "_").replace("-", "_").lower()
|
||
agent_names.append(safe_name)
|
||
|
||
# 从 AppRelease 获取 Agent 的系统提示词和模型配置
|
||
system_prompt = f"你是 {agent_name}。"
|
||
capabilities = sub_agent.get("capabilities", [])
|
||
model_config = None
|
||
release = None
|
||
|
||
if agent_id:
|
||
try:
|
||
agent_id_uuid = uuid.UUID(agent_id) if isinstance(agent_id, str) else agent_id
|
||
|
||
# 先尝试作为 release_id 查询
|
||
release = db.get(AppRelease, agent_id_uuid)
|
||
|
||
# 如果找不到,尝试作为 app_id 查询,获取 current_release
|
||
if not release:
|
||
app = db.get(App, agent_id_uuid)
|
||
if app and app.current_release_id:
|
||
release = db.get(AppRelease, app.current_release_id)
|
||
|
||
if release:
|
||
# 从 release.config 获取 system_prompt
|
||
if release.config:
|
||
config_data = release.config
|
||
release_system_prompt = config_data.get("system_prompt")
|
||
if release_system_prompt:
|
||
system_prompt = release_system_prompt
|
||
|
||
# 获取该 Agent 的模型配置
|
||
if release.default_model_config_id:
|
||
model_api_key = ModelApiKeyService.get_a_api_key(db, release.default_model_config_id)
|
||
if model_api_key:
|
||
model_config = RedBearModelConfig(
|
||
model_name=model_api_key.model_name,
|
||
provider=model_api_key.provider,
|
||
api_key=model_api_key.api_key,
|
||
base_url=model_api_key.api_base,
|
||
extra_params={
|
||
"temperature": 0.7,
|
||
"max_tokens": 2000,
|
||
"streaming": True
|
||
}
|
||
)
|
||
logger.debug(f"Agent {agent_name} 使用模型: {model_api_key.model_name}")
|
||
else:
|
||
logger.warning(f"Agent {agent_name} 模型配置无效: {release.default_model_config_id}")
|
||
else:
|
||
logger.warning(f"Agent {agent_name} 没有配置 default_model_config_id")
|
||
else:
|
||
logger.warning(f"Agent {agent_name} 找不到发布版本: agent_id={agent_id}")
|
||
except Exception as e:
|
||
logger.warning(f"获取 Agent {agent_name} 配置失败: {str(e)}")
|
||
|
||
# 如果有 capabilities,添加到系统提示词
|
||
if capabilities:
|
||
if not system_prompt.endswith("。"):
|
||
system_prompt += "。"
|
||
system_prompt += f" 你的专长是: {', '.join(capabilities)}。"
|
||
|
||
agent_configs[safe_name] = {
|
||
"agent_id": agent_id,
|
||
"name": agent_name,
|
||
"description": f"转移到 {agent_name}。{sub_agent.get('role') or ''}",
|
||
"system_prompt": system_prompt,
|
||
"capabilities": capabilities,
|
||
"model_config": model_config, # 每个 Agent 自己的模型配置
|
||
"can_transfer_to": [] # 稍后填充
|
||
}
|
||
|
||
# 设置每个 Agent 可以转移到的其他 Agent
|
||
for safe_name in agent_names:
|
||
agent_configs[safe_name]["can_transfer_to"] = [
|
||
name for name in agent_names if name != safe_name
|
||
]
|
||
# 更新系统提示词,添加转移说明
|
||
other_agents = agent_configs[safe_name]["can_transfer_to"]
|
||
current_capabilities = agent_configs[safe_name].get("capabilities", [])
|
||
|
||
if other_agents:
|
||
# 构建其他 Agent 的专长信息
|
||
other_agents_info = []
|
||
for other_name in other_agents:
|
||
other_config = agent_configs[other_name]
|
||
other_caps = other_config.get("capabilities", [])
|
||
if other_caps:
|
||
other_agents_info.append(f"- {other_config['name']}: 专长 {', '.join(other_caps)}")
|
||
else:
|
||
other_agents_info.append(f"- {other_config['name']}")
|
||
|
||
transfer_instructions = f"""
|
||
|
||
【重要工作原则】
|
||
1. 你必须先输出你对专长范围({', '.join(current_capabilities) if current_capabilities else '你的领域'})问题的完整回答
|
||
2. 回答完成后,如果还有其他部分需要其他专家处理,再调用转移工具
|
||
3. 不能转移给已经处理过这个问题的专家
|
||
|
||
【回答流程】
|
||
1. 先直接输出你的回答内容(不要放在工具参数里)
|
||
2. 输出完成后,调用转移工具转交剩余问题
|
||
|
||
【其他可用的专家】
|
||
{chr(10).join(other_agents_info)}
|
||
|
||
【转移工具参数】
|
||
- reason: 转移原因
|
||
- unhandled_question: 需要其他专家回答的具体问题
|
||
- your_answer: 简要说明你回答了什么(摘要即可)
|
||
|
||
【转移工具】"""
|
||
for other_name in other_agents:
|
||
other_config = agent_configs[other_name]
|
||
transfer_instructions += f"\n- transfer_to_{other_name}: {other_config['description']}"
|
||
|
||
agent_configs[safe_name]["system_prompt"] += transfer_instructions
|
||
|
||
return agent_configs
|
||
|
||
|
||
# ==================== Handoffs 服务类 ====================
|
||
|
||
class HandoffsService:
|
||
"""Handoffs 服务 - 管理多 Agent 协作"""
|
||
|
||
def __init__(
|
||
self,
|
||
agent_configs: Dict[str, Dict],
|
||
streaming: bool = True
|
||
):
|
||
"""初始化 Handoffs 服务
|
||
|
||
Args:
|
||
agent_configs: Agent 配置字典,每个 Agent 包含自己的 model_config
|
||
streaming: 是否启用流式输出
|
||
"""
|
||
self.agent_configs = agent_configs
|
||
self.streaming = streaming
|
||
self._graph = None
|
||
|
||
# 验证每个 Agent 都有模型配置
|
||
for agent_name, config in agent_configs.items():
|
||
if not config.get("model_config"):
|
||
raise ValueError(f"Agent {agent_name} 没有配置模型")
|
||
|
||
logger.info(f"HandoffsService 初始化, agents: {list(self.agent_configs.keys())}")
|
||
|
||
def _build_graph(self):
|
||
"""构建 LangGraph 图"""
|
||
builder = StateGraph(HandoffState)
|
||
agent_names = list(self.agent_configs.keys())
|
||
|
||
if not agent_names:
|
||
|
||
raise ValueError("至少需要一个 Agent 配置")
|
||
|
||
for agent_name in agent_names:
|
||
config = self.agent_configs[agent_name]
|
||
tools = create_tools_for_agent(agent_name, self.agent_configs)
|
||
|
||
# 使用每个 Agent 自己的模型配置
|
||
agent_model_config = config.get("model_config")
|
||
|
||
if self.streaming:
|
||
agent_node = create_streaming_agent_node(
|
||
agent_name=agent_name,
|
||
system_prompt=config.get("system_prompt", f"你是 {agent_name}"),
|
||
tools=tools,
|
||
model_config=agent_model_config
|
||
)
|
||
else:
|
||
agent_node = create_agent_node(
|
||
agent_name=agent_name,
|
||
system_prompt=config.get("system_prompt", f"你是 {agent_name}"),
|
||
tools=tools,
|
||
model_config=agent_model_config
|
||
)
|
||
builder.add_node(agent_name, agent_node)
|
||
|
||
# 添加边
|
||
default_agent = agent_names[0]
|
||
builder.add_conditional_edges(START, create_route_initial(default_agent), agent_names)
|
||
|
||
for agent_name in agent_names:
|
||
builder.add_conditional_edges(agent_name, route_after_agent, agent_names + [END])
|
||
|
||
memory = MemorySaver()
|
||
return builder.compile(checkpointer=memory)
|
||
|
||
@property
|
||
def graph(self):
|
||
"""获取图实例(懒加载)"""
|
||
if self._graph is None:
|
||
self._graph = self._build_graph()
|
||
return self._graph
|
||
|
||
def reset(self):
|
||
"""重置图实例"""
|
||
self._graph = None
|
||
logger.info("HandoffsService 图已重置")
|
||
|
||
async def chat(
|
||
self,
|
||
message: str,
|
||
conversation_id: str = None
|
||
) -> Dict[str, Any]:
|
||
"""非流式聊天"""
|
||
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex[:8]}"
|
||
config = {"configurable": {"thread_id": str(conversation_id)}}
|
||
|
||
logger.info(f"Handoffs chat: conversation_id={conversation_id}, message={message[:50]}...")
|
||
|
||
result = await self.graph.ainvoke({
|
||
"messages": [HumanMessage(content=message)],
|
||
"handoff_count": 0,
|
||
"handoff_history": [],
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
}, config=config)
|
||
|
||
# 提取响应
|
||
response_content = ""
|
||
total_tokens = 0
|
||
for msg in result.get("messages", []):
|
||
if isinstance(msg, AIMessage):
|
||
response_content = msg.content
|
||
response_meta = msg.response_metadata if hasattr(msg, 'response_metadata') else None
|
||
total_tokens = response_meta.get("token_usage", {}).get("total_tokens", 0) if response_meta else 0
|
||
break
|
||
|
||
return {
|
||
"conversation_id": str(conversation_id),
|
||
"active_agent": result.get("active_agent"),
|
||
"response": response_content,
|
||
"message_count": len(result.get("messages", [])),
|
||
"handoff_count": result.get("handoff_count", 0),
|
||
"usage": {
|
||
"prompt_tokens": 0,
|
||
"completion_tokens": 0,
|
||
"total_tokens": total_tokens
|
||
}
|
||
}
|
||
|
||
async def chat_stream(
|
||
self,
|
||
message: str,
|
||
conversation_id: str = None
|
||
) -> AsyncGenerator[str, None]:
|
||
"""流式聊天"""
|
||
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex[:8]}"
|
||
config = {"configurable": {"thread_id": str(conversation_id)}}
|
||
|
||
logger.info(f"Handoffs stream chat: conversation_id={conversation_id}, message={message[:50]}...")
|
||
|
||
# 发送开始事件
|
||
yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_id)}, ensure_ascii=False)}\n\n"
|
||
|
||
current_agent = None
|
||
handoff_count = 0
|
||
collected_tool_calls = {} # 收集工具调用信息
|
||
|
||
try:
|
||
async for event in self.graph.astream_events(
|
||
{
|
||
"messages": [HumanMessage(content=message)],
|
||
"handoff_count": 0,
|
||
"handoff_history": [],
|
||
"pending_question": None,
|
||
"previous_answer": ""
|
||
},
|
||
config=config,
|
||
version="v2"
|
||
):
|
||
kind = event["event"]
|
||
|
||
# 捕获节点开始(Agent 切换)
|
||
if kind == "on_chain_start":
|
||
node_name = event.get("name", "")
|
||
if node_name in self.agent_configs:
|
||
if current_agent != node_name:
|
||
current_agent = node_name
|
||
agent_display_name = self.agent_configs[node_name].get("name", node_name)
|
||
yield f"event: agent\ndata: {json.dumps({'agent': node_name, 'agent_name': agent_display_name}, ensure_ascii=False)}\n\n"
|
||
|
||
# 捕获 LLM 流式输出
|
||
elif kind == "on_chat_model_stream":
|
||
chunk = event["data"]["chunk"]
|
||
content = chunk.content if hasattr(chunk, 'content') else ""
|
||
if content:
|
||
yield f"event: message\ndata: {json.dumps({'content': content, 'agent': current_agent}, ensure_ascii=False)}\n\n"
|
||
|
||
# 收集工具调用信息
|
||
if hasattr(chunk, 'tool_calls') and chunk.tool_calls:
|
||
for tc in chunk.tool_calls:
|
||
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, 'id', "0")
|
||
tc_id = tc_id or "0"
|
||
if tc_id not in collected_tool_calls:
|
||
collected_tool_calls[tc_id] = {"id": tc_id, "name": "", "args": ""}
|
||
|
||
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, 'name', None)
|
||
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, 'args', None)
|
||
|
||
if tc_name:
|
||
collected_tool_calls[tc_id]["name"] = tc_name
|
||
if tc_args:
|
||
if isinstance(tc_args, dict):
|
||
collected_tool_calls[tc_id]["args"] = tc_args
|
||
elif isinstance(tc_args, str):
|
||
if isinstance(collected_tool_calls[tc_id]["args"], str):
|
||
collected_tool_calls[tc_id]["args"] += tc_args
|
||
|
||
# 处理 tool_call_chunks
|
||
if hasattr(chunk, 'tool_call_chunks') and chunk.tool_call_chunks:
|
||
for tc_chunk in chunk.tool_call_chunks:
|
||
idx = str(tc_chunk.get("index", 0) if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'index', 0))
|
||
if idx not in collected_tool_calls:
|
||
tc_id = tc_chunk.get("id", idx) if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'id', idx)
|
||
collected_tool_calls[idx] = {"id": tc_id, "name": "", "args": ""}
|
||
|
||
tc_id = tc_chunk.get("id") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'id', None)
|
||
tc_name = tc_chunk.get("name") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'name', None)
|
||
tc_args = tc_chunk.get("args") if isinstance(tc_chunk, dict) else getattr(tc_chunk, 'args', None)
|
||
|
||
if tc_id:
|
||
collected_tool_calls[idx]["id"] = tc_id
|
||
if tc_name:
|
||
collected_tool_calls[idx]["name"] = tc_name
|
||
if tc_args:
|
||
if isinstance(collected_tool_calls[idx]["args"], str):
|
||
collected_tool_calls[idx]["args"] += tc_args
|
||
|
||
# 捕获 LLM 结束事件,输出收集到的工具调用
|
||
elif kind == "on_chat_model_end":
|
||
output_message = event.get("data", {}).get("output", {})
|
||
if isinstance(output_message, AIMessageChunk):
|
||
response_meta = output_message.response_metadata if hasattr(output_message, 'response_metadata') else None
|
||
total_tokens = response_meta.get("token_usage", {}).get("total_tokens",
|
||
0) if response_meta else 0
|
||
yield f"event: sub_usage\ndata: {json.dumps({"total_tokens": total_tokens}, ensure_ascii=False)}\n\n"
|
||
if collected_tool_calls:
|
||
# 找到参数最完整的 transfer 工具调用
|
||
best_tc = None
|
||
best_args_len = -1
|
||
for tc_id, tc_info in collected_tool_calls.items():
|
||
if tc_info.get("name", "").startswith("transfer_to_"):
|
||
args = tc_info.get("args", {})
|
||
if isinstance(args, str):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
args = {}
|
||
# 计算参数完整度(有 unhandled_question 的优先)
|
||
args_len = len(str(args.get("unhandled_question", ""))) + len(str(args.get("reason", "")))
|
||
if args_len > best_args_len:
|
||
best_args_len = args_len
|
||
best_tc = (tc_info, args)
|
||
|
||
if best_tc:
|
||
tc_info, args = best_tc
|
||
handoff_count += 1
|
||
target_agent = tc_info["name"].replace("transfer_to_", "")
|
||
target_name = self.agent_configs.get(target_agent, {}).get("name", target_agent)
|
||
yield f"event: handoff\ndata: {json.dumps({'from': current_agent, 'to': target_agent, 'to_name': target_name, 'handoff_count': handoff_count, 'reason': args.get('reason', ''), 'unhandled_question': args.get('unhandled_question', ''), 'your_answer': args.get('your_answer', '')}, ensure_ascii=False)}\n\n"
|
||
collected_tool_calls = {} # 清空,准备收集下一个 Agent 的工具调用
|
||
|
||
# 发送结束事件
|
||
yield f"event: end\ndata: {json.dumps({'conversation_id': str(conversation_id), 'final_agent': current_agent, 'total_handoffs': handoff_count}, ensure_ascii=False)}\n\n"
|
||
|
||
except Exception as e:
|
||
logger.error(f"Handoffs stream error: {str(e)}")
|
||
yield f"event: error\ndata: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||
|
||
def get_agents(self) -> List[Dict[str, Any]]:
|
||
"""获取可用的 Agent 列表"""
|
||
agents = []
|
||
for name, config in self.agent_configs.items():
|
||
agents.append({
|
||
"id": name,
|
||
"name": config.get("name", name),
|
||
"description": config.get("description", ""),
|
||
"capabilities": config.get("capabilities", []),
|
||
"can_transfer_to": config.get("can_transfer_to", [])
|
||
})
|
||
return agents
|
||
|
||
|
||
# ==================== 服务工厂 ====================
|
||
|
||
# 缓存服务实例(按 app_id)
|
||
_service_cache: Dict[str, HandoffsService] = {}
|
||
|
||
|
||
def get_handoffs_service_for_app(
|
||
app_id: uuid.UUID,
|
||
db: Session,
|
||
streaming: bool = True
|
||
) -> HandoffsService:
|
||
"""根据 app_id 获取 Handoffs 服务实例
|
||
|
||
Args:
|
||
app_id: 应用 ID
|
||
db: 数据库会话
|
||
streaming: 是否流式
|
||
|
||
Returns:
|
||
HandoffsService 实例
|
||
"""
|
||
from app.services.multi_agent_service import MultiAgentService
|
||
|
||
cache_key = f"{app_id}_{streaming}"
|
||
|
||
# 检查缓存
|
||
if cache_key in _service_cache:
|
||
return _service_cache[cache_key]
|
||
|
||
# 获取多 Agent 配置
|
||
multi_agent_service = MultiAgentService(db)
|
||
multi_agent_config = multi_agent_service.get_multi_agent_configs(app_id)
|
||
|
||
if not multi_agent_config:
|
||
raise ValueError(f"应用 {app_id} 没有多 Agent 配置")
|
||
|
||
# 转换配置(每个 Agent 包含自己的 model_config)
|
||
agent_configs = convert_multi_agent_config_to_handoffs(multi_agent_config, db)
|
||
|
||
if not agent_configs:
|
||
raise ValueError(f"应用 {app_id} 没有配置子 Agent")
|
||
|
||
# 创建服务
|
||
service = HandoffsService(agent_configs, streaming)
|
||
|
||
# 缓存
|
||
_service_cache[cache_key] = service
|
||
|
||
return service
|
||
|
||
|
||
def reset_handoffs_service_cache(app_id: uuid.UUID = None):
|
||
"""重置服务缓存
|
||
|
||
Args:
|
||
app_id: 应用 ID,如果为 None 则清除所有缓存
|
||
"""
|
||
global _service_cache
|
||
|
||
if app_id:
|
||
keys_to_remove = [k for k in _service_cache if k.startswith(str(app_id))]
|
||
for key in keys_to_remove:
|
||
del _service_cache[key]
|
||
else:
|
||
_service_cache = {}
|
||
|
||
logger.info(f"Handoffs 服务缓存已重置: app_id={app_id}")
|