Files

492 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ACP Gateway - Agent Communication Protocol 内部实现
Phase G3: 多 Agent 协作编排层
将平台已有的 Agent(MarketApp type='agent')封装为可编排调用的"Agent 节点"。
每个 Agent 节点执行时,通过本 Gateway 创建会话、发送提示、接收流式响应。
支持通过 Redis EventBus 发布节点进度通知,供 WebSocket 实时推送到前端。
"""
import json
import uuid
import time
import logging
import asyncio
import threading
from typing import Optional, Dict, Any, Callable, AsyncIterator
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# ============================================================
# Data Models
# ============================================================
@dataclass
class AcpSessionState:
"""ACP 会话状态"""
session_id: str
agent_id: str # MarketApp id
agent_name: str = ""
status: str = "idle" # idle / running / completed / failed / cancelled
prompt: str = ""
created_at: float = field(default_factory=time.time)
completed_at: Optional[float] = None
output: str = ""
error: Optional[str] = None
tool_calls: list = field(default_factory=list)
chain_of_thought: list = field(default_factory=list) # 思考过程
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"status": self.status,
"prompt": self.prompt[:200],
"created_at": self.created_at,
"completed_at": self.completed_at,
"output": self.output[:500] if self.output else "",
"error": self.error,
"tool_calls": self.tool_calls,
"chain_of_thought": self.chain_of_thought,
}
@dataclass
class AgentConfig:
"""从 MarketApp.skill_content 解析出的 Agent 配置"""
agent_id: str = ""
agent_name: str = ""
system_prompt: str = ""
instructions: str = ""
model_id: Optional[str] = None
temperature: float = 0.7
max_tokens: int = 4096
skills: list = field(default_factory=list)
knowledge_base_ids: list = field(default_factory=list)
tools: list = field(default_factory=list)
# ============================================================
# Agent Resolver
# ============================================================
def resolve_agent_config(db, agent_id: str) -> Optional[AgentConfig]:
"""从 MarketApp 解析 Agent 配置"""
from app.models import MarketApp
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent"
).first()
if not agent:
return None
config_json = {}
if agent.skill_content:
try:
config_json = json.loads(agent.skill_content) if isinstance(agent.skill_content, str) else agent.skill_content
except Exception:
pass
return AgentConfig(
agent_id=agent.id,
agent_name=agent.name,
system_prompt=config_json.get("system_prompt", config_json.get("role", agent.description or "AI助手")),
instructions=config_json.get("instructions", ""),
model_id=config_json.get("model_id"),
temperature=float(config_json.get("temperature", 0.7)),
max_tokens=int(config_json.get("max_tokens", 4096)),
skills=config_json.get("skills", []),
knowledge_base_ids=config_json.get("knowledge_base_ids", []),
tools=config_json.get("tools", []),
)
def resolve_model_config(db, model_id: str) -> dict:
"""获取模型的 API 配置"""
from app.models import AIModel
model = db.query(AIModel).filter(AIModel.id == model_id).first()
if not model:
# Fallback to default model
default_model = db.query(AIModel).filter(AIModel.is_enabled == True).first()
if default_model:
model = default_model
else:
return {
"api_key": "",
"api_base": "https://api.siliconflow.cn/v1",
"model_name": "deepseek-ai/DeepSeek-V4-Flash",
}
return {
"api_key": model.api_key or "",
"api_base": model.api_base or "https://api.siliconflow.cn/v1",
"model_name": model.model_id or model.name,
}
# ============================================================
# ACP Gateway
# ============================================================
class AcpGateway:
"""Agent 通信网关 — 平台内部多 Agent 编排核心"""
def __init__(self):
self._sessions: Dict[str, AcpSessionState] = {}
self._lock = threading.Lock()
self._on_notification: Optional[Callable] = None
def set_notification_handler(self, handler: Callable[[dict], None]):
"""设置通知回调 — 用于发布到 Redis EventBus"""
self._on_notification = handler
def _notify(self, event_type: str, data: dict):
"""发送通知"""
if self._on_notification:
try:
self._on_notification({
"type": event_type,
"timestamp": time.time(),
**data,
})
except Exception as e:
logger.error(f"[AcpGateway] 通知回调异常: {e}")
# ---- Session Management ----
def create_session(self, agent_id: str, agent_name: str = "") -> str:
"""创建新的 Agent 会话"""
session_id = f"acp-{uuid.uuid4().hex[:12]}"
with self._lock:
self._sessions[session_id] = AcpSessionState(
session_id=session_id,
agent_id=agent_id,
agent_name=agent_name or agent_id,
status="idle",
)
logger.info(f"[AcpGateway] 创建会话: {session_id} (agent={agent_name or agent_id})")
return session_id
def get_session(self, session_id: str) -> Optional[AcpSessionState]:
"""获取会话"""
with self._lock:
return self._sessions.get(session_id)
def cancel_session(self, session_id: str) -> bool:
"""取消会话"""
with self._lock:
sess = self._sessions.get(session_id)
if sess and sess.status == "running":
sess.status = "cancelled"
sess.completed_at = time.time()
self._notify("agent_cancelled", sess.to_dict())
return True
return False
# ---- Agent Invocation ----
async def invoke_agent(
self,
agent_id: str,
prompt: str,
system_prompt: str = "",
model_id: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
context: Optional[dict] = None,
db_session=None,
) -> dict:
"""调用 Agent — 创建会话、发送提示、等待完成
这是一个高级封装,内部自动创建和管理会话生命周期。
返回完整结果字典,包含 output, tool_calls, chain_of_thought 等。
"""
import httpx
# Resolve agent config from DB
agent_name = agent_id
if db_session:
config = resolve_agent_config(db_session, agent_id)
if config:
agent_name = config.agent_name
system_prompt = system_prompt or config.system_prompt
model_id = model_id or config.model_id
temperature = config.temperature
max_tokens = config.max_tokens
# Create session
session_id = self.create_session(agent_id, agent_name)
sess = self.get_session(session_id)
# Resolve model
if db_session and model_id:
model_cfg = resolve_model_config(db_session, model_id)
else:
model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"}
# Update status
with self._lock:
sess.status = "running"
sess.prompt = prompt
self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name, "prompt": prompt[:200]})
try:
# Build messages
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
# Call LLM
headers = {
"Authorization": f"Bearer {model_cfg['api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": model_cfg["model_name"],
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{model_cfg['api_base']}/chat/completions",
headers=headers,
json=payload,
)
resp.raise_for_status()
data = resp.json()
output = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
with self._lock:
sess.status = "completed"
sess.output = output
sess.completed_at = time.time()
result = {
"session_id": session_id,
"status": "completed",
"output": output,
"agent_name": agent_name,
"usage": usage,
"tool_calls": sess.tool_calls,
"chain_of_thought": sess.chain_of_thought,
}
self._notify("agent_completed", {"session_id": session_id, "output_preview": output[:200]})
return result
except Exception as e:
error_msg = str(e)
with self._lock:
sess.status = "failed"
sess.error = error_msg
sess.completed_at = time.time()
result = {
"session_id": session_id,
"status": "failed",
"output": f"[Agent] 调用失败: {error_msg}",
"agent_name": agent_name,
"error": error_msg,
}
self._notify("agent_failed", {"session_id": session_id, "error": error_msg})
return result
async def invoke_agent_streaming(
self,
agent_id: str,
prompt: str,
system_prompt: str = "",
model_id: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
context: Optional[dict] = None,
db_session=None,
) -> AsyncIterator[dict]:
"""流式调用 Agent — 逐步返回 LLM 生成的 token chunks
每个 yield 的 dict 格式:
{"type": "token"|"done"|"error", "content": str, ...}
"""
import httpx
# Resolve agent config
agent_name = agent_id
if db_session:
config = resolve_agent_config(db_session, agent_id)
if config:
agent_name = config.agent_name
system_prompt = system_prompt or config.system_prompt
model_id = model_id or config.model_id
temperature = config.temperature
max_tokens = config.max_tokens
session_id = self.create_session(agent_id, agent_name)
sess = self.get_session(session_id)
if db_session and model_id:
model_cfg = resolve_model_config(db_session, model_id)
else:
model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"}
with self._lock:
sess.status = "running"
sess.prompt = prompt
self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name})
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
headers = {
"Authorization": f"Bearer {model_cfg['api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": model_cfg["model_name"],
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
collected = []
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream("POST", f"{model_cfg['api_base']}/chat/completions",
headers=headers, json=payload) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
collected.append(content)
yield {"type": "token", "content": content, "session_id": session_id}
# Notify periodically (every ~20 tokens)
if len(collected) % 20 == 0:
self._notify("agent_token", {
"session_id": session_id,
"partial": "".join(collected[-20:]),
})
except json.JSONDecodeError:
continue
full_output = "".join(collected)
with self._lock:
sess.status = "completed"
sess.output = full_output
sess.completed_at = time.time()
self._notify("agent_completed", {"session_id": session_id, "output_preview": full_output[:200]})
yield {"type": "done", "session_id": session_id, "output": full_output, "status": "completed"}
except Exception as e:
error_msg = str(e)
with self._lock:
sess.status = "failed"
sess.error = error_msg
sess.completed_at = time.time()
self._notify("agent_failed", {"session_id": session_id, "error": error_msg})
yield {"type": "error", "session_id": session_id, "error": error_msg, "status": "failed"}
# ---- Multi-Agent Parallel Execution ----
async def invoke_agents_parallel(
self,
tasks: list,
db_session=None,
) -> list:
"""并行调用多个 Agent
tasks = [
{"agent_id": "xxx", "prompt": "...", "system_prompt": "..."},
{"agent_id": "yyy", "prompt": "...", "system_prompt": "..."},
]
Returns list of results in same order.
"""
async def _invoke_one(task: dict) -> dict:
try:
return await self.invoke_agent(
agent_id=task["agent_id"],
prompt=task.get("prompt", ""),
system_prompt=task.get("system_prompt", ""),
model_id=task.get("model_id"),
temperature=task.get("temperature", 0.7),
max_tokens=task.get("max_tokens", 4096),
context=task.get("context"),
db_session=db_session,
)
except Exception as e:
return {
"status": "failed",
"output": f"[Agent {task.get('agent_id', 'unknown')}] 执行异常: {str(e)}",
"error": str(e),
}
return await asyncio.gather(*[_invoke_one(t) for t in tasks])
# ============================================================
# Global Singleton
# ============================================================
_acp_gateway_instance: Optional[AcpGateway] = None
_lock_gateway = threading.Lock()
def get_acp_gateway() -> AcpGateway:
"""获取全局 ACP Gateway 单例"""
global _acp_gateway_instance
if _acp_gateway_instance is None:
with _lock_gateway:
if _acp_gateway_instance is None:
_acp_gateway_instance = AcpGateway()
return _acp_gateway_instance
# ============================================================
# Redis Notification Helper
# ============================================================
def publish_workflow_event(workflow_id: str, node_id: str, event: dict):
"""发布工作流执行事件到 Redis EventBus"""
try:
from app.event_bus import get_event_bus, MessageEvent
bus = get_event_bus()
msg = MessageEvent(
platform="workflow",
channel_id=f"{workflow_id}:{node_id}",
content=json.dumps(event, ensure_ascii=False),
content_type="workflow_event",
raw={
"workflow_id": workflow_id,
"node_id": node_id,
**event,
},
)
bus.publish(msg)
return True
except Exception as e:
logger.error(f"[AcpGateway] Redis 发布失败: {e}")
return False