"""Session execution phase: virtual-user sessions, turns, and idle expiry.""" from datetime import timedelta from typing import Any from sqlmodel import Session, func, select from agenteval.channels.base import ExchangeStatus, SendResult from agenteval.channels.factory import ChannelFactory from agenteval.config import get_settings from agenteval.intelligent_eval.decision_logs import append_decision_log from agenteval.intelligent_eval.lifecycle._core import ( IntelligentEvalChannelError, IntelligentEvalNotFoundError, IntelligentEvalTransitionError, get_or_raise, ) from agenteval.intelligent_eval.models import ( IntelligentEvalMessage, IntelligentEvalSession, IntelligentEvalSessionStatus, IntelligentEvalStatus, ) from agenteval.intelligent_eval.repository import ( CompareAndSetStatus, IntelligentEvalMessageRepository, IntelligentEvalRepository, IntelligentEvalSessionRepository, ) from agenteval.storage.db import IntelligentEvalMessageDB, IntelligentEvalSessionDB, utc_now from agenteval.storage.repository import TargetRepository def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession: obj = repo.get(session_id) if obj is None: raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") return obj def _get_owned_session_or_raise( repo: IntelligentEvalSessionRepository, eval_id: str, session_id: str, ) -> IntelligentEvalSession: obj = _get_session_or_raise(repo, session_id) if obj.eval_id != eval_id: raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") return obj def open_session( session: Session, *, eval_id: str, persona: dict[str, Any], goal: str, dimension: str | None = None, ) -> IntelligentEvalSession: """创建虚拟用户会话:仅 executing 状态且未超粗计划预算的评估可创建。""" eval_repo = IntelligentEvalRepository(session) ev = get_or_raise(eval_repo, eval_id) if ev.status != IntelligentEvalStatus.EXECUTING: raise IntelligentEvalTransitionError(f"评估不在执行中(当前 {ev.status.value}),无法创建会话") # 预算硬闸门(ADR-0011):会话数不得超过粗计划虚拟用户数 budget = (ev.plan or {}).get("estimated_sessions", 0) if isinstance(budget, int) and budget > 0: opened = session.exec( select(func.count()).select_from(IntelligentEvalSessionDB).where(IntelligentEvalSessionDB.eval_id == ev.id) ).one() if opened >= budget: raise IntelligentEvalTransitionError(f"会话数已达粗计划上限({budget} 个虚拟用户),超出预算") repo = IntelligentEvalSessionRepository(session) status, created = repo._create_if_executing( IntelligentEvalSession( eval_id=ev.id, target_id=ev.target_id, persona=persona, goal=goal, dimension=dimension, ) ) if status is CompareAndSetStatus.CONFLICT: raise IntelligentEvalTransitionError("评估不在执行中,会话创建被拒绝") if status is CompareAndSetStatus.NOT_FOUND or created is None: raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found") return created async def conduct_turn(session: Session, *, eval_id: str, session_id: str, content: str) -> dict[str, Any]: """一轮完整问答:状态检查 → 通道往返 → 双条消息落库 → 轮次自增。""" repo = IntelligentEvalSessionRepository(session) obj = _get_owned_session_or_raise(repo, eval_id, session_id) if obj.status != IntelligentEvalSessionStatus.RUNNING: raise IntelligentEvalTransitionError("会话不在进行中,拒收消息") target = TargetRepository(session).get(obj.target_id) if not target: raise IntelligentEvalNotFoundError("session target not found") message_repo = IntelligentEvalMessageRepository(session) sent_at = utc_now() channel = ChannelFactory.create(target) async def record_sent(_send_result: SendResult) -> None: message = IntelligentEvalMessage( session_id=obj.id, role="user", content=content, created_at=sent_at, ) status = message_repo._create_user_and_increment(message) if status is CompareAndSetStatus.NOT_FOUND: raise IntelligentEvalNotFoundError(f"intelligent eval session {obj.id} not found") if status is CompareAndSetStatus.CONFLICT: raise IntelligentEvalTransitionError("会话不在进行中,拒收消息") outcome = await channel.exchange( content, timeout=get_settings().poll_reply_timeout, on_sent=record_sent, ) if outcome.status is ExchangeStatus.SEND_FAILED: raise IntelligentEvalChannelError(f"评测对象通道发送失败: {outcome.reason}") if outcome.status is ExchangeStatus.POLL_FAILED: raise IntelligentEvalChannelError(f"等待评测对象回复失败: {outcome.reason}") if outcome.status is ExchangeStatus.REPLY_TIMEOUT: raise IntelligentEvalChannelError("等待评测对象回复超时") received_at = utc_now() latency_ms = ( outcome.latency_ms if outcome.latency_ms is not None else int((received_at - sent_at).total_seconds() * 1000) ) reply_text = outcome.reply_text or "" message_repo._create( IntelligentEvalMessage( session_id=obj.id, role="assistant", content=reply_text, latency_ms=latency_ms, created_at=received_at, ) ) return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": obj.turn_count + 1} def close_session( session: Session, *, eval_id: str, session_id: str, verdict: dict[str, Any], ) -> IntelligentEvalSession: """关闭会话并记录结论(verdict);仅 running 会话可关闭。""" repo = IntelligentEvalSessionRepository(session) obj = _get_owned_session_or_raise(repo, eval_id, session_id) status, closed = repo._close_if_running(obj.id, verdict) if status is CompareAndSetStatus.NOT_FOUND: raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") if status is CompareAndSetStatus.CONFLICT: raise IntelligentEvalTransitionError("会话不在进行中,无法关闭") if closed is None: raise RuntimeError(f"session close returned no session: {session_id}") return closed def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession]: eval_repo = IntelligentEvalRepository(session) get_or_raise(eval_repo, eval_id) return IntelligentEvalSessionRepository(session).list_by_eval(eval_id) def list_messages(session: Session, *, eval_id: str, session_id: str) -> list[IntelligentEvalMessage]: _get_owned_session_or_raise(IntelligentEvalSessionRepository(session), eval_id, session_id) return IntelligentEvalMessageRepository(session).list_by_session(session_id) # --------------------------------------------------------------------------- # 会话过期 watchdog(ADR-0011):running 会话 60 分钟无新轮次 → expired # --------------------------------------------------------------------------- SESSION_IDLE_EXPIRE_MINUTES = 60 def expire_stale_running_sessions(session: Session) -> int: """把「60 分钟无新轮次」的 running 会话置为 expired。 worker/agent 中断会让会话永久卡在 running:conduct_turn 拒收非 running 会话,而 submit_report 又要求全部终态——不过期的话评估永远无法收尾。 活动判定取会话内最后一条消息的 created_at,无消息则退回会话 created_at。 expired 是终态(closed_at 一并写入),报告中按不完整证据对待。 Returns: 过期的会话数。 """ now = utc_now() # SQLite 读出为 naive datetime,阈值须同为 naive 才能在 Python 侧比较 threshold = now.replace(tzinfo=None) - timedelta(minutes=SESSION_IDLE_EXPIRE_MINUTES) # 单次查询获取所有 running 会话及其最后消息时间(避免 N+1 查询) stmt = ( select( IntelligentEvalSessionDB, func.max(IntelligentEvalMessageDB.created_at).label("last_message_at"), ) .outerjoin( IntelligentEvalMessageDB, IntelligentEvalMessageDB.session_id == IntelligentEvalSessionDB.id, ) .where(IntelligentEvalSessionDB.status == "running") .group_by(IntelligentEvalSessionDB.id) ) rows = session.exec(stmt).all() expired = 0 for session_row, last_message_at in rows: last_activity = last_message_at or session_row.created_at if last_activity is None or last_activity >= threshold: continue session_row.status = IntelligentEvalSessionStatus.EXPIRED.value session_row.closed_at = now expired += 1 append_decision_log( session_row.eval_id, "session_expired", f"平台兜底:会话 {SESSION_IDLE_EXPIRE_MINUTES} 分钟无新轮次,置为过期(不完整证据)", "platform", {"platform_supplemented": True, "session_id": session_row.id, "turn_count": session_row.turn_count}, session, ) if expired: session.commit() return expired