AgentEvalTool/backend/agenteval/web/routers/exploration.py

319 lines
12 KiB
Python

"""API routes for exploratory evaluation sessions (探索式评测, v0.9).
The virtual user (OpenClaw) drives these sessions through plain HTTP: create a
running session against a campaign, converse with the target through its real
channel, then close with a structured self-reported experience record.
Guardrails are a platform ledger — enforced here on every call, never trusted
to client self-discipline. Budget overruns and state violations are rejected
with 409 plus a readable reason, so the rejection itself is feedback to the
resident agent.
"""
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import Session
from agenteval.channels.factory import ChannelFactory
from agenteval.config import get_settings
from agenteval.evaluation.report import generate_campaign_report
from agenteval.exploration.judge import start_judge_review
from agenteval.exploration.models import (
ExplorationBudget,
ExplorationMessage,
ExplorationSession,
ExplorationSessionStatus,
ExplorationTrigger,
normalize_experience,
resolve_budget,
)
from agenteval.models import Campaign, CampaignStatus, EvalRun
from agenteval.storage.db import as_utc, iso_utc, utc_now
from agenteval.storage.repository import (
CampaignRepository,
ExplorationMessageRepository,
ExplorationSessionRepository,
RunRepository,
ScenarioRepository,
TargetRepository,
)
from agenteval.web.deps import get_db
router = APIRouter()
class CreateSessionRequest(BaseModel):
campaign_id: str
persona: dict[str, Any]
goal: str = Field(min_length=1)
seed_ref: dict[str, Any] | None = None
triggered_by: ExplorationTrigger = ExplorationTrigger.AUTO
class SendMessageRequest(BaseModel):
content: str = Field(min_length=1)
class CloseSessionRequest(BaseModel):
experience: dict[str, Any]
def _check_creation_guardrails(
campaign: Campaign,
triggered_by: ExplorationTrigger,
budget: ExplorationBudget,
repo: ExplorationSessionRepository,
) -> None:
if campaign.status != CampaignStatus.RUNNING:
raise HTTPException(status_code=409, detail="活动不在进行中,无法创建探索会话")
if campaign.time_scale != 1 and triggered_by != ExplorationTrigger.MANUAL:
raise HTTPException(
status_code=409,
detail="加速调试线仅允许手动创建探索会话(时间压缩与拟真相冲突)",
)
sessions = repo.list_by_campaign(campaign.id)
if len(sessions) >= budget.max_sessions:
raise HTTPException(
status_code=409,
detail=f"探索会话数超出预算:本活动窗口最多 {budget.max_sessions} 个会话",
)
if sessions:
latest = max(s.created_at for s in sessions if s.created_at)
elapsed = (utc_now() - as_utc(latest)).total_seconds()
if elapsed < budget.min_interval_seconds:
wait_minutes = budget.min_interval_seconds // 60
raise HTTPException(
status_code=409,
detail=f"相邻探索会话间隔不足:最小间隔 {wait_minutes} 分钟,请稍后再试",
)
def _new_runs_since(runs: list[EvalRun], watermark: datetime | None) -> list[EvalRun]:
fresh = []
for run in runs:
if run.completed_at is None:
continue
if watermark is not None and as_utc(run.completed_at) <= watermark:
continue
fresh.append(run)
return fresh
@router.get("/patrol")
async def patrol(session: Session = Depends(get_db)) -> dict:
"""Stateless patrol for the resident agent.
Reports every running production-line (time_scale == 1) campaign that
participates in exploration (has a seed set), with new results since the
last watermark and the remaining exploration budget. Advances each
patrolled campaign's watermark after building the response, so the next
call only reports increments.
"""
campaign_repo = CampaignRepository(session)
run_repo = RunRepository(session)
exploration_repo = ExplorationSessionRepository(session)
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
target_names = {t.id: t.name for t in TargetRepository(session).list_all()}
patrolled_at = utc_now()
entries: list[dict[str, Any]] = []
patrolled_campaigns: list[Campaign] = []
for campaign in campaign_repo.list_all():
if campaign.status != CampaignStatus.RUNNING:
continue
if campaign.time_scale != 1:
continue
if campaign.exploration_seeds is None:
continue
watermark = as_utc(campaign.last_patrolled_at) if campaign.last_patrolled_at else None
fresh = _new_runs_since(run_repo.list_by_campaign(campaign.id), watermark)
new_results = None
if fresh:
report = generate_campaign_report(campaign, fresh, scenario_names=scenario_names)
new_results = {
"summary": report["summary"],
"capability_summary": report["capability_summary"],
}
budget = resolve_budget(campaign)
sessions = exploration_repo.list_by_campaign(campaign.id)
seconds_since_last_session = None
if sessions:
latest = max(as_utc(s.created_at) for s in sessions if s.created_at)
seconds_since_last_session = int((patrolled_at - latest).total_seconds())
entries.append(
{
"campaign_id": campaign.id,
"campaign_name": campaign.name,
"target_id": campaign.target_id,
"target_name": target_names.get(campaign.target_id),
"last_patrolled_at": iso_utc(campaign.last_patrolled_at),
"new_results": new_results,
"budget": {
"max_sessions": budget.max_sessions,
"sessions_used": len(sessions),
"remaining_sessions": max(0, budget.max_sessions - len(sessions)),
"max_turns": budget.max_turns,
"min_interval_seconds": budget.min_interval_seconds,
"seconds_since_last_session": seconds_since_last_session,
},
}
)
patrolled_campaigns.append(campaign)
# 水位取构建响应之后的时刻:查询与持久化之间完成的结果不会在下次重复上报。
watermark_at = utc_now()
for campaign in patrolled_campaigns:
campaign.last_patrolled_at = watermark_at
campaign_repo.update(campaign)
return {"patrolled_at": iso_utc(watermark_at), "campaigns": entries}
@router.post("/sessions")
async def create_session(
request: CreateSessionRequest,
session: Session = Depends(get_db),
) -> dict:
campaign = CampaignRepository(session).get(request.campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="campaign not found")
if not TargetRepository(session).get(campaign.target_id):
raise HTTPException(status_code=404, detail="campaign target not found")
budget = resolve_budget(campaign)
repo = ExplorationSessionRepository(session)
_check_creation_guardrails(campaign, request.triggered_by, budget, repo)
session_obj = ExplorationSession(
campaign_id=campaign.id,
target_id=campaign.target_id,
persona=request.persona,
goal=request.goal,
seed_ref=request.seed_ref,
triggered_by=request.triggered_by,
)
return repo.create(session_obj).model_dump(mode="json")
@router.get("/campaigns/{campaign_id}/sessions")
async def list_campaign_sessions(
campaign_id: str,
session: Session = Depends(get_db),
) -> dict:
if not CampaignRepository(session).get(campaign_id):
raise HTTPException(status_code=404, detail="campaign not found")
sessions = ExplorationSessionRepository(session).list_by_campaign(campaign_id)
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
@router.get("/sessions/{session_id}/messages")
async def list_session_messages(
session_id: str,
session: Session = Depends(get_db),
) -> dict:
if not ExplorationSessionRepository(session).get(session_id):
raise HTTPException(status_code=404, detail="exploration session not found")
messages = ExplorationMessageRepository(session).list_by_session(session_id)
return {"messages": [m.model_dump(mode="json") for m in messages]}
@router.post("/sessions/{session_id}/messages")
async def send_session_message(
session_id: str,
request: SendMessageRequest,
session: Session = Depends(get_db),
) -> dict:
repo = ExplorationSessionRepository(session)
session_obj = repo.get(session_id)
if not session_obj:
raise HTTPException(status_code=404, detail="exploration session not found")
if session_obj.status != ExplorationSessionStatus.RUNNING:
raise HTTPException(status_code=409, detail="探索会话不在进行中,拒收消息")
campaign = CampaignRepository(session).get(session_obj.campaign_id)
budget = resolve_budget(campaign) if campaign else ExplorationBudget()
if session_obj.turn_count >= budget.max_turns:
raise HTTPException(
status_code=409,
detail=f"会话轮数超出预算:单会话最多 {budget.max_turns}",
)
target = TargetRepository(session).get(session_obj.target_id)
if not target:
raise HTTPException(status_code=404, detail="session target not found")
channel = ChannelFactory.create(target)
sent_at = utc_now()
try:
send_result = await channel.send(request.content)
except Exception as exc: # channel adapters raise transport-specific errors
raise HTTPException(status_code=502, detail=f"评测对象通道发送失败: {exc}")
if not send_result.ok:
raise HTTPException(
status_code=502,
detail=f"评测对象通道发送失败: {send_result.error}",
)
# 消息已送达即消耗一轮预算(平台账本):先落用户消息,再等回复。
message_repo = ExplorationMessageRepository(session)
round_index = session_obj.turn_count + 1
message_repo.save_message(
ExplorationMessage(
session_id=session_obj.id, round_index=round_index, role="user", content=request.content, created_at=sent_at
)
)
session_obj.turn_count = round_index
repo.update(session_obj)
try:
reply = await channel.poll_reply(
send_result.question_msg_id,
timeout=get_settings().poll_reply_timeout,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"等待评测对象回复失败: {exc}")
if reply is None:
raise HTTPException(status_code=502, detail="等待评测对象回复超时")
received_at = utc_now()
latency_ms = int((received_at - sent_at).total_seconds() * 1000)
reply_text = str(reply.content)
message_repo.save_message(
ExplorationMessage(
session_id=session_obj.id,
round_index=round_index,
role="assistant",
content=reply_text,
latency_ms=latency_ms,
created_at=received_at,
)
)
return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": round_index}
@router.post("/sessions/{session_id}/close")
async def close_session(
session_id: str,
request: CloseSessionRequest,
session: Session = Depends(get_db),
) -> dict:
repo = ExplorationSessionRepository(session)
session_obj = repo.get(session_id)
if not session_obj:
raise HTTPException(status_code=404, detail="exploration session not found")
if session_obj.status != ExplorationSessionStatus.RUNNING:
raise HTTPException(status_code=409, detail="探索会话不在进行中,无法关闭")
session_obj.experience = normalize_experience(request.experience)
session_obj.status = ExplorationSessionStatus.COMPLETED
session_obj.closed_at = utc_now()
updated = repo.update(session_obj)
start_judge_review(session_obj.id)
return updated.model_dump(mode="json")