base_agent/client/agent_client.py

412 lines
17 KiB
Python
Raw Normal View History

2026-02-28 08:21:35 +00:00
"""
client/agent_client.py
2026-03-09 05:40:27 +00:00
修复OpenAI 消息序列必须满足
user assistant(tool_calls) tool(s) assistant(reply)
每个 tool 消息的 tool_call_id 必须与同一 assistant 消息中的 tool_calls[].id 完全匹配
2026-02-28 08:21:35 +00:00
"""
2026-03-09 05:40:27 +00:00
import json
2026-03-09 05:37:29 +00:00
import uuid
from dataclasses import dataclass, field
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
from config.settings import settings
2026-02-28 08:21:35 +00:00
from llm.llm_engine import LLMEngine
2026-03-09 05:37:29 +00:00
from mcp.mcp_protocol import (
ChainPlan, ChainResult, MCPRequest, MCPResponse,
StepResult, ToolStep,
)
2026-02-28 08:21:35 +00:00
from mcp.mcp_server import MCPServer
from memory.memory_store import MemoryStore
from utils.logger import get_logger
@dataclass
class AgentResponse:
"""一次完整 Agent 调用的结果"""
2026-03-09 05:37:29 +00:00
user_input: str
final_reply: str
chain_result: ChainResult | None = None
tool_used: str | None = None
tool_output: str | None = None
success: bool = True
error: str | None = None
token_usage: dict = field(default_factory=dict)
@property
def is_multi_step(self) -> bool:
return self.chain_result is not None and self.chain_result.total_steps > 1
2026-02-28 08:21:35 +00:00
class AgentClient:
"""
2026-03-09 05:37:29 +00:00
Agent 客户端OpenAI Function Calling + Multi-Step Tool Chain
2026-02-28 08:21:35 +00:00
2026-03-09 05:40:27 +00:00
修复后的 OpenAI 消息序列规范:
单步工具调用
{"role": "system", "content": "..."}
{"role": "user", "content": "计算 1+1"}
{"role": "assistant", "content": null,
"tool_calls": [{"id": "call_abc", "type": "function",
"function": {"name": "calculator", "arguments": "{...}"}}]}
{"role": "tool", "content": "结果: 2", "tool_call_id": "call_abc"}
generate_reply() 最终回复
多步工具调用并行声明串行执行
{"role": "system", "content": "..."}
2026-03-09 05:37:29 +00:00
{"role": "user", "content": "搜索天气然后计算..."}
2026-03-09 05:40:27 +00:00
{"role": "assistant", "content": null,
"tool_calls": [{"id": "call_001", ...web_search...},
{"id": "call_002", ...calculator...}]}
{"role": "tool", "content": "搜索结果", "tool_call_id": "call_001"}
{"role": "tool", "content": "计算结果", "tool_call_id": "call_002"}
generate_reply() 最终回复
关键约束:
1. tool 消息必须紧跟在含 tool_calls assistant 消息之后
2. 每个 tool tool_call_id 必须与 assistant.tool_calls[i].id 完全一致
3. tool_calls 中声明了几个工具就必须有几条对应的 tool 消息
2026-02-28 08:21:35 +00:00
"""
def __init__(
2026-03-09 05:37:29 +00:00
self,
llm: LLMEngine,
mcp_server: MCPServer,
memory: MemoryStore,
2026-02-28 08:21:35 +00:00
):
2026-03-09 05:37:29 +00:00
self.llm = llm
2026-02-28 08:21:35 +00:00
self.mcp_server = mcp_server
2026-03-09 05:37:29 +00:00
self.memory = memory
self.logger = get_logger("CLIENT")
self._openai_history: list[dict] = []
self.logger.info("💻 Agent Client 初始化完成OpenAI Function Calling 模式)")
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
# ════════════════════════════════════════════════════════════
# 主入口
# ════════════════════════════════════════════════════════════
2026-02-28 08:21:35 +00:00
def chat(self, user_input: str) -> AgentResponse:
2026-03-09 05:37:29 +00:00
"""处理一轮用户对话"""
sep = "" * 60
self.logger.info(sep)
self.logger.info(f"📨 收到用户输入: {user_input}")
self.logger.info(sep)
2026-02-28 08:21:35 +00:00
self.memory.add_user_message(user_input)
context = self.memory.get_context_summary()
2026-03-09 05:40:27 +00:00
# Step 1: LLM 规划工具调用链
self.logger.info("🗺 [LLM] 规划工具调用链...")
2026-02-28 08:21:35 +00:00
tool_schemas = self.mcp_server.get_tool_schemas()
2026-03-09 05:37:29 +00:00
plan: ChainPlan = self.llm.plan_tool_chain(
user_input=user_input,
tool_schemas=tool_schemas,
context=context,
history=self._openai_history,
)
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
# 无需工具:直接回复
if not plan.steps:
2026-02-28 08:21:35 +00:00
return self._handle_direct_reply(user_input, context)
2026-03-09 05:40:27 +00:00
# Step 2: 执行工具调用链,构造合规的 OpenAI 消息序列
chain_result, openai_tool_block = self._execute_chain(plan, user_input)
2026-02-28 08:21:35 +00:00
2026-03-09 05:40:27 +00:00
# Step 3: 调用 OpenAI 整合结果,生成最终回复
return self._generate_response(
user_input, chain_result, openai_tool_block, context
)
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
# ════════════════════════════════════════════════════════════
# 串行执行引擎
# ════════════════════════════════════════════════════════════
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
def _execute_chain(
self,
plan: ChainPlan,
user_input: str,
) -> tuple[ChainResult, list[dict]]:
"""
2026-03-09 05:40:27 +00:00
串行执行工具调用链构造符合 OpenAI 协议的消息块
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
Returns:
2026-03-09 05:40:27 +00:00
chain_result: 执行结果汇总
openai_tool_block: 合规的 OpenAI 消息块结构为:
[
assistant(tool_calls=[call_001, call_002, ...]),
tool(tool_call_id=call_001, content=...),
tool(tool_call_id=call_002, content=...),
...
]
关键所有 tool_calls assistant 消息中一次性声明
然后逐条追加对应的 tool 结果消息保证 id 完全匹配
2026-03-09 05:37:29 +00:00
"""
2026-02-28 08:21:35 +00:00
self.logger.info(
2026-03-09 05:37:29 +00:00
f"\n{'' * 60}\n"
f" 🔗 开始执行工具调用链\n"
f" 目标: {plan.goal}\n"
f" 步骤: {plan.step_count}\n"
f"{'' * 60}"
2026-02-28 08:21:35 +00:00
)
2026-03-09 05:40:27 +00:00
# ── 1. 预先为每个步骤生成稳定的 tool_call_id ──────────
# 必须在执行前全部生成assistant 消息和 tool 消息共享同一批 id
step_call_ids: dict[int, str] = {
step.step_id: f"call_{uuid.uuid4().hex[:12]}"
for step in plan.steps
}
self.logger.debug(f"🔑 预生成 tool_call_ids: {step_call_ids}")
# ── 2. 构造 assistant 消息(一次性声明全部 tool_calls──
assistant_msg = self._build_assistant_message(plan, step_call_ids)
# ── 3. 串行执行每个步骤,收集 tool 结果消息 ────────────
2026-03-09 05:37:29 +00:00
step_results: list[StepResult] = []
2026-03-09 05:40:27 +00:00
tool_result_msgs: list[dict] = []
2026-03-09 05:37:29 +00:00
chain_context: dict[str, str] = {}
failed_step: int | None = None
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
for step in plan.steps:
2026-03-09 05:40:27 +00:00
call_id = step_call_ids[step.step_id]
2026-03-09 05:37:29 +00:00
if self._has_failed_dependency(step, failed_step):
self.logger.warning(
f"⏭ Step {step.step_id} [{step.tool_name}] 跳过"
f"(依赖步骤 {failed_step} 失败)"
)
step_results.append(StepResult(
step_id=step.step_id,
tool_name=step.tool_name,
success=False,
output="",
error=f"跳过:依赖步骤 {failed_step} 失败",
))
2026-03-09 05:40:27 +00:00
# ⚠️ 即使跳过,也必须补一条 tool 消息,保持 id 完全匹配
tool_result_msgs.append({
2026-03-09 05:37:29 +00:00
"role": "tool",
"content": f"步骤跳过:依赖步骤 {failed_step} 执行失败",
2026-03-09 05:40:27 +00:00
"tool_call_id": call_id,
2026-03-09 05:37:29 +00:00
})
continue
2026-03-09 05:40:27 +00:00
result = self._execute_single_step(step, chain_context, call_id)
2026-03-09 05:37:29 +00:00
step_results.append(result)
2026-03-09 05:40:27 +00:00
# 追加 tool 结果消息tool_call_id 与 assistant 声明完全一致)
tool_result_msgs.append({
2026-03-09 05:37:29 +00:00
"role": "tool",
"content": result.output if result.success else f"执行失败: {result.error}",
2026-03-09 05:40:27 +00:00
"tool_call_id": call_id,
2026-03-09 05:37:29 +00:00
})
if result.success:
chain_context[result.context_key] = result.output
self.memory.add_tool_result(step.tool_name, result.output)
else:
failed_step = step.step_id
2026-03-09 05:40:27 +00:00
# ── 4. 组装合规的 OpenAI 消息块 ─────────────────────────
# 格式: [assistant(tool_calls), tool, tool, ...]
openai_tool_block = [assistant_msg] + tool_result_msgs
self.logger.debug("📦 OpenAI 消息块结构:")
for i, msg in enumerate(openai_tool_block):
role = msg["role"]
if role == "assistant":
ids = [tc["id"] for tc in (msg.get("tool_calls") or [])]
self.logger.debug(f" [{i}] assistant tool_calls.ids = {ids}")
else:
self.logger.debug(
f" [{i}] tool tool_call_id = {msg.get('tool_call_id')}"
f" content = {str(msg.get('content', ''))[:50]}..."
)
2026-03-09 05:37:29 +00:00
overall_success = failed_step is None
chain_result = ChainResult(
goal=plan.goal,
step_results=step_results,
success=overall_success,
failed_step=failed_step,
)
self.logger.info(
f"{'' * 60}\n"
f" {'✅ 调用链执行完成' if overall_success else '⚠️ 调用链部分失败'}\n"
f" 完成: {chain_result.completed_steps}/{chain_result.total_steps}\n"
f"{'' * 60}"
)
2026-03-09 05:40:27 +00:00
return chain_result, openai_tool_block
2026-03-09 05:37:29 +00:00
def _execute_single_step(
self,
2026-03-09 05:40:27 +00:00
step: ToolStep,
chain_context: dict[str, str],
call_id: str,
) -> StepResult:
"""执行单个步骤,返回 StepResult"""
resolved_step = step.inject_context(chain_context)
2026-03-09 05:37:29 +00:00
self.logger.info(
f"\n ▶ Step {step.step_id} 执行中\n"
2026-03-09 05:40:27 +00:00
f" 工具 : [{resolved_step.tool_name}]\n"
f" 说明 : {resolved_step.description}\n"
f" 参数 : {resolved_step.arguments}\n"
f" call_id : {call_id}"
2026-03-09 05:37:29 +00:00
)
2026-03-09 05:40:27 +00:00
mcp_request: MCPRequest = resolved_step.to_mcp_request()
2026-03-09 05:37:29 +00:00
mcp_response: MCPResponse = self.mcp_server.handle_request(mcp_request)
if mcp_response.success:
output = mcp_response.content
self.logger.info(f" ✅ Step {step.step_id} 成功: {output[:80]}...")
return StepResult(
step_id=step.step_id,
tool_name=step.tool_name,
success=True,
output=output,
2026-03-09 05:40:27 +00:00
)
2026-03-09 05:37:29 +00:00
else:
error_msg = mcp_response.error.get("message", "未知错误")
self.logger.error(f" ❌ Step {step.step_id} 失败: {error_msg}")
return StepResult(
step_id=step.step_id,
tool_name=step.tool_name,
2026-02-28 08:21:35 +00:00
success=False,
2026-03-09 05:37:29 +00:00
output="",
2026-02-28 08:21:35 +00:00
error=error_msg,
2026-03-09 05:40:27 +00:00
)
2026-03-09 05:37:29 +00:00
# ════════════════════════════════════════════════════════════
# 回复生成
# ════════════════════════════════════════════════════════════
def _generate_response(
self,
2026-03-09 05:40:27 +00:00
user_input: str,
chain_result: ChainResult,
openai_tool_block: list[dict],
context: str,
2026-03-09 05:37:29 +00:00
) -> AgentResponse:
"""调用 OpenAI 整合工具结果,生成最终 AgentResponse"""
2026-03-09 05:40:27 +00:00
self.logger.info("✍️ [LLM] 调用 OpenAI 生成最终回复...")
2026-03-09 05:37:29 +00:00
chain_summary = self._build_chain_summary(chain_result)
2026-03-09 05:40:27 +00:00
# 将合规的消息块传给 LLMEngine
final_reply = self.llm.generate_chain_reply(
user_input=user_input,
chain_summary=chain_summary,
context=context,
openai_tool_block=openai_tool_block, # ← 传递完整合规消息块
)
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
chain_result.final_reply = final_reply
2026-02-28 08:21:35 +00:00
2026-03-09 05:40:27 +00:00
# 更新跨轮次 OpenAI 历史(只保留 user/assistant 摘要,不含 tool 消息)
2026-03-09 05:37:29 +00:00
self._openai_history.append({"role": "user", "content": user_input})
self._openai_history.append({"role": "assistant", "content": final_reply})
if len(self._openai_history) > 20:
self._openai_history = self._openai_history[-20:]
2026-02-28 08:21:35 +00:00
2026-03-09 05:37:29 +00:00
if chain_result.total_steps > 1:
self.memory.add_chain_result(chain_result)
else:
self.memory.add_assistant_message(final_reply)
2026-03-09 05:40:27 +00:00
self.logger.info("🎉 流程完成,回复已返回")
2026-02-28 08:21:35 +00:00
return AgentResponse(
user_input=user_input,
final_reply=final_reply,
2026-03-09 05:37:29 +00:00
chain_result=chain_result,
2026-03-09 05:40:27 +00:00
tool_used=(chain_result.step_results[0].tool_name
if chain_result.total_steps == 1 else None),
tool_output=(chain_result.step_results[0].output
if chain_result.total_steps == 1 else None),
2026-03-09 05:37:29 +00:00
success=chain_result.success,
2026-02-28 08:21:35 +00:00
)
2026-03-09 05:37:29 +00:00
def _handle_direct_reply(self, user_input: str, context: str) -> AgentResponse:
"""无需工具时直接调用 OpenAI 生成回复"""
self.logger.info("💬 无需工具,直接调用 OpenAI 生成回复")
reply = self.llm.generate_direct_reply(user_input, context)
self.memory.add_assistant_message(reply)
self._openai_history.append({"role": "user", "content": user_input})
self._openai_history.append({"role": "assistant", "content": reply})
return AgentResponse(user_input=user_input, final_reply=reply)
# ════════════════════════════════════════════════════════════
2026-03-09 05:40:27 +00:00
# 消息构造工具方法
2026-03-09 05:37:29 +00:00
# ════════════════════════════════════════════════════════════
@staticmethod
2026-03-09 05:40:27 +00:00
def _build_assistant_message(
plan: ChainPlan,
step_call_ids: dict[int, str],
) -> dict:
2026-03-09 05:37:29 +00:00
"""
2026-03-09 05:40:27 +00:00
构造 assistant 消息一次性声明全部 tool_calls
正确格式:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123", 与后续 tool 消息的 tool_call_id 完全一致
"type": "function",
"function": {
"name": "calculator",
"arguments": "{\"expression\": \"1+2\"}" 必须是 JSON 字符串
}
2026-03-09 05:37:29 +00:00
}
2026-03-09 05:40:27 +00:00
]
}
2026-03-09 05:37:29 +00:00
"""
tool_calls = []
for step in plan.steps:
tool_calls.append({
2026-03-09 05:40:27 +00:00
"id": step_call_ids[step.step_id],
2026-03-09 05:37:29 +00:00
"type": "function",
"function": {
"name": step.tool_name,
"arguments": json.dumps(step.arguments, ensure_ascii=False),
},
})
2026-03-09 05:40:27 +00:00
return {
"role": "assistant",
"content": None,
"tool_calls": tool_calls,
}
2026-03-09 05:37:29 +00:00
@staticmethod
def _build_chain_summary(chain_result: ChainResult) -> str:
2026-03-09 05:40:27 +00:00
"""将调用链结果格式化为 LLM 可读的摘要(降级时使用)"""
2026-03-09 05:37:29 +00:00
lines = []
for r in chain_result.step_results:
if r.success:
lines.append(
f"**Step {r.step_id} [{r.tool_name}]** ✅\n"
f"```\n{r.output[:300]}\n```"
)
else:
lines.append(
f"**Step {r.step_id} [{r.tool_name}]** ❌\n"
f"错误: {r.error}"
)
return "\n\n".join(lines)
@staticmethod
def _has_failed_dependency(step: ToolStep, failed_step: int | None) -> bool:
return failed_step is not None and failed_step in step.depends_on
2026-02-28 08:21:35 +00:00
def get_memory_stats(self) -> dict:
2026-03-09 05:37:29 +00:00
stats = self.memory.stats()
stats["openai_history_len"] = len(self._openai_history)
return stats
2026-02-28 08:21:35 +00:00
def clear_session(self) -> None:
self.memory.clear_history()
2026-03-09 05:37:29 +00:00
self._openai_history.clear()
self.logger.info("🗑 会话已清空(含 OpenAI 消息历史)")