631 lines
32 KiB
Python
631 lines
32 KiB
Python
import asyncio
|
||
import contextlib
|
||
import logging
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any, Protocol, cast
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import select, update
|
||
|
||
from app.core.config import Settings, get_settings
|
||
from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext
|
||
from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency
|
||
from app.repository.agent_run_repository import AgentRunRepository
|
||
from app.service.agent.bootstrap import (
|
||
get_agent_factory,
|
||
get_memory_cache_adapter,
|
||
get_model_service,
|
||
)
|
||
from app.service.agent.executor import AgentExecutor
|
||
from app.service.agent.factory import AgentFactory
|
||
from app.service.agent_persistence_service import AgentPersistenceService
|
||
from app.service.customer_service_session_memory_service import (
|
||
CustomerServiceSessionMemory,
|
||
CustomerServiceSessionTurn,
|
||
build_customer_service_session_memory,
|
||
)
|
||
from app.service.identity_service import IdentityService
|
||
from app.service.memory_extraction_service import (
|
||
ExtractionEndpointResolver,
|
||
MemoryExtractionService,
|
||
)
|
||
from app.service.memory_lifecycle_service import MemoryLifecycleService, Mode
|
||
from app.service.memory_recall_service import MemoryRecallService
|
||
from app.service.memory_service import CacheDeleteAdapter, MemoryService
|
||
from app.service.memory_taxonomy import BUSINESS_EVENT_TYPES
|
||
from app.service.model_gateway import DatabaseModelEndpointResolver, ModelGenerationService
|
||
from app.worker.customer_profile_candidate_worker import CustomerProfileCandidateWorker
|
||
from app.worker.episode_worker import (
|
||
EpisodeConsumptionResult,
|
||
EpisodeExtractionConsumer,
|
||
EpisodeWorker,
|
||
)
|
||
from app.worker.memory_extraction_worker import MemoryExtractionWorker
|
||
from app.worker.outbox_worker import OutboxWorker
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# episode 聚合按轮次节流:每 N 轮顺带处理一次已静默的会话片段,
|
||
# 避免每轮轮询都做一次客户维度的 distinct 查询。
|
||
EPISODE_INTERVAL_ROUNDS = 30
|
||
|
||
# 一轮内最多消费的事件条数。`OutboxWorker.publish_one` 每次只领一条(单条事务让幂等与
|
||
# skip_locked 锁语义保持简单),所以清空速度原先等于轮询速度:262 条积压要 262 轮。
|
||
OUTBOX_DISPATCH_LIMIT = 10
|
||
# 单轮消费的片段条数上限:批处理必须可中断,不能一次吃完整库。
|
||
EPISODE_CONSUME_LIMIT = 20
|
||
PROJECTION_AUDIT_ACTION = "memory.projection_cleanup"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProjectionCleanupOutcome:
|
||
"""投影清理结果;`cleaned=False` 时 `detail` 必须说明真实缺口。"""
|
||
|
||
cleaned: bool
|
||
detail: str = ""
|
||
|
||
|
||
class ProjectionCleaner(Protocol):
|
||
"""Milvus/Neo4j 投影清理边界:由组装层注入,未注入即显式降级(绝不伪造成功)。"""
|
||
|
||
async def cleanup(self, *, memory_uuid: str, operation: str) -> ProjectionCleanupOutcome: ...
|
||
|
||
|
||
class WorkerRuntime:
|
||
def __init__(
|
||
self, factory: AgentFactory | None = None, settings: Settings | None = None,
|
||
resolve_identity: Callable[[RequestContext], Awaitable[RequestContext]] | None = None,
|
||
model_service: ModelGenerationService | None = None,
|
||
endpoint_resolver: ExtractionEndpointResolver | None = None,
|
||
memory_cache: CacheDeleteAdapter | None = None,
|
||
projection_cleaner: ProjectionCleaner | None = None,
|
||
session_memory: CustomerServiceSessionMemory | None = None,
|
||
) -> None:
|
||
self.factory = factory if factory is not None else get_agent_factory()
|
||
self.settings = settings or get_settings()
|
||
self.resolve_identity = resolve_identity or IdentityService().resolve
|
||
# 记忆抽取必须走与业务 Agent 相同的模型路由入口。默认取生产装配
|
||
# (app/service/agent/bootstrap.py),允许构造参数注入替身模型与端点解析器,
|
||
# 使验收探针不依赖库中真实模型端点配置。
|
||
self.model_service = model_service if model_service is not None else get_model_service()
|
||
self.memory_extraction = MemoryExtractionService(
|
||
self.model_service,
|
||
endpoint_resolver if endpoint_resolver is not None else DatabaseModelEndpointResolver(),
|
||
)
|
||
# 召回热缓存适配器:任何记忆写入(事件消费、片段消费)都必须让该客户的
|
||
# 召回热缓存失效,否则新记忆在 TTL 内召回不到。默认取生产装配,可注入替身。
|
||
self.memory_cache: CacheDeleteAdapter | None = (
|
||
memory_cache if memory_cache is not None else get_memory_cache_adapter()
|
||
)
|
||
# Milvus/Neo4j 删除客户端:当前组装层没有提供(bootstrap 只装配召回用的读适配器),
|
||
# 因此默认 None = 投影清理显式降级并留痕,绝不写成"删除成功"。
|
||
self.projection_cleaner = projection_cleaner
|
||
# 该组件是客服当前会话 Redis List,不是长期记忆召回缓存或客户画像。
|
||
self.session_memory = (
|
||
session_memory
|
||
if session_memory is not None
|
||
else build_customer_service_session_memory()
|
||
)
|
||
# episode 聚合是低频批处理,按轮次节流而不是每轮都查。
|
||
self._episode_rounds = 0
|
||
|
||
async def restore_context(
|
||
self, *, actor_type: str, actor_id: str, trace_id: str
|
||
) -> RequestContext:
|
||
"""按已验证的内部事件身份恢复执行上下文。"""
|
||
identity = RequestContext(user_id=actor_id, trace_id=trace_id)
|
||
if actor_type == "visitor":
|
||
return identity.model_copy(update={
|
||
"roles": ("visitor",),
|
||
# 与访客 JWT 对齐,只恢复公开 Agent 和公开知识的最小权限。
|
||
"permissions": ("agent:run", "knowledge:query"),
|
||
"data_scope": "public",
|
||
})
|
||
return await self.resolve_identity(identity)
|
||
|
||
@staticmethod
|
||
def should_request_memory_extraction(
|
||
*, agent_type: str, context: RequestContext, message: str, result: AgentResult,
|
||
business_events: tuple[str, ...] | list[str],
|
||
) -> bool:
|
||
"""只允许非客服 Agent 的已登录用户业务事实进入客户记忆抽取队列。"""
|
||
# 客服只使用未来受控的会话短期上下文,绝不沉淀为长期画像或客户偏好。
|
||
if agent_type == "customer_service" or "visitor" in context.roles:
|
||
return False
|
||
return MemoryService.should_extract_memory(
|
||
conversation_content=message,
|
||
role="user",
|
||
tool_result=any(call.status == "succeeded" for call in result.result.tool_calls),
|
||
event_type=business_events[0] if business_events else None,
|
||
signals=MemoryService.detect_memory_signals(message),
|
||
)
|
||
|
||
@staticmethod
|
||
def should_request_profile_candidate(
|
||
*, agent_type: str, context: RequestContext, message: str,
|
||
) -> bool:
|
||
"""客服只为已登录客户生成候选,不为访客生成正式画像数据。"""
|
||
if agent_type != "customer_service" or "visitor" in context.roles:
|
||
return False
|
||
if not {"customer", "authenticated_user"}.intersection(context.roles):
|
||
return False
|
||
if context.data_scope != "self":
|
||
return False
|
||
# 只有明确的长期偏好、约束或目标才进入候选队列,普通问答不触发模型抽取。
|
||
return bool(MemoryService.detect_memory_signals(message))
|
||
|
||
async def dispatch_one(self, *, run_id: str | None = None) -> bool:
|
||
# Outbox acknowledges a durable SQL queue entry, not an in-memory task.
|
||
async with SessionFactory() as session:
|
||
async def dispatch(payload: dict[str, Any]) -> None:
|
||
run = await AgentRunRepository(session).get(str(payload["run_id"]))
|
||
if run is None:
|
||
raise ValueError("run not found")
|
||
|
||
async def dispatch_memory_extraction(payload: dict[str, Any]) -> None:
|
||
if "message_id" not in payload or "customer_id" not in payload:
|
||
raise ValueError("memory extraction payload is incomplete")
|
||
# 幂等键只认事件 id,由 worker 自己按 payload 回查,避免调用方漏传。
|
||
# 注入召回缓存适配器:写入生效后立即失效该客户的热缓存。
|
||
await MemoryExtractionWorker(
|
||
session, extractor=self.memory_extraction, cache=self.memory_cache
|
||
).handle(payload)
|
||
|
||
async def dispatch_profile_candidate(payload: dict[str, Any]) -> None:
|
||
if "message_id" not in payload or "customer_id" not in payload:
|
||
raise ValueError("profile candidate payload is incomplete")
|
||
await CustomerProfileCandidateWorker(
|
||
session, extractor=self.memory_extraction, cache=self.memory_cache
|
||
).handle(payload)
|
||
|
||
async def dispatch_run_completed(payload: dict[str, Any]) -> None:
|
||
# 结果消息与审计已由 complete_run 同事务落库,此事件只承担
|
||
# "运行已完成"的对外通知职责。当前没有独立外部消费者,
|
||
# 这里显式消费以免事件永久滞留;接入推送链路时在此处扩展。
|
||
if not str(payload.get("run_id", "")):
|
||
raise ValueError("agent.run_completed payload is incomplete")
|
||
|
||
async def dispatch_cache_invalidate(payload: dict[str, Any]) -> None:
|
||
await self._invalidate_config_cache(payload)
|
||
|
||
async def dispatch_projection_cleanup(payload: dict[str, Any]) -> None:
|
||
# memory.invalidated / memory.deleted 由 MemoryLifecycleService 按
|
||
# memory_uuid 写入,这里做幂等的投影清理(Milvus 向量、Neo4j 关系)。
|
||
await self._cleanup_projection(payload, session=session)
|
||
|
||
async def dispatch_memory_deletion(payload: dict[str, Any]) -> None:
|
||
# 客户级联失效/删除的公共入口:业务侧(用户注销、合规删除令)
|
||
# 只写 memory.deletion_requested 事件,不直接操作记忆表。
|
||
customer_id = payload.get("customer_id")
|
||
if not customer_id:
|
||
raise ValueError("memory.deletion_requested payload is incomplete")
|
||
mode = str(payload.get("mode", "invalidate"))
|
||
if mode not in {"invalidate", "delete"}:
|
||
raise ValueError("memory.deletion_requested mode is invalid")
|
||
await MemoryLifecycleService(session).run(
|
||
int(customer_id),
|
||
mode=cast("Mode", mode),
|
||
reason=str(payload.get("reason", "customer_lifecycle")),
|
||
trace_id=str(payload.get("trace_id", "")),
|
||
)
|
||
|
||
async def dispatch_handover_queue_ready(payload: dict[str, Any]) -> None:
|
||
# 事件成功消费只表示管理员队列可查看,不能修改 pending 状态,更不能向用户
|
||
# 表述人工已经接单、处理或承诺时效。
|
||
ticket_no = str(payload.get("ticket_no", "")).strip()
|
||
if not ticket_no:
|
||
raise ValueError("conversation.transfer_requested payload is incomplete")
|
||
ticket = await session.scalar(
|
||
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
|
||
)
|
||
if ticket is None:
|
||
raise ValueError("handover ticket not found")
|
||
session.add(InteractionAudit(
|
||
actor_type="system",
|
||
actor_id=None,
|
||
target_customer_id=ticket.customer_id,
|
||
session_id=ticket.session_id,
|
||
portal="worker",
|
||
action_type="handover.queue_ready",
|
||
detail={
|
||
"ticket_no": ticket.ticket_no,
|
||
"source_agent": ticket.source_agent,
|
||
"reason_code": ticket.reason_code,
|
||
"ticket_status": ticket.status,
|
||
},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
))
|
||
await session.flush()
|
||
|
||
handlers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {
|
||
"agent.run_requested": dispatch,
|
||
"memory.extraction_requested": dispatch_memory_extraction,
|
||
"customer_profile.candidate_requested": dispatch_profile_candidate,
|
||
"agent.run_completed": dispatch_run_completed,
|
||
"config.cache_invalidate_requested": dispatch_cache_invalidate,
|
||
"memory.deletion_requested": dispatch_memory_deletion,
|
||
# 投影清理:这两类事件此前没有消费者,永久 pending。
|
||
"memory.invalidated": dispatch_projection_cleanup,
|
||
"memory.deleted": dispatch_projection_cleanup,
|
||
"conversation.transfer_requested": dispatch_handover_queue_ready,
|
||
}
|
||
return await OutboxWorker(session, handlers).publish_one(aggregate_id=run_id)
|
||
|
||
async def dispatch_batch(self, *, limit: int = OUTBOX_DISPATCH_LIMIT) -> int:
|
||
"""一轮内尽量多消费事件,返回实际消费条数。
|
||
|
||
单条领取的语义与幂等边界都不变,只是不再让"每轮一条"限制清空速度:
|
||
队列空时提前退出,因此稳态下与原来的一次调用开销相同,积压时才提速。
|
||
"""
|
||
consumed = 0
|
||
for _ in range(max(1, limit)):
|
||
if not await self.dispatch_one():
|
||
break
|
||
consumed += 1
|
||
return consumed
|
||
|
||
async def _invalidate_config_cache(self, payload: dict[str, Any]) -> None:
|
||
"""删除发布配置与记忆召回热缓存键;Redis 不可用时只记录告警,不阻塞事件消费。
|
||
|
||
记忆召回热缓存的键必须由 `MemoryRecallService.cache_keys` 枚举:此前这里手写的
|
||
`mem:mid:hot:{customer_id}` 并不是召回缓存的真实前缀(真实前缀是 `mem:recall`),
|
||
失效动作一直打在并不存在的键上。事件 payload 里没有查询摘要与 limit,客户级
|
||
枚举是唯一可靠的失效方式(召回热缓存本身也只服务常见参数组合)。
|
||
"""
|
||
release_id = payload.get("release_id")
|
||
raw_customer_id = payload.get("customer_id")
|
||
keys: list[str] = []
|
||
if raw_customer_id:
|
||
keys.extend(MemoryRecallService.cache_keys(int(raw_customer_id)))
|
||
if release_id is not None:
|
||
keys.append(f"config:release:{release_id}")
|
||
if not keys:
|
||
return
|
||
client = await self._redis_client()
|
||
if client is None:
|
||
return
|
||
try:
|
||
await client.delete(*keys)
|
||
except Exception:
|
||
logger.warning("config cache invalidation degraded keys=%s", ",".join(keys))
|
||
finally:
|
||
await client.aclose()
|
||
|
||
async def _cleanup_projection(self, payload: dict[str, Any], *, session: Any) -> None:
|
||
"""幂等清理一条记忆的派生投影(Milvus 向量、Neo4j 关系)。
|
||
|
||
为什么选择"显式降级 + 留痕"而不是假装成功:当前组装层没有提供任何删除客户端
|
||
(`bootstrap` 只装配了召回用的 Milvus 读适配器,Neo4j 连读适配器都没有),
|
||
伪造"已删除"会让合规删除令在投影侧静默失效。因此无客户端时只做两件事:
|
||
记录告警,并写一条 `interaction_audit`(`status=skipped_no_client`),事件本身
|
||
照常标记为已消费——权威库(MySQL)状态已经正确,投影是可重建的派生数据,
|
||
让事件永久 pending 只会阻塞队列里其它事件。
|
||
注入 `projection_cleaner` 后同一入口执行真实删除,审计只记录适配器返回的真实
|
||
结论(`cleaned` 为假即写 `skipped`),不做任何"假定成功"的兜底。
|
||
"""
|
||
memory_uuid = str(payload.get("memory_uuid", "")).strip()
|
||
if not memory_uuid:
|
||
raise ValueError("memory projection cleanup payload is incomplete")
|
||
operation = str(payload.get("operation", "invalidate"))
|
||
raw_customer_id = payload.get("customer_id")
|
||
customer_id = int(raw_customer_id) if raw_customer_id else None
|
||
cleaner = self.projection_cleaner
|
||
if cleaner is None:
|
||
logger.warning(
|
||
"projection cleanup degraded: milvus/neo4j delete client not configured "
|
||
"memory_uuid=%s operation=%s", memory_uuid, operation,
|
||
)
|
||
await self._audit_projection(
|
||
session, memory_uuid, customer_id, operation,
|
||
status="skipped_no_client", reason="projection delete client not configured",
|
||
)
|
||
return
|
||
outcome = await cleaner.cleanup(memory_uuid=memory_uuid, operation=operation)
|
||
if outcome.cleaned:
|
||
logger.info("projection cleanup done memory_uuid=%s operation=%s",
|
||
memory_uuid, operation)
|
||
else:
|
||
logger.warning("projection cleanup degraded memory_uuid=%s reason=%s",
|
||
memory_uuid, outcome.detail)
|
||
await self._audit_projection(
|
||
session, memory_uuid, customer_id, operation,
|
||
status="cleaned" if outcome.cleaned else "skipped", reason=outcome.detail,
|
||
)
|
||
|
||
async def _audit_projection(
|
||
self, session: Any, memory_uuid: str, customer_id: int | None, operation: str,
|
||
*, status: str, reason: str,
|
||
) -> None:
|
||
"""投影清理留痕:审计行与事件同事务提交,降级状态在库里可查。"""
|
||
session.add(InteractionAudit(
|
||
actor_type="system",
|
||
actor_id=None,
|
||
target_customer_id=customer_id,
|
||
session_id=None,
|
||
portal=None,
|
||
action_type=PROJECTION_AUDIT_ACTION,
|
||
detail={
|
||
"memory_uuid": memory_uuid,
|
||
"operation": operation,
|
||
"status": status,
|
||
"reason": reason,
|
||
},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
))
|
||
await session.flush()
|
||
|
||
async def _redis_client(self) -> Any:
|
||
try:
|
||
from redis.asyncio import Redis
|
||
except ImportError:
|
||
logger.warning("redis client unavailable; cache invalidation skipped")
|
||
return None
|
||
client: Any = Redis.from_url(
|
||
self.settings.redis_url,
|
||
socket_connect_timeout=self.settings.redis_connect_timeout_seconds,
|
||
socket_timeout=self.settings.redis_connect_timeout_seconds,
|
||
)
|
||
return client
|
||
|
||
async def run_once(self) -> bool:
|
||
dispatched = await self.dispatch_batch() > 0
|
||
self._episode_rounds += 1
|
||
if self._episode_rounds % EPISODE_INTERVAL_ROUNDS == 0:
|
||
# 会话片段聚合:内部幂等(content_hash 唯一键),失败只告警,
|
||
# 不得影响 run 的处理与轮询节奏。
|
||
try:
|
||
await self.aggregate_episodes()
|
||
except Exception:
|
||
logger.warning("episode aggregation failed", exc_info=True)
|
||
# 聚合之后立刻消费:片段只有被消费才会变成记忆,否则"待提取"永久滞留。
|
||
try:
|
||
await self.consume_episodes()
|
||
except Exception:
|
||
logger.warning("episode extraction failed", exc_info=True)
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
async with SessionFactory() as session:
|
||
run_id = await session.scalar(select(AgentRun.run_id).where(
|
||
AgentRun.status.in_(("queued", "running", "cancel_requested")),
|
||
(AgentRun.locked_until.is_(None) | (AgentRun.locked_until < now)),
|
||
).order_by(AgentRun.created_at).limit(1))
|
||
if run_id is None:
|
||
return dispatched
|
||
return await self.execute(run_id) or dispatched
|
||
|
||
async def aggregate_episodes(self, *, customer_limit: int = 50) -> int:
|
||
"""把已静默的会话片段聚合为 episode,返回新写入的片段数。
|
||
|
||
可重复调用:同一片段的 `content_hash` 命中唯一键时直接跳过,
|
||
不需要额外的幂等表。
|
||
"""
|
||
# 必须由调用方开启并提交事务:EpisodeWorker 只做 add/flush(savepoint 幂等),
|
||
# 自己不提交。漏掉提交会同时出现"返回已插入"与"表里没有行"。
|
||
async with SessionFactory() as session, session.begin():
|
||
# customer_id 列可空:SQL 的 is_not(None) 不足以让类型检查器收窄,
|
||
# 这里显式再过滤一次。
|
||
raw_customers = list(await session.scalars(
|
||
select(ConversationMessage.customer_id)
|
||
.where(ConversationMessage.customer_id.is_not(None))
|
||
.distinct()
|
||
.limit(max(1, customer_limit))
|
||
))
|
||
customers = [int(item) for item in raw_customers if item is not None]
|
||
worker = EpisodeWorker(session)
|
||
inserted = 0
|
||
for customer_id in customers:
|
||
result = await worker.aggregate(customer_id)
|
||
inserted += len(result.inserted)
|
||
return inserted
|
||
|
||
async def consume_episodes(
|
||
self, *, limit: int = EPISODE_CONSUME_LIMIT
|
||
) -> EpisodeConsumptionResult:
|
||
"""消费一批待提取片段(低频批处理),把片段提升为长期记忆。
|
||
|
||
必须由调用方开启并提交事务:消费者只做 flush 与状态更新,自己不提交,
|
||
否则会出现"返回已处理但表里状态没变"。单个片段失败不冒泡(在消费者内部
|
||
转换为 `失败` + 重试计数),因此本方法只在数据库层面失败时抛错。
|
||
"""
|
||
async with SessionFactory() as session, session.begin():
|
||
return await EpisodeExtractionConsumer(
|
||
session,
|
||
extractor=self.memory_extraction,
|
||
cache=self.memory_cache,
|
||
limit=limit,
|
||
).consume_pending()
|
||
|
||
async def execute(self, run_id: str) -> bool:
|
||
# A new fencing token for each claim also fences restarts of the same process.
|
||
worker_id = str(uuid4())
|
||
async with SessionFactory() as session, session.begin():
|
||
run = await session.scalar(select(AgentRun).where(
|
||
AgentRun.run_id == run_id).with_for_update())
|
||
if run is None:
|
||
return False
|
||
if run.status == "cancel_requested":
|
||
# 文档 §6.4:运行真正落到 cancelled 时,原请求在 request_idempotency 中
|
||
# 以 failed + RUN_CANCELLED 结束(HTTP 层取消受理时可能已写过一次,
|
||
# 这里幂等覆盖,保证 worker 抢先落终态的场景也不漏)。
|
||
run.status = "cancelled"
|
||
run.completed_at = datetime.now(UTC).replace(tzinfo=None)
|
||
run.locked_until = None
|
||
await session.execute(update(RequestIdempotency).where(
|
||
RequestIdempotency.id == run.idempotency_id
|
||
).values(status="failed", error_code="RUN_CANCELLED", updated_at=run.completed_at))
|
||
return True
|
||
claimed = await AgentRunRepository(session).claim(
|
||
run_id, worker_id, self.settings.worker_lease_seconds)
|
||
if not claimed:
|
||
return False
|
||
task = asyncio.create_task(self._execute_claimed(run_id, worker_id))
|
||
heartbeat = asyncio.create_task(self._heartbeat(run_id, worker_id, task))
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
await self._failure(run_id, worker_id, "RUN_INTERRUPTED", retryable=True)
|
||
# Runtime cancellation is shutdown; a lost lease only cancels the child.
|
||
current = asyncio.current_task()
|
||
if current is not None and current.cancelling():
|
||
raise
|
||
except RunLeaseLostError:
|
||
await self._failure(run_id, worker_id, "RUN_LEASE_LOST", retryable=True)
|
||
except Exception as exc:
|
||
code = exc.code if isinstance(exc, AgentError) else "AGENT_INTERNAL_ERROR"
|
||
await self._failure(run_id, worker_id, code,
|
||
retryable=isinstance(exc, RecoverableAgentError))
|
||
# 记录堆栈:只记录异常类型会让线上排障无从下手(run 的 error_code 只有
|
||
# AGENT_INTERNAL_ERROR,看不到真实原因)。
|
||
logger.warning("run failed run_id=%s error_type=%s", run_id, type(exc).__name__,
|
||
exc_info=True)
|
||
finally:
|
||
heartbeat.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await heartbeat
|
||
return True
|
||
|
||
async def _heartbeat(
|
||
self, run_id: str, worker_id: str, task: asyncio.Task[None]
|
||
) -> None:
|
||
try:
|
||
while True:
|
||
await asyncio.sleep(self.settings.worker_lease_seconds / 3)
|
||
async with SessionFactory() as session, session.begin():
|
||
renewed = await AgentRunRepository(session).renew(
|
||
run_id, worker_id, self.settings.worker_lease_seconds)
|
||
if not renewed:
|
||
task.cancel()
|
||
return
|
||
except Exception:
|
||
task.cancel()
|
||
logger.warning("lease renewal failed run_id=%s", run_id)
|
||
|
||
async def _execute_claimed(self, run_id: str, worker_id: str) -> None:
|
||
async with SessionFactory() as session:
|
||
run = await AgentRunRepository(session).get(run_id)
|
||
if run is None:
|
||
raise ValueError("run not found")
|
||
message = await session.get(ConversationMessage, run.request_message_id)
|
||
idem = await session.get(RequestIdempotency, run.idempotency_id)
|
||
event = await session.scalar(select(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == run_id,
|
||
DomainEventOutbox.event_type == "agent.run_requested").limit(1))
|
||
if message is None or idem is None:
|
||
raise ValueError("run input missing")
|
||
metadata = event.payload.get("metadata", {}) if event else {}
|
||
request = AgentRequest(
|
||
agent_type=run.agent_type, message=message.content, session_id=run.session_id,
|
||
idempotency_key=idem.idempotency_key,
|
||
metadata=AgentRequestMetadata.model_validate(metadata),
|
||
)
|
||
actor_type = (
|
||
str(event.payload.get("actor_type", "authenticated"))
|
||
if event else "authenticated"
|
||
)
|
||
actor_id = str(run.user_id)
|
||
trace_id = run.trace_id
|
||
context = await self.restore_context(
|
||
actor_type=actor_type, actor_id=actor_id, trace_id=trace_id
|
||
)
|
||
result: AgentResult | None = None
|
||
async for event_data in AgentExecutor(self.factory).execute(
|
||
request.agent_type, request, context, run_id
|
||
):
|
||
if event_data.event_type == "done":
|
||
result = AgentResult.model_validate(event_data.payload["result"])
|
||
if result is None:
|
||
raise ValueError("Agent produced no terminal result")
|
||
# 业务事件查询必须用独立 Session:在 complete_run 的 Session 上先跑 SELECT 会触发
|
||
# SQLAlchemy 的 autobegin,使 complete_run 内部的 session.begin() 抛
|
||
# "A transaction is already begun on this Session",运行直接失败。
|
||
async with SessionFactory() as events_session:
|
||
business_events = list(await events_session.scalars(
|
||
select(DomainEventOutbox.event_type).where(
|
||
DomainEventOutbox.aggregate_id == run_id,
|
||
DomainEventOutbox.event_type.in_(tuple(BUSINESS_EVENT_TYPES)),
|
||
)
|
||
))
|
||
async with SessionFactory() as session:
|
||
await AgentPersistenceService(session).complete_run(
|
||
run_id, result, worker_id=worker_id,
|
||
memory_extraction_requested=self.should_request_memory_extraction(
|
||
agent_type=run.agent_type, context=context,
|
||
message=request.message,
|
||
result=result,
|
||
business_events=business_events,
|
||
),
|
||
profile_candidate_requested=self.should_request_profile_candidate(
|
||
agent_type=run.agent_type, context=context, message=request.message,
|
||
),
|
||
)
|
||
# 只有数据库成功保存完整用户/助手轮次后才写短期 Redis;Redis 故障不应让已完成
|
||
# 的客服回答回滚或重试。短期组件会再次脱敏,形成持久化链路的第二道保护。
|
||
await self._append_customer_service_session_memory(
|
||
agent_type=run.agent_type, actor_id=str(run.user_id), session_id=run.session_id,
|
||
request_message=request.message, response_message=result.result.text,
|
||
)
|
||
|
||
async def _append_customer_service_session_memory(
|
||
self, *, agent_type: str, actor_id: str, session_id: str,
|
||
request_message: str, response_message: str,
|
||
) -> None:
|
||
"""将一轮成功客服会话写入 Redis;此派生写入永不影响已提交的主事务。"""
|
||
if agent_type != "customer_service":
|
||
return
|
||
try:
|
||
await self.session_memory.append(
|
||
actor_id=actor_id, session_id=session_id,
|
||
turns=(
|
||
CustomerServiceSessionTurn(role="user", content=request_message),
|
||
CustomerServiceSessionTurn(role="assistant", content=response_message),
|
||
),
|
||
)
|
||
except Exception:
|
||
# 注入的第三方 Redis 适配器也可能违反“不抛异常”约定,Worker 仍必须隔离故障。
|
||
logger.warning("客服短期会话写入降级,不影响已完成的客服运行", exc_info=True)
|
||
|
||
async def _failure(
|
||
self, run_id: str, worker_id: str, error_code: str, *, retryable: bool
|
||
) -> None:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
async with SessionFactory() as session, session.begin():
|
||
run = await session.scalar(select(AgentRun).where(
|
||
AgentRun.run_id == run_id).with_for_update())
|
||
if run is None or run.worker_id != worker_id:
|
||
return
|
||
if run.status not in {"running", "cancel_requested"}:
|
||
return
|
||
if run.status == "cancel_requested":
|
||
run.status = "cancelled"
|
||
elif retryable and run.attempt_count < self.settings.worker_retry_limit:
|
||
run.status = "queued"
|
||
else:
|
||
run.status = "failed"
|
||
run.error_code, run.updated_at = error_code, now
|
||
run.locked_until = (
|
||
now + timedelta(seconds=min(60, 2**run.attempt_count))
|
||
if run.status == "queued" else None
|
||
)
|
||
if run.status != "queued":
|
||
run.completed_at = now
|
||
# 取消导致的终态属于“原请求已终止”,按文档 §6.4 在 request_idempotency
|
||
# 中写 failed + RUN_CANCELLED;`agent_run.error_code` 仍保留真实触发原因
|
||
#(如 RUN_INTERRUPTED),两者层级不同。
|
||
await session.execute(update(RequestIdempotency).where(
|
||
RequestIdempotency.id == run.idempotency_id
|
||
).values(
|
||
status="failed",
|
||
error_code="RUN_CANCELLED" if run.status == "cancelled" else error_code,
|
||
updated_at=now,
|
||
))
|
||
session.add(InteractionAudit(
|
||
actor_type="agent", actor_id=run.user_id, session_id=run.session_id,
|
||
portal="api", action_type=f"agent.run_{run.status}",
|
||
detail={"run_id": run_id, "error_code": error_code}, created_at=now,
|
||
))
|