fischer-agentkit/src/agentkit/core/dispatcher.py

416 lines
15 KiB
Python

"""任务分发器 - 通过 Redis Queue 将任务分发给 Agent
与业务系统解耦:通过依赖注入获取 Redis 连接和数据库会话。
"""
import ipaddress
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Awaitable
from urllib.parse import urlparse
from agentkit.core.exceptions import (
NoAvailableAgentError,
TaskDispatchError,
TaskNotFoundError,
)
from agentkit.core.protocol import (
AgentStatus,
TaskMessage,
TaskProgress,
TaskResult,
TaskStatus,
)
logger = logging.getLogger(__name__)
_PRIVATE_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _validate_callback_url(url: str) -> bool:
"""Validate callback URL to prevent SSRF attacks.
Rules:
- Only http/https protocols allowed
- No localhost or loopback addresses
- No private/internal IP ranges
- No link-local addresses
Returns True if valid, False if should be blocked.
"""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
hostname = parsed.hostname
if not hostname:
return False
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
return False
try:
ip = ipaddress.ip_address(hostname)
for network in _PRIVATE_NETWORKS:
if ip in network:
return False
except ValueError:
pass
return True
class TaskDispatcher:
"""任务分发器,通过 Redis Queue 将任务分发给 Agent"""
def __init__(
self,
redis_factory: Callable[[], Awaitable[Any]],
session_factory: Callable[[], Any],
agent_model: Any,
task_model: Any,
task_log_model: Any,
):
"""
Args:
redis_factory: 返回 Redis 连接的异步工厂
session_factory: 返回 async context manager 的工厂,用于获取数据库会话
agent_model: Agent ORM 模型类
task_model: AgentTask ORM 模型类
task_log_model: AgentTaskLog ORM 模型类
"""
self._redis_factory = redis_factory
self._session_factory = session_factory
self._agent_model = agent_model
self._task_model = task_model
self._task_log_model = task_log_model
async def _get_redis(self):
return await self._redis_factory()
async def dispatch(
self,
task: TaskMessage,
organization_id: str | None = None,
created_by: str | None = None,
) -> str:
"""分发任务到对应 Agent 的队列,返回 task_id"""
async with self._session_factory() as db:
try:
from sqlalchemy import select
AgentModel = self._agent_model
TaskModel = self._task_model
# 查找目标 Agent
stmt = select(AgentModel).where(AgentModel.name == task.agent_name)
result = await db.execute(stmt)
agent = result.scalar_one_or_none()
if not agent:
raise TaskDispatchError(task.task_id, f"Agent '{task.agent_name}' not found")
if agent.status != AgentStatus.ONLINE:
raise TaskDispatchError(
task.task_id,
f"Agent '{task.agent_name}' is not online (status={agent.status})",
)
# 写入 agent_tasks 表
task_id_uuid = uuid.UUID(task.task_id)
agent_task = TaskModel(
id=task_id_uuid,
agent_id=agent.id,
task_type=task.task_type,
status=TaskStatus.PENDING,
priority=task.priority,
input_data=task.input_data,
organization_id=uuid.UUID(organization_id) if organization_id else agent.id,
created_by=uuid.UUID(created_by) if created_by else None,
)
db.add(agent_task)
await db.commit()
# 推送到 Redis Queue
redis = await self._get_redis()
queue_key = f"agent:{task.agent_name}:tasks"
await redis.lpush(queue_key, json.dumps(task.to_dict()))
logger.info(
f"Task {task.task_id} dispatched to agent '{task.agent_name}' "
f"(type={task.task_type}, priority={task.priority})"
)
return task.task_id
except TaskDispatchError:
raise
except Exception as e:
await db.rollback()
logger.error(f"Failed to dispatch task {task.task_id}: {e}")
raise TaskDispatchError(task.task_id, str(e))
async def cancel_task(self, task_id: str):
"""取消任务"""
async with self._session_factory() as db:
try:
from sqlalchemy import select
TaskModel = self._task_model
task_uuid = uuid.UUID(task_id)
stmt = select(TaskModel).where(TaskModel.id == task_uuid)
result = await db.execute(stmt)
task = result.scalar_one_or_none()
if not task:
raise TaskNotFoundError(task_id)
if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
logger.warning(f"Cannot cancel task {task_id} with status '{task.status}'")
return
task.status = TaskStatus.CANCELLED
task.completed_at = datetime.now(timezone.utc)
await db.commit()
await self._write_log(
db, task_id=task_id, agent_id=str(task.agent_id),
log_level="info", message="Task cancelled by user",
)
await db.commit()
logger.info(f"Task {task_id} cancelled")
except TaskNotFoundError:
raise
except Exception as e:
await db.rollback()
logger.error(f"Failed to cancel task {task_id}: {e}")
raise
async def get_task_status(self, task_id: str) -> dict:
"""获取任务状态"""
async with self._session_factory() as db:
from sqlalchemy import select
TaskModel = self._task_model
task_uuid = uuid.UUID(task_id)
stmt = select(TaskModel).where(TaskModel.id == task_uuid)
result = await db.execute(stmt)
task = result.scalar_one_or_none()
if not task:
raise TaskNotFoundError(task_id)
return self._task_to_dict(task)
async def handle_result(self, result: TaskResult):
"""处理 Agent 返回的结果"""
async with self._session_factory() as db:
try:
from sqlalchemy import select
TaskModel = self._task_model
task_uuid = uuid.UUID(result.task_id)
stmt = select(TaskModel).where(TaskModel.id == task_uuid)
db_result = await db.execute(stmt)
task = db_result.scalar_one_or_none()
if not task:
logger.error(f"Task {result.task_id} not found when handling result")
return
task.status = result.status
task.output_data = result.output_data
task.error_message = result.error_message
task.started_at = result.started_at
task.completed_at = result.completed_at
await db.commit()
log_level = "info" if result.status == TaskStatus.COMPLETED else "error"
log_message = (
f"Task {result.status}"
if result.status == TaskStatus.COMPLETED
else f"Task failed: {result.error_message}"
)
await self._write_log(
db,
task_id=result.task_id,
agent_id=str(task.agent_id),
log_level=log_level,
message=log_message,
extra_metadata=result.metrics,
)
await db.commit()
# 触发回调
if result.output_data and result.output_data.get("callback_url"):
await self._trigger_callback(result.output_data["callback_url"], result)
logger.info(f"Task {result.task_id} result handled (status={result.status})")
except Exception as e:
await db.rollback()
logger.error(f"Failed to handle result for task {result.task_id}: {e}")
async def handle_progress(self, progress: TaskProgress):
"""处理进度上报"""
async with self._session_factory() as db:
try:
from sqlalchemy import select
AgentModel = self._agent_model
stmt = select(AgentModel).where(AgentModel.name == progress.agent_name)
result = await db.execute(stmt)
agent = result.scalar_one_or_none()
if not agent:
logger.warning(f"Agent '{progress.agent_name}' not found for progress report")
return
await self._write_log(
db,
task_id=progress.task_id,
agent_id=str(agent.id),
log_level="info",
message=f"Progress: {progress.progress:.0%} - {progress.message}",
extra_metadata={
"progress": progress.progress,
"updated_at": progress.updated_at.isoformat(),
},
)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"Failed to handle progress for task {progress.task_id}: {e}")
async def retry_failed_tasks(self, max_retries: int = 3):
"""重试失败的任务"""
async with self._session_factory() as db:
try:
from sqlalchemy import select
TaskModel = self._task_model
AgentModel = self._agent_model
LogModel = self._task_log_model
stmt = select(TaskModel).where(TaskModel.status == TaskStatus.FAILED)
result = await db.execute(stmt)
failed_tasks = result.scalars().all()
retried = 0
for task in failed_tasks:
log_stmt = select(LogModel).where(
LogModel.task_id == task.id,
LogModel.message.like("%retry%"),
)
log_result = await db.execute(log_stmt)
retry_count = len(log_result.scalars().all())
if retry_count < max_retries:
task.status = TaskStatus.PENDING
task.error_message = None
task.started_at = None
task.completed_at = None
agent_stmt = select(AgentModel).where(AgentModel.id == task.agent_id)
agent_result = await db.execute(agent_stmt)
agent = agent_result.scalar_one_or_none()
if agent and agent.status == AgentStatus.ONLINE:
task_msg = TaskMessage(
task_id=str(task.id),
agent_name=agent.name,
task_type=task.task_type,
priority=task.priority,
input_data=task.input_data or {},
callback_url=None,
created_at=datetime.now(timezone.utc),
)
redis = await self._get_redis()
queue_key = f"agent:{agent.name}:tasks"
await redis.lpush(queue_key, json.dumps(task_msg.to_dict()))
await self._write_log(
db,
task_id=str(task.id),
agent_id=str(agent.id),
log_level="info",
message=f"Task retry attempt {retry_count + 1}/{max_retries}",
)
retried += 1
await db.commit()
if retried > 0:
logger.info(f"Retried {retried} failed tasks")
except Exception as e:
await db.rollback()
logger.error(f"Failed to retry failed tasks: {e}")
async def _write_log(
self,
db: Any,
task_id: str,
agent_id: str,
log_level: str,
message: str,
extra_metadata: dict | None = None,
):
LogModel = self._task_log_model
log_entry = LogModel(
task_id=uuid.UUID(task_id),
agent_id=uuid.UUID(agent_id),
log_level=log_level,
message=message,
extra_metadata=extra_metadata,
)
db.add(log_entry)
async def _trigger_callback(self, callback_url: str, result: TaskResult):
if not _validate_callback_url(callback_url):
logger.warning(f"Callback URL rejected (SSRF protection): {callback_url}")
return
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
await client.post(callback_url, json=result.to_dict())
logger.info(f"Callback triggered for task {result.task_id}")
except Exception as e:
logger.warning(f"Callback failed for task {result.task_id}: {e}")
def _task_to_dict(self, task: Any) -> dict:
return {
"id": str(task.id),
"agent_id": str(task.agent_id),
"task_type": task.task_type,
"status": task.status,
"priority": task.priority,
"input_data": task.input_data,
"output_data": task.output_data,
"error_message": task.error_message,
"created_by": str(task.created_by) if task.created_by else None,
"organization_id": str(task.organization_id),
"project_id": str(task.project_id) if task.project_id else None,
"scheduled_at": task.scheduled_at.isoformat() if task.scheduled_at else None,
"started_at": task.started_at.isoformat() if task.started_at else None,
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
"created_at": task.created_at.isoformat() if task.created_at else None,
}