diff --git a/backend/apps/agent_automation_app.py b/backend/apps/agent_automation_app.py new file mode 100644 index 000000000..e0059d348 --- /dev/null +++ b/backend/apps/agent_automation_app.py @@ -0,0 +1,225 @@ +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, Header, HTTPException, Query + +from consts.exceptions import UnauthorizedError +from services.agent_automation.errors import ( + AgentAutomationError, + AutomationConversationAlreadyBoundError, + AutomationNotFoundError, +) +from services.agent_automation.facade import agent_automation_facade +from services.agent_automation.models import ( + AutomationProposalConfirmRequest, + AutomationProposalCreateRequest, + AutomationProposalPatchRequest, + AutomationResponse, + AutomationTaskPatchRequest, +) +from utils.auth_utils import get_current_user_id + +logger = logging.getLogger("agent_automation_app") + +router = APIRouter(prefix="/agent/automations") +conversation_automation_router = APIRouter(prefix="/conversation") + + +def _get_current_user(authorization: Optional[str]) -> tuple[str, str]: + try: + return get_current_user_id(authorization) + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) from exc + + +def _map_error(exc: AgentAutomationError) -> HTTPException: + if isinstance(exc, AutomationNotFoundError): + status = HTTPStatus.NOT_FOUND + elif isinstance(exc, AutomationConversationAlreadyBoundError): + status = HTTPStatus.CONFLICT + else: + status = HTTPStatus.BAD_REQUEST + return HTTPException( + status_code=status, + detail={ + "code": exc.error_code, + "message": exc.message, + "details": exc.details, + }, + ) + + +@router.post("/proposals", response_model=AutomationResponse) +async def create_proposal(request: AutomationProposalCreateRequest, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + data = await agent_automation_facade.create_proposal(request, tenant_id, user_id) + return AutomationResponse(data=data) + except AgentAutomationError as exc: + raise _map_error(exc) + except HTTPException: + raise + except Exception as exc: + logger.error("Failed to create automation proposal: %s", exc, exc_info=True) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) + + +@router.post("/proposals/{proposal_id}/confirm", response_model=AutomationResponse) +async def confirm_proposal( + proposal_id: int, + request: AutomationProposalConfirmRequest | None = None, + authorization: Optional[str] = Header(None), +): + try: + user_id, tenant_id = _get_current_user(authorization) + data = await agent_automation_facade.confirm_proposal( + proposal_id, + request or AutomationProposalConfirmRequest(), + tenant_id, + user_id, + ) + return AutomationResponse(data=data) + except AgentAutomationError as exc: + raise _map_error(exc) + except HTTPException: + raise + except Exception as exc: + logger.error("Failed to confirm automation proposal: %s", exc, exc_info=True) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) + + +@router.patch("/proposals/{proposal_id}", response_model=AutomationResponse) +async def update_proposal( + proposal_id: int, + request: AutomationProposalPatchRequest, + authorization: Optional[str] = Header(None), +): + try: + user_id, tenant_id = _get_current_user(authorization) + data = await agent_automation_facade.update_proposal( + proposal_id, + request, + tenant_id, + user_id, + ) + return AutomationResponse(data=data) + except AgentAutomationError as exc: + raise _map_error(exc) + except HTTPException: + raise + except Exception as exc: + logger.error("Failed to update automation proposal: %s", exc, exc_info=True) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) + + +@router.get("", response_model=AutomationResponse) +async def list_tasks( + status: Optional[str] = Query(default=None), + search: Optional[str] = Query(default=None, max_length=200), + authorization: Optional[str] = Header(None), +): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.list_tasks(tenant_id, user_id, status, search)) + except HTTPException: + raise + except Exception as exc: + logger.error("Failed to list automation tasks: %s", exc, exc_info=True) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) + + +@router.get("/{task_id}", response_model=AutomationResponse) +async def get_task(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.get_task(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.patch("/{task_id}", response_model=AutomationResponse) +async def patch_task(task_id: int, request: AutomationTaskPatchRequest, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + data = await agent_automation_facade.patch_task(task_id, request, tenant_id, user_id) + return AutomationResponse(data=data) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.post("/{task_id}/pause", response_model=AutomationResponse) +async def pause_task(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.pause_task(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.post("/{task_id}/resume", response_model=AutomationResponse) +async def resume_task(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.resume_task(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.post("/{task_id}/run", response_model=AutomationResponse) +async def run_task_now(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=await agent_automation_facade.run_task_now(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.delete("/{task_id}", response_model=AutomationResponse) +async def delete_task(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.delete_task(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.get("/{task_id}/runs", response_model=AutomationResponse) +async def list_runs(task_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.list_runs(task_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.post("/runs/{run_id}/cancel", response_model=AutomationResponse) +async def cancel_run(run_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.cancel_run(run_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@router.delete("/runs/{run_id}", response_model=AutomationResponse) +async def delete_run(run_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse(data=agent_automation_facade.delete_run(run_id, tenant_id, user_id)) + except AgentAutomationError as exc: + raise _map_error(exc) + + +@conversation_automation_router.get("/{conversation_id}/automation", response_model=AutomationResponse) +async def get_conversation_automation(conversation_id: int, authorization: Optional[str] = Header(None)): + try: + user_id, tenant_id = _get_current_user(authorization) + return AutomationResponse( + data=agent_automation_facade.get_task_for_conversation(conversation_id, tenant_id, user_id) + ) + except HTTPException: + raise + except Exception as exc: + logger.error("Failed to get conversation automation: %s", exc, exc_info=True) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) diff --git a/backend/apps/runtime_app.py b/backend/apps/runtime_app.py index 49fe1286b..d623b14e0 100644 --- a/backend/apps/runtime_app.py +++ b/backend/apps/runtime_app.py @@ -2,6 +2,7 @@ from apps.app_factory import create_app from apps.agent_app import agent_runtime_router as agent_router +from apps.agent_automation_app import conversation_automation_router, router as agent_automation_router from apps.voice_app import voice_runtime_router as voice_router from apps.conversation_management_app import router as conversation_management_router from apps.conversation_share_app import router as conversation_share_router @@ -20,9 +21,25 @@ app.add_middleware(ExceptionHandlerMiddleware) app.include_router(agent_router) +app.include_router(agent_automation_router) +app.include_router(conversation_automation_router) app.include_router(conversation_management_router) app.include_router(conversation_share_router) app.include_router(memory_config_router) app.include_router(file_management_router) app.include_router(voice_router) app.include_router(skill_creator_router) + + +@app.on_event("startup") +async def start_agent_automation_scheduler(): + from services.agent_automation.scheduler import agent_automation_scheduler + + await agent_automation_scheduler.start() + + +@app.on_event("shutdown") +async def stop_agent_automation_scheduler(): + from services.agent_automation.scheduler import agent_automation_scheduler + + await agent_automation_scheduler.stop() diff --git a/backend/consts/const.py b/backend/consts/const.py index eabf6a8b3..4ba53c800 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -46,6 +46,29 @@ class VectorDatabaseType(str, Enum): PER_WAVE_TIMEOUT = int(os.getenv("DP_SPLIT_WAIT_TIMEOUT_PER_WAVE_S", "30")) MAX_TIMEOUT = int(os.getenv("DP_SPLIT_WAIT_TIMEOUT_MAX_S", "1800")) +# Agent automation runtime configuration +AGENT_AUTOMATION_ENABLED = os.getenv( + "AGENT_AUTOMATION_ENABLED", "true" +).lower() in ("true", "1", "yes", "on") +AGENT_AUTOMATION_POLL_INTERVAL_SECONDS = int( + os.getenv("AGENT_AUTOMATION_POLL_INTERVAL_SECONDS", "5") +) +AGENT_AUTOMATION_MAX_CONCURRENT_RUNS = int( + os.getenv("AGENT_AUTOMATION_MAX_CONCURRENT_RUNS", "2") +) +AGENT_AUTOMATION_LEASE_SECONDS = int( + os.getenv("AGENT_AUTOMATION_LEASE_SECONDS", "120") +) +AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS = int( + os.getenv("AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS", "1800") +) +AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS = int( + os.getenv("AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS", "30") +) +AGENT_AUTOMATION_MIN_INTERVAL_SECONDS = int( + os.getenv("AGENT_AUTOMATION_MIN_INTERVAL_SECONDS", "5") +) + # Container-internal skills storage path CONTAINER_SKILLS_PATH = os.getenv("SKILLS_PATH") diff --git a/backend/database/agent_automation_db.py b/backend/database/agent_automation_db.py new file mode 100644 index 000000000..5f2195d40 --- /dev/null +++ b/backend/database/agent_automation_db.py @@ -0,0 +1,524 @@ +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import desc, func, insert, select, text, update + +from .client import as_dict, get_db_session +from .db_models import AgentAutomationProposal, AgentAutomationRun, AgentAutomationTask +from .utils import add_creation_tracking, add_update_tracking + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def create_task(task_data: Dict[str, Any], user_id: str) -> Dict[str, Any]: + data = { + **task_data, + "delete_flag": "N", + } + data = add_creation_tracking(data, user_id) + with get_db_session() as session: + stmt = insert(AgentAutomationTask).values(**data).returning(AgentAutomationTask) + task = session.execute(stmt).scalar_one() + return as_dict(task) + + +def get_task(task_id: int, tenant_id: str, user_id: str) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + task = session.execute( + select(AgentAutomationTask).where( + AgentAutomationTask.task_id == task_id, + AgentAutomationTask.tenant_id == tenant_id, + AgentAutomationTask.user_id == user_id, + AgentAutomationTask.delete_flag == "N", + ) + ).scalar_one_or_none() + return as_dict(task) if task else None + + +def get_task_by_conversation( + conversation_id: int, + user_id: str, + include_deleted: bool = False, +) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + conditions = [ + AgentAutomationTask.conversation_id == conversation_id, + AgentAutomationTask.user_id == user_id, + ] + if not include_deleted: + conditions.extend([ + AgentAutomationTask.delete_flag == "N", + AgentAutomationTask.status != "DELETED", + ]) + task = session.execute(select(AgentAutomationTask).where(*conditions)).scalar_one_or_none() + return as_dict(task) if task else None + + +def _escape_like_pattern(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def list_tasks( + tenant_id: str, + user_id: str, + status: Optional[str] = None, + search: Optional[str] = None, +) -> List[Dict[str, Any]]: + with get_db_session() as session: + conditions = [ + AgentAutomationTask.tenant_id == tenant_id, + AgentAutomationTask.user_id == user_id, + AgentAutomationTask.delete_flag == "N", + AgentAutomationTask.status != "DELETED", + ] + if status: + conditions.append(AgentAutomationTask.status == status) + normalized_search = search.strip() if search else "" + if normalized_search: + pattern = f"%{_escape_like_pattern(normalized_search)}%" + conditions.append(AgentAutomationTask.title.ilike(pattern, escape="\\")) + rows = session.execute( + select(AgentAutomationTask) + .where(*conditions) + .order_by(desc(AgentAutomationTask.update_time)) + ).scalars().all() + return [as_dict(row) for row in rows] + + +def update_task(task_id: int, tenant_id: str, user_id: str, values: Dict[str, Any]) -> Optional[Dict[str, Any]]: + data = add_update_tracking({ + **values, + "update_time": _utcnow(), + }, user_id) + with get_db_session() as session: + task = session.execute( + update(AgentAutomationTask) + .where( + AgentAutomationTask.task_id == task_id, + AgentAutomationTask.tenant_id == tenant_id, + AgentAutomationTask.user_id == user_id, + AgentAutomationTask.delete_flag == "N", + ) + .values(**data) + .returning(AgentAutomationTask) + ).scalar_one_or_none() + return as_dict(task) if task else None + + +def update_task_if_lock_owner( + task_id: int, + tenant_id: str, + user_id: str, + lock_owner: str, + values: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Apply a scheduled-run result only while the caller owns the task lease.""" + data = add_update_tracking({ + **values, + "update_time": _utcnow(), + }, user_id) + with get_db_session() as session: + task = session.execute( + update(AgentAutomationTask) + .where( + AgentAutomationTask.task_id == task_id, + AgentAutomationTask.tenant_id == tenant_id, + AgentAutomationTask.user_id == user_id, + AgentAutomationTask.lock_owner == lock_owner, + AgentAutomationTask.lock_until > func.now(), + AgentAutomationTask.delete_flag == "N", + ) + .values(**data) + .returning(AgentAutomationTask) + ).scalar_one_or_none() + return as_dict(task) if task else None + + +def soft_delete_task(task_id: int, tenant_id: str, user_id: str) -> bool: + result = update_task(task_id, tenant_id, user_id, { + "status": "DELETED", + "delete_flag": "Y", + "lock_owner": None, + "lock_until": None, + }) + return result is not None + + +def soft_delete_task_by_conversation(conversation_id: int, user_id: str) -> int: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationTask) + .where( + AgentAutomationTask.conversation_id == conversation_id, + AgentAutomationTask.user_id == user_id, + AgentAutomationTask.delete_flag == "N", + ) + .values( + status="DELETED", + delete_flag="Y", + lock_owner=None, + lock_until=None, + update_time=_utcnow(), + updated_by=user_id, + ) + ) + return result.rowcount or 0 + + +def create_proposal(proposal_data: Dict[str, Any], user_id: str) -> Dict[str, Any]: + data = add_creation_tracking({**proposal_data, "delete_flag": "N"}, user_id) + with get_db_session() as session: + stmt = insert(AgentAutomationProposal).values(**data).returning(AgentAutomationProposal) + proposal = session.execute(stmt).scalar_one() + return as_dict(proposal) + + +def get_proposal(proposal_id: int, tenant_id: str, user_id: str) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + proposal = session.execute( + select(AgentAutomationProposal).where( + AgentAutomationProposal.proposal_id == proposal_id, + AgentAutomationProposal.tenant_id == tenant_id, + AgentAutomationProposal.user_id == user_id, + AgentAutomationProposal.delete_flag == "N", + ) + ).scalar_one_or_none() + return as_dict(proposal) if proposal else None + + +def update_proposal_status(proposal_id: int, tenant_id: str, user_id: str, status: str) -> bool: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationProposal) + .where( + AgentAutomationProposal.proposal_id == proposal_id, + AgentAutomationProposal.tenant_id == tenant_id, + AgentAutomationProposal.user_id == user_id, + AgentAutomationProposal.delete_flag == "N", + ) + .values(status=status, update_time=_utcnow(), updated_by=user_id) + ) + return bool(result.rowcount) + + +def update_proposal_task( + proposal_id: int, + tenant_id: str, + user_id: str, + proposed_task: Dict[str, Any], +) -> bool: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationProposal) + .where( + AgentAutomationProposal.proposal_id == proposal_id, + AgentAutomationProposal.tenant_id == tenant_id, + AgentAutomationProposal.user_id == user_id, + AgentAutomationProposal.delete_flag == "N", + ) + .values( + proposed_task=proposed_task, + update_time=_utcnow(), + updated_by=user_id, + ) + ) + return bool(result.rowcount) + + +def update_proposal( + proposal_id: int, + tenant_id: str, + user_id: str, + proposed_task: Dict[str, Any], + capability_resolution: Dict[str, Any], +) -> bool: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationProposal) + .where( + AgentAutomationProposal.proposal_id == proposal_id, + AgentAutomationProposal.tenant_id == tenant_id, + AgentAutomationProposal.user_id == user_id, + AgentAutomationProposal.status.in_(["PENDING", "ACCEPTED"]), + AgentAutomationProposal.delete_flag == "N", + ) + .values( + proposed_task=proposed_task, + capability_resolution=capability_resolution, + update_time=_utcnow(), + updated_by=user_id, + ) + ) + return bool(result.rowcount) + + +def create_run(run_data: Dict[str, Any], user_id: str) -> Dict[str, Any]: + data = add_creation_tracking({**run_data, "delete_flag": "N"}, user_id) + with get_db_session() as session: + stmt = insert(AgentAutomationRun).values(**data).returning(AgentAutomationRun) + run = session.execute(stmt).scalar_one() + return as_dict(run) + + +def update_run( + run_id: int, + values: Dict[str, Any], + user_id: Optional[str] = None, + expected_statuses: Optional[List[str]] = None, +) -> Optional[Dict[str, Any]]: + data = { + **values, + "update_time": _utcnow(), + } + if user_id: + data = add_update_tracking(data, user_id) + with get_db_session() as session: + conditions = [ + AgentAutomationRun.run_id == run_id, + AgentAutomationRun.delete_flag == "N", + ] + if expected_statuses: + conditions.append(AgentAutomationRun.status.in_(expected_statuses)) + run = session.execute( + update(AgentAutomationRun) + .where(*conditions) + .values(**data) + .returning(AgentAutomationRun) + ).scalar_one_or_none() + return as_dict(run) if run else None + + +def get_run(run_id: int, tenant_id: str, user_id: str) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + run = session.execute( + select(AgentAutomationRun).where( + AgentAutomationRun.run_id == run_id, + AgentAutomationRun.tenant_id == tenant_id, + AgentAutomationRun.user_id == user_id, + AgentAutomationRun.delete_flag == "N", + ) + ).scalar_one_or_none() + return as_dict(run) if run else None + + +def cancel_run(run_id: int, tenant_id: str, user_id: str, reason: str) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + run = session.execute( + update(AgentAutomationRun) + .where( + AgentAutomationRun.run_id == run_id, + AgentAutomationRun.tenant_id == tenant_id, + AgentAutomationRun.user_id == user_id, + AgentAutomationRun.status.in_(["QUEUED", "RUNNING"]), + AgentAutomationRun.delete_flag == "N", + ) + .values( + status="CANCELED", + error_code="AUTOMATION_RUN_CANCELED", + error_message=reason, + finished_at=_utcnow(), + update_time=_utcnow(), + updated_by=user_id, + ) + .returning(AgentAutomationRun) + ).scalar_one_or_none() + return as_dict(run) if run else None + + +def soft_delete_run( + run_id: int, + tenant_id: str, + user_id: str, + expected_statuses: List[str], +) -> Optional[Dict[str, Any]]: + with get_db_session() as session: + run = session.execute( + update(AgentAutomationRun) + .where( + AgentAutomationRun.run_id == run_id, + AgentAutomationRun.tenant_id == tenant_id, + AgentAutomationRun.user_id == user_id, + AgentAutomationRun.status.in_(expected_statuses), + AgentAutomationRun.delete_flag == "N", + ) + .values( + delete_flag="Y", + update_time=_utcnow(), + updated_by=user_id, + ) + .returning(AgentAutomationRun) + ).scalar_one_or_none() + return as_dict(run) if run else None + + +def cancel_runs_by_conversation(conversation_id: int, user_id: str, reason: str) -> int: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationRun) + .where( + AgentAutomationRun.conversation_id == conversation_id, + AgentAutomationRun.user_id == user_id, + AgentAutomationRun.status.in_(["QUEUED", "RUNNING"]), + AgentAutomationRun.delete_flag == "N", + ) + .values( + status="CANCELED", + error_code="AUTOMATION_RUN_CANCELED", + error_message=reason, + finished_at=_utcnow(), + update_time=_utcnow(), + updated_by=user_id, + ) + ) + return result.rowcount or 0 + + +def list_runs(task_id: int, tenant_id: str, user_id: str, limit: int = 50) -> List[Dict[str, Any]]: + with get_db_session() as session: + rows = session.execute( + select(AgentAutomationRun) + .where( + AgentAutomationRun.task_id == task_id, + AgentAutomationRun.tenant_id == tenant_id, + AgentAutomationRun.user_id == user_id, + AgentAutomationRun.delete_flag == "N", + ) + .order_by(desc(AgentAutomationRun.scheduled_fire_at)) + .limit(limit) + ).scalars().all() + return [as_dict(row) for row in rows] + + +def has_active_run_for_conversation(conversation_id: int) -> bool: + with get_db_session() as session: + run = session.execute( + select(AgentAutomationRun.run_id) + .where( + AgentAutomationRun.conversation_id == conversation_id, + AgentAutomationRun.status.in_(["QUEUED", "RUNNING"]), + AgentAutomationRun.delete_flag == "N", + ) + .limit(1) + ).scalar_one_or_none() + return run is not None + + +def claim_due_tasks(instance_id: str, batch_size: int, lease_seconds: float) -> List[Dict[str, Any]]: + sql = text(""" + WITH due AS ( + SELECT task_id + FROM nexent.agent_automation_task_t + WHERE delete_flag = 'N' + AND status = 'ACTIVE' + AND next_fire_at <= now() + AND (lock_until IS NULL OR lock_until < now()) + ORDER BY next_fire_at ASC + LIMIT :batch_size + FOR UPDATE SKIP LOCKED + ), claimed AS ( + UPDATE nexent.agent_automation_task_t AS task + SET lock_owner = :instance_id, + lock_until = now() + (:lease_seconds * interval '1 second'), + update_time = now() + FROM due + WHERE task.task_id = due.task_id + RETURNING task.* + ), orphaned_runs AS ( + UPDATE nexent.agent_automation_run_t AS run + SET status = 'TIMEOUT', + error_code = 'AUTOMATION_LEASE_EXPIRED', + error_message = 'The previous scheduler lease expired before the run completed.', + finished_at = now(), + update_time = now() + WHERE run.task_id IN (SELECT task_id FROM claimed) + AND run.trigger_type = 'SCHEDULED' + AND run.status IN ('QUEUED', 'RUNNING') + AND run.delete_flag = 'N' + RETURNING run.run_id + ) + SELECT claimed.* + FROM claimed + WHERE (SELECT count(*) FROM orphaned_runs) >= 0 + """) + with get_db_session() as session: + rows = session.execute(sql, { + "instance_id": instance_id, + "batch_size": batch_size, + "lease_seconds": lease_seconds, + }).fetchall() + return [dict(row._mapping) for row in rows] + + +def release_task_lock(task_id: int, lock_owner: Optional[str] = None) -> bool: + conditions = [AgentAutomationTask.task_id == task_id] + if lock_owner: + conditions.append(AgentAutomationTask.lock_owner == lock_owner) + with get_db_session() as session: + result = session.execute( + update(AgentAutomationTask) + .where(*conditions) + .values(lock_owner=None, lock_until=None, update_time=_utcnow()) + ) + return bool(result.rowcount) + + +def renew_task_lock(task_id: int, lock_owner: str, lease_seconds: float) -> bool: + sql = text(""" + UPDATE nexent.agent_automation_task_t + SET lock_until = now() + (:lease_seconds * interval '1 second'), + update_time = now() + WHERE task_id = :task_id + AND lock_owner = :lock_owner + AND lock_until > now() + AND delete_flag = 'N' + AND status = 'ACTIVE' + RETURNING task_id + """) + with get_db_session() as session: + renewed_task_id = session.execute(sql, { + "task_id": task_id, + "lock_owner": lock_owner, + "lease_seconds": lease_seconds, + }).scalar_one_or_none() + return renewed_task_id is not None + + +def recover_orphaned_runs() -> int: + """Finish runs whose task no longer has a live scheduler lease. + + This is safe to run on every replica startup because a live lease is never + modified. Due tasks are retried after their expired lease is reclaimed. + """ + sql = text(""" + UPDATE nexent.agent_automation_run_t AS run + SET status = 'TIMEOUT', + error_code = 'AUTOMATION_LEASE_EXPIRED', + error_message = 'The scheduler stopped before the run completed.', + finished_at = now(), + update_time = now() + FROM nexent.agent_automation_task_t AS task + WHERE run.task_id = task.task_id + AND run.delete_flag = 'N' + AND run.trigger_type = 'SCHEDULED' + AND run.status IN ('QUEUED', 'RUNNING') + AND (task.lock_until IS NULL OR task.lock_until < now()) + """) + with get_db_session() as session: + result = session.execute(sql) + return result.rowcount or 0 + + +def release_expired_locks() -> int: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationTask) + .where( + AgentAutomationTask.delete_flag == "N", + AgentAutomationTask.lock_until.is_not(None), + AgentAutomationTask.lock_until < func.now(), + ) + .values(lock_owner=None, lock_until=None, update_time=_utcnow()) + ) + return result.rowcount or 0 diff --git a/backend/database/db_models.py b/backend/database/db_models.py index b50e801fb..086dd2876 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -97,6 +97,143 @@ class ConversationMessageUnit(TableBase): doc="Lifecycle status: streaming (still aggregating) or completed (fully persisted)") +class AgentAutomationTask(TableBase): + """User-managed scheduled automation task bound to one conversation.""" + + __tablename__ = "agent_automation_task_t" + __table_args__ = ( + Index( + "idx_agent_automation_due", + "status", + "next_fire_at", + postgresql_where=text("delete_flag = 'N'"), + ), + Index( + "idx_agent_automation_owner", + "tenant_id", + "user_id", + "status", + postgresql_where=text("delete_flag = 'N'"), + ), + Index( + "uq_agent_automation_conversation_active", + "conversation_id", + unique=True, + postgresql_where=text("delete_flag = 'N' AND status <> 'DELETED'"), + ), + {"schema": SCHEMA}, + ) + + task_id = Column(BigInteger, Sequence( + "agent_automation_task_t_task_id_seq", schema=SCHEMA), primary_key=True, nullable=False) + tenant_id = Column(String(100), nullable=False, doc="Tenant ID") + user_id = Column(String(100), nullable=False, doc="Owner user ID") + conversation_id = Column(BigInteger, nullable=False, doc="Bound conversation ID") + agent_id = Column(BigInteger, nullable=False, doc="Bound agent ID") + agent_version_no = Column(Integer, nullable=True, doc="Pinned agent version") + title = Column(String(255), nullable=False, doc="Task title") + instruction = Column(Text, nullable=False, doc="Base instruction for every automation run") + status = Column(String(32), nullable=False, doc="Task lifecycle status") + source = Column(String(32), nullable=False, doc="Creation source") + schedule_mode = Column(String(16), nullable=False, doc="ONCE or RECURRING") + schedule_rule_type = Column(String(16), nullable=False, doc="AT, INTERVAL, or CRON") + schedule_expr = Column(Text, nullable=True, doc="Display schedule expression") + schedule_config = Column(JSONB, nullable=False, doc="Normalized ScheduleTrigger payload") + capability_requirements = Column(JSONB, doc="Capability requirements parsed from user intent") + capability_bindings = Column(JSONB, doc="Confirmed matched capabilities") + runtime_snapshot = Column(JSONB, doc="Agent/runtime capability snapshot at creation time") + timezone = Column(String(64), nullable=False, doc="IANA timezone") + next_fire_at = Column(TIMESTAMP(timezone=True), nullable=True, doc="Next scheduled fire time") + last_fire_at = Column(TIMESTAMP(timezone=True), nullable=True, doc="Last scheduled fire time") + fire_count = Column(Integer, default=0, nullable=False, doc="Number of scheduled fires") + last_run_status = Column(String(32), nullable=True, doc="Latest run status") + last_error = Column(Text, nullable=True, doc="Latest run error") + consecutive_failures = Column(Integer, default=0, nullable=False, doc="Consecutive failure count") + timeout_seconds = Column(Integer, nullable=False, doc="Single-run timeout") + overlap_policy = Column(String(16), nullable=False, doc="Overlap policy") + misfire_policy = Column(String(16), nullable=False, doc="Misfire policy") + lock_owner = Column(String(128), nullable=True, doc="Scheduler lease owner") + lock_until = Column(TIMESTAMP(timezone=True), nullable=True, doc="Scheduler lease expiry") + + +class AgentAutomationRun(TableBase): + """Execution history for an automation task fire.""" + + __tablename__ = "agent_automation_run_t" + __table_args__ = ( + Index( + "idx_agent_automation_run_task", + "task_id", + "scheduled_fire_at", + postgresql_where=text("delete_flag = 'N'"), + ), + Index( + "idx_agent_automation_run_conversation", + "conversation_id", + "status", + postgresql_where=text("delete_flag = 'N'"), + ), + Index( + "uq_agent_automation_active_occurrence", + "task_id", + "scheduled_fire_at", + unique=True, + postgresql_where=text( + "delete_flag = 'N' AND trigger_type = 'SCHEDULED' " + "AND status IN ('QUEUED', 'RUNNING')" + ), + ), + {"schema": SCHEMA}, + ) + + run_id = Column(BigInteger, Sequence( + "agent_automation_run_t_run_id_seq", schema=SCHEMA), primary_key=True, nullable=False) + task_id = Column(BigInteger, nullable=False, doc="Automation task ID") + tenant_id = Column(String(100), nullable=False, doc="Tenant ID") + user_id = Column(String(100), nullable=False, doc="Owner user ID") + conversation_id = Column(BigInteger, nullable=False, doc="Bound conversation ID") + scheduled_fire_at = Column(TIMESTAMP(timezone=True), nullable=False, doc="Scheduled fire time") + actual_fire_at = Column(TIMESTAMP(timezone=True), nullable=True, doc="Actual fire time") + trigger_type = Column(String(32), nullable=False, doc="SCHEDULED or MANUAL") + status = Column(String(32), nullable=False, doc="Run lifecycle status") + generated_prompt = Column(Text, nullable=True, doc="Prompt appended to the conversation") + user_message_id = Column(BigInteger, nullable=True, doc="Automation user message ID") + assistant_message_id = Column(BigInteger, nullable=True, doc="Assistant message ID") + started_at = Column(TIMESTAMP(timezone=True), nullable=True, doc="Run start time") + finished_at = Column(TIMESTAMP(timezone=True), nullable=True, doc="Run finish time") + duration_ms = Column(BigInteger, nullable=True, doc="Run duration in milliseconds") + error_code = Column(String(64), nullable=True, doc="Automation error code") + error_message = Column(Text, nullable=True, doc="Automation error message") + capability_check = Column(JSONB, nullable=True, doc="Capability check result before execution") + + +class AgentAutomationProposal(TableBase): + """Pending automation task proposal created from chat intent.""" + + __tablename__ = "agent_automation_proposal_t" + __table_args__ = ( + Index( + "idx_agent_automation_proposal_owner", + "tenant_id", + "user_id", + "status", + postgresql_where=text("delete_flag = 'N'"), + ), + {"schema": SCHEMA}, + ) + + proposal_id = Column(BigInteger, Sequence( + "agent_automation_proposal_t_proposal_id_seq", schema=SCHEMA), primary_key=True, nullable=False) + tenant_id = Column(String(100), nullable=False, doc="Tenant ID") + user_id = Column(String(100), nullable=False, doc="Owner user ID") + conversation_id = Column(BigInteger, nullable=False, doc="Source conversation ID") + agent_id = Column(BigInteger, nullable=False, doc="Bound agent ID") + proposed_task = Column(JSONB, nullable=False, doc="Proposed automation task payload") + capability_resolution = Column(JSONB, nullable=False, doc="Capability matching result") + status = Column(String(32), nullable=False, doc="PENDING, ACCEPTED, REJECTED, or EXPIRED") + expires_at = Column(TIMESTAMP(timezone=True), nullable=False, doc="Proposal expiry time") + + class ConversationSourceImage(TableBase): """ Holds the search image source information of conversation messages diff --git a/backend/prompts/agent_automation_en.yaml b/backend/prompts/agent_automation_en.yaml new file mode 100644 index 000000000..e0f032027 --- /dev/null +++ b/backend/prompts/agent_automation_en.yaml @@ -0,0 +1,50 @@ +INTENT_ANALYSIS_SYSTEM_PROMPT: |- + ### Role + You analyze automation intent. Decide whether the user wants a task to run automatically in the future or on a schedule, or wants an ordinary task executed now. + + ### Classification Rules + 1. Set is_automation_intent to true only for a requested future, delayed, repeated, or explicitly timed execution. + 2. Immediate requests, questions about data at a time, schedule explanations, factual statements, and personal habits are ordinary tasks. + 3. The schedule must modify the action. “Analyze sales every day at 8” is automation; “analyze the sales recorded every day at 8” is an ordinary task. + 4. An explicit automation request with missing schedule details remains automation; return a null schedule and a concise schedule_error. + 5. Never guess a missing date, time, timezone, recurrence, end condition, or run count. + + ### Task Content + - title is a short task goal under 60 characters with no schedule, Agent, tool, or orchestration details. + - instruction states only the business action for one run as a direct imperative. + - Preserve scope, conditions, and output requirements. Do not add data sources, tools, steps, retries, or error handling. + - Keep title and instruction in the language used for the business action. + + ### Schedule Rules + - AT is one explicit future time. start_at must be ISO 8601 with a UTC offset. + - INTERVAL is a fixed seconds, minutes, or hours interval. interval_seconds must meet the supplied minimum. + - CRON is for daily, weekday, weekly, monthly, quarterly, and yearly calendar schedules. Use standard five-field Cron only: minute hour day month weekday. + - Use an IANA timezone. Use the supplied default when the user gives none. + - Use start_at only for an explicit starting point. Set end_at and max_fire_count only when explicitly requested. + - schedule and schedule_error are mutually exclusive. + + ### Output Format + Return exactly one JSON object with no Markdown or explanation. Include only these top-level fields: + {"is_automation_intent":true,"confidence":0.98,"title":"Task title","instruction":"Single-run action","schedule":{"rule_type":"CRON","timezone":"Asia/Shanghai","cron_expr":"0 8 * * *","interval_seconds":null,"start_at":null,"end_at":null,"max_fire_count":null},"schedule_error":null} + + For an ordinary task, return an empty title, empty instruction, null schedule, and null schedule_error. + + ### Examples + - For “Get the almanac every day at 8 am”, use title “Get almanac”, instruction “Get today's almanac”, and Cron 0 8 * * *. Do not include “daily” or “8 am” in the title or instruction. + - “Get today's almanac” is an ordinary task. + - “Analyze the sales recorded every day at 8 am” is an ordinary task. + - “Every day at 8 am analyze sales” is automation with Cron 0 8 * * *. + - “Check service status every five minutes” is automation with a 300-second INTERVAL. + +INTENT_ANALYSIS_USER_PROMPT: |- + Current datetime: {{ current_datetime }} + Default timezone: {{ timezone }} + Minimum interval: {{ min_interval_seconds }} seconds + User message: {{ message }} + +TASK_CONTENT_SYSTEM_PROMPT: |- + Generate only the title and single-run instruction for an automation task. Scheduling details have already been removed from the input. + Return exactly one JSON object: {"title":"...","instruction":"..."}. The title must be a short task-goal phrase under 60 characters. The instruction must state only the business action for one run. Preserve the user's scope, conditions, requested output, and original language, but do not add schedule metadata, Agent details, tools, implementation steps, data sources, retries, error handling, or generic quality requirements. Remove conversational request fillers and make only minimal wording changes when the action is already clear. Do not translate the title or instruction. + +TASK_CONTENT_USER_PROMPT: |- + Business action: {{ instruction }} diff --git a/backend/prompts/agent_automation_zh.yaml b/backend/prompts/agent_automation_zh.yaml new file mode 100644 index 000000000..2d040d317 --- /dev/null +++ b/backend/prompts/agent_automation_zh.yaml @@ -0,0 +1,64 @@ +INTENT_ANALYSIS_SYSTEM_PROMPT: |- + ### 角色 + 你是自动任务意图分析器。判断用户是在要求系统未来或周期性自动执行任务,还是要求现在执行普通任务。 + + ### 判定规则 + 1. 只有用户要求未来、延迟、重复或指定时间自动执行动作时,is_automation_intent 才为 true。 + 2. 立即查询、立即分析、询问某个时间的数据、解释时间表达式、事实陈述和个人习惯均为普通任务。 + 3. 时间语义必须修饰待执行动作。例如“每天八点分析销量”是自动任务,“分析每天八点的销量”是普通任务。 + 4. 用户明确要求创建定时任务但时间不完整时,仍判定为自动任务;schedule 为 null,并通过 schedule_error 请求补充信息。 + 5. 不猜测用户未提供的日期、时间、时区、周期、结束条件或次数。 + + ### 内容提取 + - title:简短任务目标,不包含调度时间、周期、Agent、工具或“定时任务”等编排信息,最多 20 个汉字。 + - instruction:只描述单次触发需要完成的业务动作,使用直接、明确的祈使句。 + - 保留业务对象、范围、条件和输出要求,不添加数据来源、工具、步骤、重试或异常处理。 + - title 和 instruction 保持用户业务动作原本使用的语言。 + + ### 调度规则 + - AT:明确的单次未来时间。start_at 使用带 UTC 偏移的 ISO 8601 时间。 + - INTERVAL:固定秒、分钟或小时间隔。interval_seconds 不得低于用户提示中的最小值。 + - CRON:每天、工作日、每周、每月、季度、每年等日历周期。仅使用标准五段 Cron:分钟 小时 日 月 星期。 + - timezone 使用 IANA 时区;用户未指定时使用输入中的默认时区。 + - start_at 可用于“从某时开始”;end_at 和 max_fire_count 只在用户明确要求时填写。 + - schedule_error 与 schedule 互斥。 + + ### 输出格式 + 仅输出一个 JSON 对象,不要输出 Markdown 或解释。必须包含且只能包含以下顶层字段: + {"is_automation_intent":true,"confidence":0.98,"title":"任务标题","instruction":"单次执行动作","schedule":{"rule_type":"CRON","timezone":"Asia/Shanghai","cron_expr":"0 8 * * *","interval_seconds":null,"start_at":null,"end_at":null,"max_fire_count":null},"schedule_error":null} + + 普通任务必须输出空标题、空指令、null schedule 和 null schedule_error。 + + ### 示例 + - 输入“每天早上八点获取黄历”:title 为“查询黄历”,instruction 为“查询当天的黄历信息”,CRON 为 0 8 * * *。标题和指令中不要出现“每日”或“早上八点”。 + - “帮我获取今天的黄历”是普通任务。 + - “帮我分析一下每天早上八点的销量”是普通任务。 + - “帮我每天早上八点分析销量”是自动任务,CRON 为 0 8 * * *。 + - “每五分钟检查服务状态”是自动任务,INTERVAL 为 300 秒。 + +INTENT_ANALYSIS_USER_PROMPT: |- + 当前时间:{{ current_datetime }} + 默认时区:{{ timezone }} + 最小执行间隔:{{ min_interval_seconds }} 秒 + 用户消息:{{ message }} + +TASK_CONTENT_SYSTEM_PROMPT: |- + 你只负责生成自动任务的标题和单次执行提示词。输入已经移除了调度时间和周期信息。 + + 严格要求: + 1. 仅输出一个 JSON 对象,格式为 {"title":"...","instruction":"..."},不要输出解释或 Markdown。 + 2. title 是任务目标的短语,最多 20 个汉字,不包含时间、周期、Agent、工具或“定时任务”等字样。 + 3. instruction 只描述本次触发真正要完成的业务动作,使用直接、明确的祈使句。 + 4. 严格保留用户原意,不增加用户未要求的信息来源、工具、执行步骤、输出格式、异常处理或重试策略。 + 5. 删除“请、帮我、我希望、创建任务”等请求性套话,但保留业务对象、范围、条件和输出要求。 + 6. 原始动作已经清晰时只做最小改写。 + 7. 标题和执行提示词必须保持业务动作原本使用的语言,不要翻译。 + + 示例: + - 输入:给我发一句你好 + 输出:{"title":"发送你好","instruction":"发送一次“你好”"} + - 输入:汇总销售数据并生成 Excel 表格 + 输出:{"title":"汇总销售数据","instruction":"汇总销售数据并生成 Excel 表格"} + +TASK_CONTENT_USER_PROMPT: |- + 业务动作:{{ instruction }} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9481cdbc7..78639250b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pydantic-settings>=2.0.0", "python-docx>=1.1.0", "xlrd>=2.0.1", + "croniter>=2.0.0", ] [project.optional-dependencies] diff --git a/backend/services/agent_automation/__init__.py b/backend/services/agent_automation/__init__.py new file mode 100644 index 000000000..514e860e2 --- /dev/null +++ b/backend/services/agent_automation/__init__.py @@ -0,0 +1 @@ +"""Agent automation domain module.""" diff --git a/backend/services/agent_automation/agent_identity_adapter.py b/backend/services/agent_automation/agent_identity_adapter.py new file mode 100644 index 000000000..b9de6accf --- /dev/null +++ b/backend/services/agent_automation/agent_identity_adapter.py @@ -0,0 +1,77 @@ +import logging +from collections import defaultdict +from typing import Dict, Iterable, Optional, Tuple + +from sqlalchemy import or_, select + +from consts.const import ASSET_OWNER_TENANT_ID +from database.client import get_db_session +from database.db_models import AgentInfo + + +AgentReference = Tuple[int, int] +logger = logging.getLogger("agent_automation.agent_identity_adapter") + + +def resolve_agent_display_names( + references: Iterable[Tuple[int, Optional[int]]], + tenant_id: str, +) -> Dict[AgentReference, str]: + """Resolve user-facing Agent names in one query at the automation boundary.""" + normalized_references = { + (int(agent_id), int(version_no or 0)) + for agent_id, version_no in references + if agent_id + } + if not normalized_references: + return {} + + agent_ids = {agent_id for agent_id, _ in normalized_references} + try: + with get_db_session() as session: + rows = session.execute( + select( + AgentInfo.agent_id, + AgentInfo.version_no, + AgentInfo.name, + AgentInfo.display_name, + AgentInfo.tenant_id, + ).where( + AgentInfo.agent_id.in_(agent_ids), + or_( + AgentInfo.tenant_id == tenant_id, + AgentInfo.tenant_id == ASSET_OWNER_TENANT_ID, + ), + AgentInfo.delete_flag != "Y", + ) + ).all() + except Exception: + logger.warning("Failed to resolve Agent display names", exc_info=True) + return {} + + candidates = defaultdict(dict) + for row in rows: + key = (int(row.agent_id), int(row.version_no or 0)) + current = candidates[key].get("row") + if current is None or row.tenant_id == tenant_id: + candidates[key]["row"] = row + + resolved: Dict[AgentReference, str] = {} + for reference in normalized_references: + agent_id, version_no = reference + row = candidates.get((agent_id, version_no), {}).get("row") + if row is None: + row = candidates.get((agent_id, 0), {}).get("row") + if row is None: + available = [ + item["row"] + for (candidate_id, _), item in candidates.items() + if candidate_id == agent_id and item.get("row") is not None + ] + row = max(available, key=lambda item: int(item.version_no or 0), default=None) + if row is not None: + display_name = (row.display_name or row.name or "").strip() + if display_name: + resolved[reference] = display_name + + return resolved diff --git a/backend/services/agent_automation/capability_resolver.py b/backend/services/agent_automation/capability_resolver.py new file mode 100644 index 000000000..9fca40f7a --- /dev/null +++ b/backend/services/agent_automation/capability_resolver.py @@ -0,0 +1,192 @@ +import re +from typing import Any, Dict, Iterable, List, Optional + +from .agent_identity_adapter import resolve_agent_display_names +from .models import CapabilityBinding, CapabilityResolution, CapabilityType + + +SEARCH_HINTS = ("联网", "网络", "搜索", "新闻", "网页", "竞品", "公开") +KNOWLEDGE_HINTS = ("知识库", "文档", "资料", "项目", "销售线索") + + +def _safe_text(*parts: Any) -> str: + return " ".join(str(part or "") for part in parts).lower() + + +def _tool_binding(tool: Any) -> CapabilityBinding: + class_name = getattr(tool, "class_name", "") or "" + name = getattr(tool, "name", None) or class_name + metadata = getattr(tool, "metadata", None) or {} + if class_name == "KnowledgeBaseSearchTool": + index_names = (getattr(tool, "params", None) or {}).get("index_names", []) + display_map = metadata.get("index_name_to_display_map", {}) + display = ", ".join(display_map.get(index, index) for index in index_names) if index_names else name + return CapabilityBinding( + type=CapabilityType.KNOWLEDGE_BASE, + name=",".join(index_names) if index_names else name, + display_name=display, + binding_ref=f"tool:KnowledgeBaseSearchTool:index:{','.join(index_names)}", + reason="Agent has a configured knowledge-base search tool.", + ) + return CapabilityBinding( + type=CapabilityType.TOOL, + name=name, + display_name=name, + binding_ref=f"tool:{name}", + reason=getattr(tool, "description", None) or "Agent has this tool configured.", + ) + + +def _skill_binding(skill: Dict[str, Any]) -> CapabilityBinding: + name = skill.get("name") or "" + return CapabilityBinding( + type=CapabilityType.SKILL, + name=name, + display_name=name, + binding_ref=f"skill:{name}", + reason=skill.get("description") or "Agent has this skill enabled.", + ) + + +def _agent_binding(agent: Any, capability_type: CapabilityType) -> CapabilityBinding: + name = getattr(agent, "name", None) or getattr(agent, "agent_id", "") + return CapabilityBinding( + type=capability_type, + name=str(name), + display_name=str(name), + binding_ref=f"{capability_type.value.lower()}:{name}", + reason=getattr(agent, "description", None) or "Agent is available as a callable capability.", + ) + + +def _flatten_bindings(bindings: Iterable[CapabilityBinding]) -> Dict[str, CapabilityBinding]: + return {binding.binding_ref: binding for binding in bindings} + + +async def resolve_agent_capabilities( + agent_id: int, + tenant_id: str, + user_id: str, + instruction: str, + version_no: int = 0, +) -> CapabilityResolution: + """Resolve capabilities from the same assembly path used by normal agent runs.""" + from agents.create_agent_info import create_agent_config + from services.skill_service import SkillService + + agent_config = await create_agent_config( + agent_id=agent_id, + tenant_id=tenant_id, + user_id=user_id, + last_user_query=instruction, + version_no=version_no, + allow_memory_search=False, + ) + + tool_bindings = [_tool_binding(tool) for tool in getattr(agent_config, "tools", []) or []] + skill_bindings = [] + try: + enabled_skills = SkillService().get_enabled_skills_for_agent( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + skill_bindings.extend(_skill_binding(skill) for skill in enabled_skills) + except Exception: + enabled_skills = [] + + # Skills may also be carried in context components for context-manager agents. + for component in getattr(agent_config, "context_components", []) or []: + if getattr(component, "component_type", None) == "skills": + for skill in getattr(component, "skills", []) or []: + skill_bindings.append(_skill_binding(skill)) + + managed_bindings = [ + _agent_binding(agent, CapabilityType.MANAGED_AGENT) + for agent in getattr(agent_config, "managed_agents", []) or [] + ] + a2a_bindings = [ + _agent_binding(agent, CapabilityType.EXTERNAL_A2A_AGENT) + for agent in getattr(agent_config, "external_a2a_agents", []) or [] + ] + + all_bindings = tool_bindings + skill_bindings + managed_bindings + a2a_bindings + lower_instruction = instruction.lower() + missing: List[Dict[str, Any]] = [] + + has_search_tool = any( + binding.type == CapabilityType.TOOL + and re.search(r"search|linkup|exa|web|联网|搜索", binding.name.lower()) + for binding in all_bindings + ) + if any(hint in instruction for hint in SEARCH_HINTS) and not has_search_tool: + missing.append({ + "type": CapabilityType.TOOL.value, + "name": "web_search", + "suggestion": ( + "请先为该 Agent 启用联网搜索工具," + "或删除任务中的联网/新闻/网页检索要求。" + ), + }) + + has_knowledge = any(binding.type == CapabilityType.KNOWLEDGE_BASE for binding in all_bindings) + if any(hint in instruction for hint in KNOWLEDGE_HINTS) and not has_knowledge: + missing.append({ + "type": CapabilityType.KNOWLEDGE_BASE.value, + "name": "knowledge_base", + "suggestion": "请先为该 Agent 选择知识库,或修改任务为仅基于当前会话执行。", + }) + + matched = [] + for binding in all_bindings: + text = _safe_text(binding.name, binding.display_name, binding.reason) + instruction_tokens = re.findall(r"[\w\u4e00-\u9fff]+", lower_instruction) + if not lower_instruction or any(token in text for token in instruction_tokens): + matched.append(binding) + + if not matched: + matched = all_bindings[:10] + + agent_display_name = resolve_agent_display_names( + [(agent_id, version_no)], + tenant_id, + ).get((agent_id, version_no)) + + return CapabilityResolution( + matched_capabilities=matched[:20], + missing_capabilities=missing, + agent_snapshot={ + "agent_id": agent_id, + "version_no": version_no, + "name": getattr(agent_config, "name", ""), + "display_name": agent_display_name or getattr(agent_config, "name", ""), + "description": getattr(agent_config, "description", ""), + "tools_count": len(tool_bindings), + "skills_count": len(skill_bindings), + "managed_agents_count": len(managed_bindings), + "external_a2a_agents_count": len(a2a_bindings), + }, + executable=len(missing) == 0, + ) + + +async def validate_bindings_available( + agent_id: int, + tenant_id: str, + user_id: str, + instruction: str, + bindings: List[Dict[str, Any]], + version_no: int = 0, +) -> Dict[str, Any]: + resolution = await resolve_agent_capabilities(agent_id, tenant_id, user_id, instruction, version_no) + available = _flatten_bindings(resolution.matched_capabilities) + unavailable = [] + for binding in bindings or []: + ref = binding.get("binding_ref") + if ref and ref not in available: + unavailable.append(binding) + return { + "available": not unavailable and resolution.executable, + "unavailable_bindings": unavailable, + "resolution": resolution.model_dump(mode="json"), + } diff --git a/backend/services/agent_automation/conversation_adapter.py b/backend/services/agent_automation/conversation_adapter.py new file mode 100644 index 000000000..fd412baab --- /dev/null +++ b/backend/services/agent_automation/conversation_adapter.py @@ -0,0 +1,82 @@ +import json +import logging +from typing import Any, Dict, Optional + +from consts.model import MessageRequest, MessageUnit +from services.conversation_management_service import ( + get_conversation_history_service, + save_message, + save_message_unit, + update_unit_content, +) + +logger = logging.getLogger("agent_automation.conversation_adapter") + + +class AutomationConversationAdapter: + """Persist automation UI events through the existing conversation service.""" + + def append_proposal_exchange( + self, + conversation_id: int, + user_instruction: str, + payload: Dict[str, Any], + user_id: str, + tenant_id: str, + ) -> Dict[str, int]: + history_payload = get_conversation_history_service(conversation_id, user_id) + messages = history_payload[0].get("message", []) if history_payload else [] + user_request = MessageRequest( + conversation_id=conversation_id, + message_idx=len(messages), + role="user", + message=[MessageUnit(type="string", content=user_instruction)], + ) + user_message_id = save_message(user_request, user_id, tenant_id) + user_unit_id = save_message_unit( + message_id=user_message_id, + conversation_id=conversation_id, + unit_index=0, + unit_type="string", + unit_content=user_instruction, + user_id=user_id, + ) + content = json.dumps(payload, ensure_ascii=False) + request = MessageRequest( + conversation_id=conversation_id, + message_idx=len(messages) + 1, + role="assistant", + message=[MessageUnit(type="automation_proposal", content=content)], + ) + message_id = save_message(request, user_id, tenant_id) + unit_id = save_message_unit( + message_id=message_id, + conversation_id=conversation_id, + unit_index=0, + unit_type="automation_proposal", + unit_content=content, + user_id=user_id, + ) + return { + "user_message_id": user_message_id, + "user_unit_id": user_unit_id, + "message_id": message_id, + "unit_id": unit_id, + } + + def update_proposal( + self, + unit_id: Optional[int], + payload: Dict[str, Any], + user_id: str, + ) -> None: + if not unit_id: + return + update_unit_content( + unit_id, + json.dumps(payload, ensure_ascii=False), + user_id, + ) + + +automation_conversation_adapter = AutomationConversationAdapter() diff --git a/backend/services/agent_automation/errors.py b/backend/services/agent_automation/errors.py new file mode 100644 index 000000000..5c9e40ca9 --- /dev/null +++ b/backend/services/agent_automation/errors.py @@ -0,0 +1,33 @@ +class AgentAutomationError(Exception): + """Base domain exception for agent automation.""" + + error_code = "AUTOMATION_ERROR" + + def __init__(self, message: str, details: dict | None = None): + super().__init__(message) + self.message = message + self.details = details or {} + + +class AutomationCapabilityNotReadyError(AgentAutomationError): + error_code = "AUTOMATION_CAPABILITY_NOT_READY" + + +class AutomationCapabilityUnavailableError(AgentAutomationError): + error_code = "AUTOMATION_CAPABILITY_UNAVAILABLE" + + +class AutomationCapabilityBindingInvalidError(AgentAutomationError): + error_code = "AUTOMATION_CAPABILITY_BINDING_INVALID" + + +class AutomationScheduleInvalidError(AgentAutomationError): + error_code = "AUTOMATION_SCHEDULE_INVALID" + + +class AutomationNotFoundError(AgentAutomationError): + error_code = "AUTOMATION_TASK_NOT_FOUND" + + +class AutomationConversationAlreadyBoundError(AgentAutomationError): + error_code = "AUTOMATION_CONVERSATION_ALREADY_BOUND" diff --git a/backend/services/agent_automation/facade.py b/backend/services/agent_automation/facade.py new file mode 100644 index 000000000..d65b677e3 --- /dev/null +++ b/backend/services/agent_automation/facade.py @@ -0,0 +1,753 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy.exc import IntegrityError + +from consts.const import AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS, AGENT_AUTOMATION_MIN_INTERVAL_SECONDS +from database import agent_automation_db +from database.conversation_db import get_conversation +from services.conversation_management_service import ( + create_new_conversation, + update_conversation_agent_id_service, +) +from . import agent_identity_adapter +from .capability_resolver import resolve_agent_capabilities, validate_bindings_available +from .conversation_adapter import automation_conversation_adapter +from .errors import ( + AutomationCapabilityBindingInvalidError, + AutomationCapabilityNotReadyError, + AutomationConversationAlreadyBoundError, + AutomationNotFoundError, + AutomationScheduleInvalidError, +) +from .intent_analyzer import AutomationIntentContext, automation_intent_analyzer +from .models import ( + AutomationProposalConfirmRequest, + AutomationProposalCreateRequest, + AutomationProposalPatchRequest, + AutomationProposalStatus, + AutomationRunStatus, + AutomationSource, + AutomationTaskCreateRequest, + AutomationTaskPatchRequest, + AutomationTaskStatus, + CapabilityBinding, + ScheduleTrigger, +) +from .prompt_generator import ( + AutomationPromptContext, + AutomationTaskContent, + automation_prompt_generator, + detect_instruction_language, +) +from .schedule_engine import compute_next_fire_at, is_valid_cron_expression + +logger = logging.getLogger("agent_automation.facade") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _as_utc(value: datetime | str) -> datetime: + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +def _json(data: Any) -> Any: + if hasattr(data, "model_dump"): + return data.model_dump(mode="json") + if isinstance(data, list): + return [_json(item) for item in data] + return data + + +def _agent_reference(task: Dict[str, Any]) -> tuple[int, int]: + return int(task["agent_id"]), int(task.get("agent_version_no") or 0) + + +def _enrich_tasks_with_agent_names( + tasks: List[Dict[str, Any]], + tenant_id: str, +) -> List[Dict[str, Any]]: + if not tasks: + return [] + try: + names = agent_identity_adapter.resolve_agent_display_names( + [_agent_reference(task) for task in tasks], + tenant_id, + ) + except Exception: + logger.warning("Failed to resolve Agent display names", exc_info=True) + names = {} + enriched = [] + for task in tasks: + snapshot = task.get("runtime_snapshot") or {} + agent_id, version_no = _agent_reference(task) + agent_name = ( + names.get((agent_id, version_no)) + or snapshot.get("display_name") + or snapshot.get("name") + or f"Agent #{agent_id}" + ) + enriched.append({**task, "agent_name": agent_name}) + return enriched + + +def _enrich_task_with_agent_name(task: Dict[str, Any], tenant_id: str) -> Dict[str, Any]: + return _enrich_tasks_with_agent_names([task], tenant_id)[0] + + +def _parse_trigger(raw: Dict[str, Any] | ScheduleTrigger) -> ScheduleTrigger: + return raw if isinstance(raw, ScheduleTrigger) else ScheduleTrigger.model_validate(raw) + + +def _validate_schedule_policy(trigger: ScheduleTrigger) -> None: + if trigger.mode.value == "ONCE" and _as_utc(trigger.start_at) <= _utcnow(): + raise AutomationScheduleInvalidError( + "Automation execution time must be in the future.", + details={"start_at": trigger.start_at.isoformat()}, + ) + if trigger.rule_type.value == "CRON" and not is_valid_cron_expression(trigger.cron_expr or ""): + raise AutomationScheduleInvalidError( + "Automation cron expression is invalid.", + details={"cron_expr": trigger.cron_expr}, + ) + if ( + trigger.rule_type.value == "INTERVAL" + and trigger.interval_seconds is not None + and trigger.interval_seconds < AGENT_AUTOMATION_MIN_INTERVAL_SECONDS + ): + raise AutomationScheduleInvalidError( + f"Automation interval must be at least {AGENT_AUTOMATION_MIN_INTERVAL_SECONDS} seconds.", + details={ + "min_interval_seconds": AGENT_AUTOMATION_MIN_INTERVAL_SECONDS, + "interval_seconds": trigger.interval_seconds, + }, + ) + + +class AgentAutomationFacade: + async def create_proposal( + self, + request: AutomationProposalCreateRequest, + tenant_id: str, + user_id: str, + ) -> Dict[str, Any]: + try: + parsed = await automation_intent_analyzer.analyze(AutomationIntentContext( + tenant_id=tenant_id, + message=request.message, + timezone=request.timezone, + model_id=request.model_id, + )) + except ValueError as exc: + raise AutomationScheduleInvalidError( + f"无法解析任务执行时间:{exc}", + details={"input": request.message, "timezone": request.timezone}, + ) from exc + if not parsed.get("is_automation_intent"): + return { + "proposal_id": None, + "conversation_id": request.conversation_id, + "confidence": parsed.get("confidence", 0), + "executable": False, + "task": None, + "capability_resolution": None, + "intent_analysis_source": parsed.get("analysis_source", "rule"), + "task_content_source": parsed.get("task_content_source"), + } + if parsed.get("schedule_error") or not parsed.get("schedule_trigger"): + raise AutomationScheduleInvalidError( + parsed.get("schedule_error") or "Unable to determine the automation schedule.", + details={"input": request.message, "timezone": request.timezone}, + ) + _validate_schedule_policy(parsed["schedule_trigger"]) + + if parsed.get("task_content_generated"): + task_content = AutomationTaskContent( + title=parsed["title"], + instruction=parsed["instruction"], + ) + else: + task_content = await automation_prompt_generator.generate_task_content(AutomationPromptContext( + tenant_id=tenant_id, + instruction=parsed["instruction"], + language=detect_instruction_language(parsed["instruction"]), + )) + + conversation_id = request.conversation_id + if conversation_id is None: + conversation = create_new_conversation( + task_content.title, + user_id, + agent_id=request.agent_id, + ) + conversation_id = conversation["conversation_id"] + else: + conversation = get_conversation(conversation_id, user_id) + if not conversation: + raise AutomationNotFoundError("Conversation does not exist or is not accessible.") + if conversation_id == request.conversation_id: + update_conversation_agent_id_service( + conversation_id, + request.agent_id, + user_id, + ) + if agent_automation_db.get_task_by_conversation(conversation_id, user_id): + raise AutomationConversationAlreadyBoundError("Conversation already has an active automation task.") + + resolution = await resolve_agent_capabilities( + agent_id=request.agent_id, + tenant_id=tenant_id, + user_id=user_id, + instruction=task_content.instruction, + version_no=request.agent_version_no or 0, + ) + agent_name = ( + resolution.agent_snapshot.get("display_name") + or resolution.agent_snapshot.get("name") + or f"Agent #{request.agent_id}" + ) + proposed_task = { + "title": task_content.title, + "instruction": task_content.instruction, + "original_instruction": parsed["instruction"], + "agent_id": request.agent_id, + "agent_name": agent_name, + "agent_version_no": request.agent_version_no, + "model_id": request.model_id, + "tool_params": request.tool_params, + "schedule_trigger": parsed["schedule_trigger"].model_dump(mode="json"), + } + proposal = agent_automation_db.create_proposal({ + "tenant_id": tenant_id, + "user_id": user_id, + "conversation_id": conversation_id, + "agent_id": request.agent_id, + "proposed_task": proposed_task, + "capability_resolution": resolution.model_dump(mode="json"), + "status": AutomationProposalStatus.PENDING.value, + "expires_at": _utcnow() + timedelta(hours=24), + }, user_id) + response = { + "proposal_id": proposal["proposal_id"], + "conversation_id": conversation_id, + "confidence": parsed["confidence"], + "executable": resolution.executable, + "task": proposed_task, + "capability_resolution": resolution.model_dump(mode="json"), + "intent_analysis_source": parsed.get("analysis_source", "rule"), + "task_content_source": parsed.get("task_content_source", "rule"), + } + try: + message_refs = automation_conversation_adapter.append_proposal_exchange( + conversation_id, + request.message, + response, + user_id, + tenant_id, + ) + stored_task = { + **proposed_task, + "_conversation_message_id": message_refs["message_id"], + "_conversation_unit_id": message_refs["unit_id"], + } + agent_automation_db.update_proposal_task( + proposal["proposal_id"], + tenant_id, + user_id, + stored_task, + ) + except Exception as exc: + logger.warning("Failed to persist automation proposal card: %s", exc, exc_info=True) + return response + + async def update_proposal( + self, + proposal_id: int, + request: AutomationProposalPatchRequest, + tenant_id: str, + user_id: str, + ) -> Dict[str, Any]: + proposal = agent_automation_db.get_proposal(proposal_id, tenant_id, user_id) + editable_statuses = { + AutomationProposalStatus.PENDING.value, + AutomationProposalStatus.ACCEPTED.value, + } + if not proposal or proposal["status"] not in editable_statuses: + raise AutomationNotFoundError("Automation proposal does not exist or is not editable.") + expires_at = proposal.get("expires_at") + if ( + proposal["status"] == AutomationProposalStatus.PENDING.value + and expires_at + and _as_utc(expires_at) <= _utcnow() + ): + agent_automation_db.update_proposal_status( + proposal_id, + tenant_id, + user_id, + AutomationProposalStatus.EXPIRED.value, + ) + raise AutomationNotFoundError("Automation proposal has expired.") + + proposed_task = dict(proposal["proposed_task"]) + if request.title is not None: + proposed_task["title"] = request.title.strip() + if request.instruction is not None: + proposed_task["instruction"] = request.instruction.strip() + if request.schedule_trigger is not None: + _validate_schedule_policy(request.schedule_trigger) + proposed_task["schedule_trigger"] = request.schedule_trigger.model_dump(mode="json") + + confirmed_task_id = None + if proposal["status"] == AutomationProposalStatus.ACCEPTED.value: + task = self.get_task_for_conversation(proposal["conversation_id"], tenant_id, user_id) + if not task: + raise AutomationNotFoundError("Confirmed automation task does not exist.") + updated_task = await self.patch_task( + task["task_id"], + AutomationTaskPatchRequest( + title=request.title, + instruction=request.instruction, + schedule_trigger=request.schedule_trigger, + ), + tenant_id, + user_id, + ) + confirmed_task_id = updated_task["task_id"] + resolution_data = ( + updated_task.get("capability_requirements") + or proposal.get("capability_resolution") + or {} + ) + executable = True + else: + resolution = await resolve_agent_capabilities( + agent_id=proposal["agent_id"], + tenant_id=tenant_id, + user_id=user_id, + instruction=proposed_task["instruction"], + version_no=proposed_task.get("agent_version_no") or 0, + ) + resolution_data = resolution.model_dump(mode="json") + executable = resolution.executable + if not agent_automation_db.update_proposal( + proposal_id, + tenant_id, + user_id, + proposed_task, + resolution_data, + ): + raise AutomationNotFoundError("Automation proposal does not exist or is not editable.") + + public_task = {key: value for key, value in proposed_task.items() if not key.startswith("_")} + public_task["agent_name"] = ( + (updated_task if confirmed_task_id is not None else {}).get("agent_name") + or (resolution_data.get("agent_snapshot") or {}).get("display_name") + or (resolution_data.get("agent_snapshot") or {}).get("name") + or proposed_task.get("agent_name") + or f"Agent #{proposal['agent_id']}" + ) + response = { + "proposal_id": proposal_id, + "conversation_id": proposal["conversation_id"], + "executable": executable, + "task": public_task, + "capability_resolution": resolution_data, + } + if confirmed_task_id is not None: + response["confirmed_task_id"] = confirmed_task_id + try: + automation_conversation_adapter.update_proposal( + proposed_task.get("_conversation_unit_id"), + response, + user_id, + ) + except Exception as exc: + logger.warning("Failed to persist updated automation proposal card: %s", exc, exc_info=True) + return response + + async def confirm_proposal( + self, + proposal_id: int, + request: AutomationProposalConfirmRequest, + tenant_id: str, + user_id: str, + ) -> Dict[str, Any]: + proposal = agent_automation_db.get_proposal(proposal_id, tenant_id, user_id) + if not proposal or proposal["status"] != AutomationProposalStatus.PENDING.value: + raise AutomationNotFoundError("Automation proposal does not exist or is not pending.") + expires_at = proposal.get("expires_at") + if expires_at and _as_utc(expires_at) <= _utcnow(): + agent_automation_db.update_proposal_status( + proposal_id, + tenant_id, + user_id, + AutomationProposalStatus.EXPIRED.value, + ) + raise AutomationNotFoundError("Automation proposal has expired.") + + proposed_task = proposal["proposed_task"] + instruction = request.instruction or proposed_task["instruction"] + resolution = await resolve_agent_capabilities( + agent_id=proposal["agent_id"], + tenant_id=tenant_id, + user_id=user_id, + instruction=instruction, + version_no=proposed_task.get("agent_version_no") or 0, + ) + if not resolution.executable: + raise AutomationCapabilityNotReadyError( + "Required automation capabilities are not ready.", + details=resolution.model_dump(mode="json"), + ) + + create_request = AutomationTaskCreateRequest( + title=proposed_task["title"], + agent_id=proposal["agent_id"], + instruction=instruction, + original_instruction=proposed_task.get("original_instruction") or instruction, + schedule_trigger=_parse_trigger(proposed_task["schedule_trigger"]), + conversation_id=proposal["conversation_id"], + agent_version_no=proposed_task.get("agent_version_no"), + model_id=proposed_task.get("model_id"), + tool_params=proposed_task.get("tool_params"), + capability_bindings=resolution.matched_capabilities, + ) + task = await self.create_task(create_request, tenant_id, user_id) + agent_automation_db.update_proposal_status( + proposal_id, tenant_id, user_id, AutomationProposalStatus.ACCEPTED.value) + public_task = {key: value for key, value in proposed_task.items() if not key.startswith("_")} + public_task["agent_name"] = ( + task.get("agent_name") + or proposed_task.get("agent_name") + or f"Agent #{proposal['agent_id']}" + ) + try: + automation_conversation_adapter.update_proposal( + proposed_task.get("_conversation_unit_id"), + { + "proposal_id": proposal_id, + "executable": True, + "task": public_task, + "capability_resolution": proposal["capability_resolution"], + "confirmed_task_id": task["task_id"], + }, + user_id, + ) + except Exception as exc: + logger.warning("Failed to persist confirmed automation proposal card: %s", exc, exc_info=True) + return task + + async def create_task( + self, + request: AutomationTaskCreateRequest, + tenant_id: str, + user_id: str, + ) -> Dict[str, Any]: + trigger = request.schedule_trigger + _validate_schedule_policy(trigger) + conversation_id = request.conversation_id + if not get_conversation(conversation_id, user_id): + raise AutomationNotFoundError("Conversation does not exist or is not accessible.") + + if agent_automation_db.get_task_by_conversation(conversation_id, user_id): + raise AutomationConversationAlreadyBoundError("Conversation already has an active automation task.") + + resolution = await resolve_agent_capabilities( + agent_id=request.agent_id, + tenant_id=tenant_id, + user_id=user_id, + instruction=request.instruction, + version_no=request.agent_version_no or 0, + ) + if not resolution.executable: + raise AutomationCapabilityNotReadyError( + "Required automation capabilities are not ready.", + details=resolution.model_dump(mode="json"), + ) + + if request.capability_bindings: + check = await validate_bindings_available( + request.agent_id, + tenant_id, + user_id, + request.instruction, + [_json(binding) for binding in request.capability_bindings], + request.agent_version_no or 0, + ) + if check["unavailable_bindings"]: + raise AutomationCapabilityBindingInvalidError( + "Submitted capability bindings are not available for this agent.", + details=check, + ) + bindings = [_json(binding) for binding in request.capability_bindings] + else: + bindings = resolution.model_dump(mode="json")["matched_capabilities"] + + next_fire_at = compute_next_fire_at(trigger, _utcnow(), 0) + runtime_snapshot = { + **resolution.agent_snapshot, + "display_name": ( + resolution.agent_snapshot.get("display_name") + or resolution.agent_snapshot.get("name") + or f"Agent #{request.agent_id}" + ), + "model_id": request.model_id, + "tool_params": request.tool_params, + "original_instruction": request.original_instruction or request.instruction, + } + try: + task = agent_automation_db.create_task({ + "tenant_id": tenant_id, + "user_id": user_id, + "conversation_id": conversation_id, + "agent_id": request.agent_id, + "agent_version_no": request.agent_version_no, + "title": request.title, + "instruction": request.instruction, + "status": AutomationTaskStatus.ACTIVE.value, + "source": AutomationSource.CHAT_INTENT.value, + "schedule_mode": trigger.mode.value, + "schedule_rule_type": trigger.rule_type.value, + "schedule_expr": trigger.cron_expr or str(trigger.interval_seconds or trigger.start_at), + "schedule_config": trigger.model_dump(mode="json"), + "capability_requirements": resolution.model_dump(mode="json"), + "capability_bindings": bindings, + "runtime_snapshot": runtime_snapshot, + "timezone": trigger.timezone, + "next_fire_at": next_fire_at, + "fire_count": 0, + "consecutive_failures": 0, + "timeout_seconds": request.timeout_seconds or AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS, + "overlap_policy": "SKIP", + "misfire_policy": "RUN_ONCE", + }, user_id) + except IntegrityError as exc: + constraint_name = getattr(getattr(exc.orig, "diag", None), "constraint_name", None) + if constraint_name == "uq_agent_automation_conversation_active": + raise AutomationConversationAlreadyBoundError( + "Conversation already has an active automation task." + ) from exc + raise + return _enrich_task_with_agent_name(task, tenant_id) + + def list_tasks( + self, + tenant_id: str, + user_id: str, + status: Optional[str] = None, + search: Optional[str] = None, + ) -> List[Dict[str, Any]]: + tasks = agent_automation_db.list_tasks(tenant_id, user_id, status, search) + return _enrich_tasks_with_agent_names(tasks, tenant_id) + + def get_task(self, task_id: int, tenant_id: str, user_id: str) -> Dict[str, Any]: + task = agent_automation_db.get_task(task_id, tenant_id, user_id) + if not task: + raise AutomationNotFoundError("Automation task not found.") + return _enrich_task_with_agent_name(task, tenant_id) + + def get_task_for_conversation( + self, + conversation_id: int, + tenant_id: str, + user_id: str, + ) -> Optional[Dict[str, Any]]: + task = agent_automation_db.get_task_by_conversation(conversation_id, user_id) + return _enrich_task_with_agent_name(task, tenant_id) if task else None + + async def patch_task( + self, + task_id: int, + request: AutomationTaskPatchRequest, + tenant_id: str, + user_id: str, + ) -> Dict[str, Any]: + task = self.get_task(task_id, tenant_id, user_id) + values: Dict[str, Any] = {} + instruction = request.instruction or task["instruction"] + if request.title is not None: + values["title"] = request.title + if request.instruction is not None: + values["instruction"] = request.instruction + if request.timeout_seconds is not None: + values["timeout_seconds"] = request.timeout_seconds + if request.model_id is not None or request.tool_params is not None: + snapshot = dict(task.get("runtime_snapshot") or {}) + if request.model_id is not None: + snapshot["model_id"] = request.model_id + if request.tool_params is not None: + snapshot["tool_params"] = request.tool_params + values["runtime_snapshot"] = snapshot + if request.schedule_trigger is not None: + trigger = request.schedule_trigger + _validate_schedule_policy(trigger) + values.update({ + "schedule_mode": trigger.mode.value, + "schedule_rule_type": trigger.rule_type.value, + "schedule_expr": trigger.cron_expr or str(trigger.interval_seconds or trigger.start_at), + "schedule_config": trigger.model_dump(mode="json"), + "timezone": trigger.timezone, + "next_fire_at": compute_next_fire_at(trigger, _utcnow(), int(task.get("fire_count") or 0)), + }) + if request.instruction is not None or request.capability_bindings is not None: + resolution = await resolve_agent_capabilities( + task["agent_id"], tenant_id, user_id, instruction, task.get("agent_version_no") or 0) + if not resolution.executable: + raise AutomationCapabilityNotReadyError( + "Required automation capabilities are not ready.", + details=resolution.model_dump(mode="json"), + ) + values["capability_requirements"] = resolution.model_dump(mode="json") + values["capability_bindings"] = ( + _json(request.capability_bindings) + if request.capability_bindings + else resolution.model_dump(mode="json")["matched_capabilities"] + ) + snapshot = dict(values.get("runtime_snapshot") or task.get("runtime_snapshot") or {}) + snapshot.update(resolution.agent_snapshot) + if request.instruction is not None: + snapshot["original_instruction"] = request.instruction + values["runtime_snapshot"] = snapshot + updated = agent_automation_db.update_task(task_id, tenant_id, user_id, values) + if not updated: + raise AutomationNotFoundError("Automation task not found.") + return _enrich_task_with_agent_name(updated, tenant_id) + + def pause_task(self, task_id: int, tenant_id: str, user_id: str) -> Dict[str, Any]: + task = agent_automation_db.update_task( + task_id, + tenant_id, + user_id, + {"status": AutomationTaskStatus.PAUSED.value}, + ) + if not task: + raise AutomationNotFoundError("Automation task not found.") + return _enrich_task_with_agent_name(task, tenant_id) + + def resume_task(self, task_id: int, tenant_id: str, user_id: str) -> Dict[str, Any]: + task = self.get_task(task_id, tenant_id, user_id) + trigger = _parse_trigger(task["schedule_config"]) + next_fire_at = compute_next_fire_at(trigger, _utcnow(), int(task.get("fire_count") or 0)) + if next_fire_at is None: + raise AutomationScheduleInvalidError("Automation task has no future fire time.") + updated = agent_automation_db.update_task(task_id, tenant_id, user_id, { + "status": AutomationTaskStatus.ACTIVE.value, + "next_fire_at": next_fire_at, + }) + if not updated: + raise AutomationNotFoundError("Automation task not found.") + return _enrich_task_with_agent_name(updated, tenant_id) + + def delete_task(self, task_id: int, tenant_id: str, user_id: str) -> bool: + task = self.get_task(task_id, tenant_id, user_id) + self._cancel_active_runs_for_conversation( + task["conversation_id"], + user_id, + "Automation task was deleted.", + ) + if not agent_automation_db.soft_delete_task(task_id, tenant_id, user_id): + raise AutomationNotFoundError("Automation task not found.") + return True + + def list_runs(self, task_id: int, tenant_id: str, user_id: str) -> List[Dict[str, Any]]: + self.get_task(task_id, tenant_id, user_id) + return agent_automation_db.list_runs(task_id, tenant_id, user_id) + + async def run_task_now(self, task_id: int, tenant_id: str, user_id: str) -> Dict[str, Any]: + task = self.get_task(task_id, tenant_id, user_id) + from .runner import agent_automation_runner + return await agent_automation_runner.execute_task(task, trigger_type="MANUAL") + + def cancel_run(self, run_id: int, tenant_id: str, user_id: str) -> Dict[str, Any]: + run = agent_automation_db.get_run(run_id, tenant_id, user_id) + if not run: + raise AutomationNotFoundError("Automation run not found.") + + if run["status"] not in {AutomationRunStatus.QUEUED.value, AutomationRunStatus.RUNNING.value}: + return run + + self._request_conversation_stop(run["conversation_id"], user_id) + canceled = agent_automation_db.cancel_run( + run_id, + tenant_id, + user_id, + "Automation run was canceled by user.", + ) + agent_automation_db.update_task(run["task_id"], tenant_id, user_id, { + "last_run_status": AutomationRunStatus.CANCELED.value, + "last_error": "Automation run was canceled by user.", + "lock_owner": None, + "lock_until": None, + }) + return canceled or agent_automation_db.get_run(run_id, tenant_id, user_id) or run + + def delete_run(self, run_id: int, tenant_id: str, user_id: str) -> bool: + run = agent_automation_db.get_run(run_id, tenant_id, user_id) + if not run: + raise AutomationNotFoundError("Automation run not found.") + + terminal_statuses = { + AutomationRunStatus.SUCCEEDED.value, + AutomationRunStatus.FAILED.value, + AutomationRunStatus.SKIPPED.value, + AutomationRunStatus.CANCELED.value, + AutomationRunStatus.TIMEOUT.value, + } + if run["status"] not in terminal_statuses: + raise AutomationScheduleInvalidError( + "Active automation runs must be canceled before deletion." + ) + + deleted = agent_automation_db.soft_delete_run( + run_id, + tenant_id, + user_id, + list(terminal_statuses), + ) + if not deleted: + raise AutomationNotFoundError("Automation run not found or no longer deletable.") + + remaining_runs = agent_automation_db.list_runs( + run["task_id"], + tenant_id, + user_id, + limit=1, + ) + latest_run = remaining_runs[0] if remaining_runs else None + agent_automation_db.update_task( + run["task_id"], + tenant_id, + user_id, + { + "last_run_status": latest_run.get("status") if latest_run else None, + "last_error": latest_run.get("error_message") if latest_run else None, + }, + ) + return True + + def on_conversation_deleted(self, conversation_id: int, user_id: str) -> int: + self._cancel_active_runs_for_conversation( + conversation_id, + user_id, + "Conversation was deleted.", + ) + return agent_automation_db.soft_delete_task_by_conversation(conversation_id, user_id) + + def _cancel_active_runs_for_conversation(self, conversation_id: int, user_id: str, reason: str) -> None: + self._request_conversation_stop(conversation_id, user_id) + agent_automation_db.cancel_runs_by_conversation(conversation_id, user_id, reason) + + def _request_conversation_stop(self, conversation_id: int, user_id: str) -> None: + try: + from .runner import agent_automation_runner + agent_automation_runner.cancel_for_conversation(conversation_id, user_id) + except Exception: + pass + + +agent_automation_facade = AgentAutomationFacade() diff --git a/backend/services/agent_automation/intent_analyzer.py b/backend/services/agent_automation/intent_analyzer.py new file mode 100644 index 000000000..00c6e4990 --- /dev/null +++ b/backend/services/agent_automation/intent_analyzer.py @@ -0,0 +1,338 @@ +import asyncio +import json +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, Optional +from zoneinfo import ZoneInfo + +from jinja2 import StrictUndefined, Template +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from consts.const import ( + AGENT_AUTOMATION_MIN_INTERVAL_SECONDS, + MESSAGE_ROLE, + MODEL_CONFIG_MAPPING, +) +from database.model_management_db import get_model_by_model_id +from utils.prompt_template_utils import get_prompt_template + +from .intent_parser import has_automation_schedule_signal, parse_automation_intent +from .models import ScheduleMode, ScheduleRuleType, ScheduleTrigger +from .prompt_generator import ( + AutomationTaskContent, + _fallback_title, + _normalize_task_content, + detect_instruction_language, +) +from .schedule_engine import is_valid_cron_expression + +logger = logging.getLogger("agent_automation.intent_analyzer") + + +class _LLMSchedulePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + rule_type: ScheduleRuleType + timezone: Optional[str] = None + cron_expr: Optional[str] = None + interval_seconds: Optional[int] = Field(default=None, gt=0) + start_at: Optional[datetime] = None + end_at: Optional[datetime] = None + max_fire_count: Optional[int] = Field(default=None, gt=0) + + +class _LLMIntentPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_automation_intent: bool + confidence: float = Field(ge=0, le=1) + title: str = "" + instruction: str = "" + schedule: Optional[_LLMSchedulePayload] = None + schedule_error: Optional[str] = None + + +@dataclass(frozen=True) +class AutomationIntentContext: + tenant_id: str + message: str + timezone: str = "Asia/Shanghai" + model_id: Optional[int] = None + reference_time: Optional[datetime] = None + + +def _analysis_time(context: AutomationIntentContext) -> datetime: + try: + zone = ZoneInfo(context.timezone) + except Exception as exc: + raise ValueError(f"Invalid automation timezone: {context.timezone}") from exc + now = context.reference_time or datetime.now(zone) + return now.astimezone(zone) if now.tzinfo else now.replace(tzinfo=zone) + + +def _extract_json_object(content: str) -> Dict[str, Any]: + normalized = re.sub(r"[\s\S]*?", "", content or "", flags=re.IGNORECASE).strip() + fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", normalized, flags=re.IGNORECASE) + if fence_match: + normalized = fence_match.group(1).strip() + try: + parsed = json.loads(normalized) + except json.JSONDecodeError: + object_match = re.search(r"\{[\s\S]*\}", normalized) + if not object_match: + raise + parsed = json.loads(object_match.group(0)) + if not isinstance(parsed, dict): + raise ValueError("Automation intent analysis must be a JSON object.") + return parsed + + +def _localized_datetime(value: Optional[datetime], zone: ZoneInfo) -> Optional[datetime]: + if value is None: + return None + return value.astimezone(zone) if value.tzinfo else value.replace(tzinfo=zone) + + +def _invalid_llm_schedule(payload: _LLMIntentPayload, reason: str) -> Dict[str, Any]: + return { + "is_automation_intent": True, + "confidence": payload.confidence, + "title": payload.title.strip(), + "instruction": payload.instruction.strip(), + "schedule_trigger": None, + "schedule_error": reason, + "capability_intents": [], + "output_requirements": {}, + "analysis_source": "llm", + "task_content_generated": True, + } + + +def _payload_to_result( + payload: _LLMIntentPayload, + context: AutomationIntentContext, + fallback: Dict[str, Any], +) -> Dict[str, Any]: + if not payload.is_automation_intent: + return { + "is_automation_intent": False, + "confidence": payload.confidence, + "analysis_source": "llm", + } + + if not payload.instruction.strip(): + return _invalid_llm_schedule(payload, "无法确定自动任务需要执行的具体业务动作。") + + fallback_instruction = ( + fallback.get("instruction") + if fallback.get("is_automation_intent") + else payload.instruction.strip() + ) or context.message.strip() + fallback_content = AutomationTaskContent( + title=_fallback_title(fallback_instruction), + instruction=fallback_instruction, + ) + task_content = _normalize_task_content( + json.dumps( + {"title": payload.title, "instruction": payload.instruction}, + ensure_ascii=False, + ), + fallback_content, + source=fallback_instruction, + ) + task_content_source = "llm" + if task_content == fallback_content and ( + payload.title.strip() != fallback_content.title + or payload.instruction.strip() != fallback_content.instruction + ): + task_content_source = "rule" + if payload.schedule_error: + invalid = payload.model_copy(update={ + "title": task_content.title, + "instruction": task_content.instruction, + }) + return _invalid_llm_schedule(invalid, payload.schedule_error) + if payload.schedule is None: + invalid = payload.model_copy(update={ + "title": task_content.title, + "instruction": task_content.instruction, + }) + return _invalid_llm_schedule(invalid, "无法确定任务执行时间,请补充具体日期、时间或周期。") + + zone = ZoneInfo(payload.schedule.timezone or context.timezone) + now = _analysis_time(context).astimezone(zone) + schedule = payload.schedule + start_at = _localized_datetime(schedule.start_at, zone) + end_at = _localized_datetime(schedule.end_at, zone) + + if schedule.rule_type == ScheduleRuleType.AT: + if start_at is None: + return _invalid_llm_schedule(payload, "一次性任务缺少明确的未来执行时间。") + trigger = ScheduleTrigger( + mode=ScheduleMode.ONCE, + rule_type=ScheduleRuleType.AT, + timezone=zone.key, + start_at=start_at, + end_at=end_at, + ) + elif schedule.rule_type == ScheduleRuleType.INTERVAL: + if schedule.interval_seconds is None: + return _invalid_llm_schedule(payload, "周期任务缺少有效的执行间隔。") + if schedule.interval_seconds < AGENT_AUTOMATION_MIN_INTERVAL_SECONDS: + return _invalid_llm_schedule( + payload, + f"任务执行间隔不能小于 {AGENT_AUTOMATION_MIN_INTERVAL_SECONDS} 秒。", + ) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.INTERVAL, + timezone=zone.key, + start_at=start_at or now.replace(microsecond=0) + timedelta(seconds=schedule.interval_seconds), + end_at=end_at, + interval_seconds=schedule.interval_seconds, + max_fire_count=schedule.max_fire_count, + ) + else: + if not schedule.cron_expr or not is_valid_cron_expression(schedule.cron_expr): + return _invalid_llm_schedule(payload, "大模型生成的 Cron 表达式无效,请补充或修改执行周期。") + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.CRON, + timezone=zone.key, + start_at=start_at or now.replace(second=0, microsecond=0), + end_at=end_at, + cron_expr=schedule.cron_expr, + max_fire_count=schedule.max_fire_count, + ) + + return { + "is_automation_intent": True, + "confidence": payload.confidence, + "title": task_content.title, + "instruction": task_content.instruction, + "schedule_trigger": trigger, + "schedule_error": None, + "capability_intents": [], + "output_requirements": {}, + "analysis_source": "llm", + "task_content_generated": True, + "task_content_source": task_content_source, + } + + +class AutomationIntentAnalysisStrategy(ABC): + @abstractmethod + async def analyze(self, context: AutomationIntentContext) -> Dict[str, Any]: + raise NotImplementedError + + +class RuleBasedAutomationIntentStrategy(AutomationIntentAnalysisStrategy): + async def analyze(self, context: AutomationIntentContext) -> Dict[str, Any]: + result = parse_automation_intent( + context.message, + context.timezone, + context.tenant_id, + context.reference_time, + ) + return { + **result, + "analysis_source": "rule", + "task_content_generated": False, + } + + +class LLMAutomationIntentStrategy(AutomationIntentAnalysisStrategy): + def __init__(self, model_config: Dict[str, Any], fallback: AutomationIntentAnalysisStrategy): + self._model_config = model_config + self._fallback = fallback + + async def analyze(self, context: AutomationIntentContext) -> Dict[str, Any]: + fallback = await self._fallback.analyze(context) + if not has_automation_schedule_signal(context.message): + return fallback + try: + content = await asyncio.to_thread(self._generate_sync, context) + payload = _LLMIntentPayload.model_validate(_extract_json_object(content)) + return _payload_to_result(payload, context, fallback) + except (ValidationError, ValueError, KeyError, json.JSONDecodeError) as exc: + logger.warning("Invalid LLM automation intent output, using rule fallback: %s", exc) + return fallback + except Exception as exc: + logger.warning("Failed to analyze automation intent with LLM, using rule fallback: %s", exc) + return fallback + + def _generate_sync(self, context: AutomationIntentContext) -> str: + from nexent.core.models import OpenAIModel + from utils.config_utils import get_model_name_from_config + + language = detect_instruction_language(context.message) + prompt_template = get_prompt_template("agent_automation", language) + now = _analysis_time(context) + values = { + "message": context.message.strip(), + "current_datetime": now.isoformat(), + "timezone": context.timezone, + "min_interval_seconds": AGENT_AUTOMATION_MIN_INTERVAL_SECONDS, + } + user_prompt = Template( + prompt_template["INTENT_ANALYSIS_USER_PROMPT"], + undefined=StrictUndefined, + ).render(**values).strip() + llm = OpenAIModel( + model_id=get_model_name_from_config(self._model_config), + api_base=self._model_config.get("base_url", ""), + api_key=self._model_config.get("api_key", ""), + temperature=0.1, + top_p=0.9, + max_output_tokens=700, + model_factory=self._model_config.get("model_factory"), + ssl_verify=self._model_config.get("ssl_verify", True), + display_name=self._model_config.get("display_name"), + timeout_seconds=self._model_config.get("timeout_seconds"), + stream=False, + ) + response = llm.generate([ + { + "role": MESSAGE_ROLE["SYSTEM"], + "content": prompt_template["INTENT_ANALYSIS_SYSTEM_PROMPT"], + }, + {"role": MESSAGE_ROLE["USER"], "content": user_prompt}, + ]) + return getattr(response, "content", "") or "" + + +class AutomationIntentStrategyFactory: + def create(self, context: AutomationIntentContext) -> AutomationIntentAnalysisStrategy: + fallback = RuleBasedAutomationIntentStrategy() + try: + model_config = None + if context.model_id is not None: + selected = get_model_by_model_id(context.model_id, context.tenant_id) + if selected and selected.get("model_type") == "llm": + model_config = selected + if model_config is None: + from utils.config_utils import tenant_config_manager + + model_config = tenant_config_manager.get_model_config( + key=MODEL_CONFIG_MAPPING["llm"], + tenant_id=context.tenant_id, + ) + if model_config: + return LLMAutomationIntentStrategy(model_config, fallback) + except Exception as exc: + logger.warning("Failed to resolve automation intent model, using rule fallback: %s", exc) + return fallback + + +class AutomationIntentAnalyzer: + def __init__(self, factory: Optional[AutomationIntentStrategyFactory] = None): + self._factory = factory or AutomationIntentStrategyFactory() + + async def analyze(self, context: AutomationIntentContext) -> Dict[str, Any]: + return await self._factory.create(context).analyze(context) + + +automation_intent_analyzer = AutomationIntentAnalyzer() diff --git a/backend/services/agent_automation/intent_parser.py b/backend/services/agent_automation/intent_parser.py new file mode 100644 index 000000000..5894cc79f --- /dev/null +++ b/backend/services/agent_automation/intent_parser.py @@ -0,0 +1,947 @@ +import re +from datetime import date, datetime, time, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +from .models import ScheduleMode, ScheduleRuleType, ScheduleTrigger + + +_NUMBER_TOKEN = r"(?:\d+|[零一二两三四五六七八九十百]+|半)" +_CLOCK_NUMBER_TOKEN = r"(?:\d{1,2}|[零一二两三四五六七八九十]+)" +_INTERVAL_PATTERN = re.compile( + rf"(?:每(?:隔\s*)?|隔\s*)(?P{_NUMBER_TOKEN})?\s*(?:个)?" + r"(?P秒钟?|分钟?|小时|钟头)" +) +_DAY_INTERVAL_PATTERN = re.compile( + rf"每(?:隔\s*)?(?P{_NUMBER_TOKEN})\s*(?:个)?(?P天|日|周|星期)" +) +_RELATIVE_DELAY_PATTERN = re.compile( + rf"(?P{_NUMBER_TOKEN})\s*(?:个)?(?P秒钟?|分钟?|小时|钟头|天|日|周|星期)" + r"\s*(?:以后|之后|后)" +) +_HOURLY_OFFSET_PATTERN = re.compile( + rf"每(?:个)?小时(?:的)?(?:(?:第)?(?P{_CLOCK_NUMBER_TOKEN})\s*分(?:钟)?|" + r"(?P整点|半点))" +) +_EXPLICIT_DATE_PATTERN = re.compile( + r"(?:(?P\d{4})\s*年\s*)?(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?" +) +_ISO_DATE_PATTERN = re.compile(r"(?P\d{4})[-/](?P\d{1,2})[-/](?P\d{1,2})") +_SHORT_DATE_PATTERN = re.compile(r"(?\d{1,2})/(?P\d{1,2})(?!\d)") +_RELATIVE_MONTH_DAY_PATTERN = re.compile( + r"(?P下个?月|本月|这个月)\s*(?P\d{1,2})\s*(?:日|号)?" +) +_DAY_OF_MONTH_ONCE_PATTERN = re.compile(r"(?\d{1,2})\s*(?:日|号)") +_WEEKDAY_PATTERN = re.compile( + r"(?:(?P下|本|这(?:个)?)(?:周|星期|礼拜)|(?:周|星期|礼拜))" + r"(?P[一二三四五六日天])" +) +_RECURRING_WEEKDAY_PATTERN = re.compile( + r"(?:每(?:个)?|每逢|逢)(?:周|星期|礼拜)(?P[一二三四五六日天]" + r"(?:\s*[、,,/和及]\s*(?:(?:周|星期|礼拜))?[一二三四五六日天])*)" +) +_RECURRING_WEEKDAY_RANGE_PATTERN = re.compile( + r"每(?:个)?(?:周|星期|礼拜)(?P[一二三四五六日天])\s*(?:到|至|[-~~])\s*" + r"(?:(?:周|星期|礼拜))?(?P[一二三四五六日天])" +) +_MONTH_DAY_LIST_PATTERN = re.compile( + r"每(?:个)?月\s*(?P\d{1,2}(?:\s*(?:号|日))?" + r"(?:\s*[、,,/和及]\s*\d{1,2}(?:\s*(?:号|日))?)*)" +) +_YEARLY_PATTERN = re.compile(r"每年(?:的)?\s*(?P\d{1,2})\s*月\s*(?P\d{1,2})\s*(?:日|号)?") +_MONTH_END_PATTERN = re.compile(r"(?:每(?:个)?月(?:的)?最后一天|每(?:个)?月末|每(?:个)?月底)") +_QUARTERLY_PATTERN = re.compile( + r"每(?:个)?季度(?:的)?(?:第)?(?P\d{1,2}|一)\s*(?:天|日|号)?" +) +_RECURRENCE_MARKER_PATTERN = re.compile( + r"(?:每(?:个)?(?:天|日|晚|周|星期|礼拜|月|年|季度)|每逢|逢(?:周|星期|礼拜))" +) +_UNSUPPORTED_RECURRENCE_PATTERN = re.compile( + rf"每(?:隔\s*)?(?P{_NUMBER_TOKEN})\s*(?:个)?(?P月|年)" +) +_EN_INTERVAL_PATTERN = re.compile( + r"\bevery\s+(?:(?P\d+)\s+)?(?Psecond|minute|hour)s?\b", + flags=re.IGNORECASE, +) +_EN_RELATIVE_DELAY_PATTERN = re.compile( + r"\bin\s+(?P\d+)\s+(?Pminute|hour|day)s?\b", + flags=re.IGNORECASE, +) +_EN_RECURRING_WEEKDAY_PATTERN = re.compile( + r"\bevery\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + flags=re.IGNORECASE, +) +_EN_NEXT_WEEKDAY_PATTERN = re.compile( + r"\bnext\s+(?Pmonday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", + flags=re.IGNORECASE, +) +_IANA_TIMEZONE_PATTERN = re.compile( + r"\b(?:Africa|America|Antarctica|Asia|Atlantic|Australia|Europe|Indian|Pacific)" + r"/[A-Za-z_+-]+(?:/[A-Za-z_+-]+)?\b" +) +_TIMEZONE_PATTERN = re.compile( + r"(?:北京时间|上海时间|中国时间|上海时区|中国时区|东京时间|日本时间|纽约时间|" + r"伦敦时间|洛杉矶时间|UTC\s*(?:时间|时区)|UTC\s*(?=\d{1,2}\s*(?:[::点时])))", + re.IGNORECASE, +) + +_TIMEZONE_ALIASES = ( + (re.compile(r"(?:北京时间|上海时间|中国时间|上海时区|中国时区)"), "Asia/Shanghai"), + (re.compile(r"(?:东京时间|日本时间)"), "Asia/Tokyo"), + (re.compile(r"纽约时间"), "America/New_York"), + (re.compile(r"伦敦时间"), "Europe/London"), + (re.compile(r"洛杉矶时间"), "America/Los_Angeles"), + (re.compile(r"UTC\s*(?:(?:时间|时区)|(?=\d{1,2}\s*(?:[::点时])))", re.IGNORECASE), "UTC"), +) + +_WEEKDAY_TO_CRON = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "日": 0, "天": 0} +_EN_WEEKDAY_TO_CRON = { + "sunday": 0, + "monday": 1, + "tuesday": 2, + "wednesday": 3, + "thursday": 4, + "friday": 5, + "saturday": 6, +} +_DURATION_SECONDS = { + "秒": 1, + "秒钟": 1, + "分": 60, + "分钟": 60, + "小时": 3600, + "钟头": 3600, + "天": 86400, + "日": 86400, + "周": 604800, + "星期": 604800, + "second": 1, + "minute": 60, + "hour": 3600, + "day": 86400, +} +_ACTION_TOKENS = ( + "提醒", + "发送", + "生成", + "汇总", + "总结", + "执行", + "运行", + "通知", + "检查", + "查询", + "整理", + "备份", + "同步", + "推送", + "发布", + "抓取", + "监控", + "扫描", + "清理", + "更新", + "导出", + "调用", + "统计", + "记录", + "计算", + "算一下", + "获取", + "查找", + "检索", + "搜索", + "读取", + "收集", + "采集", + "分析", + "处理", + "转换", + "翻译", + "创建", + "提交", + "保存", + "写入", + "上传", + "下载", + "告诉", + "说", +) +_AUTOMATION_TOKENS = ( + "定时", + "提醒", + "每天", + "每日", + "每晚", + "每周", + "每星期", + "每礼拜", + "每月", + "每年", + "工作日", + "周末", + "周期", + "every ", + "tomorrow", + "next ", + "明早", + "明晚", + "下个月", + "本月", + "每季度", + "定期", + "每当", + "每次", +) +_QUESTION_PATTERN = re.compile( + r"(?:多少|如何|怎么|怎么样|是什么|为何|为什么|是否|能否|可不可以|有没有|吗|呢|[??])" +) +_EXPLICIT_AUTOMATION_PATTERN = re.compile( + r"(?:定时任务|自动任务|周期任务|计划任务|" + r"(?:创建|新建|添加|设置|设定|安排|建立|配置).{0,24}(?:任务|提醒|定时|自动|周期)|提醒我)" +) +_LEADING_REQUEST_PATTERN = re.compile( + r"^\s*(?:(?:请你|请帮我|请|麻烦你|麻烦|帮我|给我|为我|" + r"我希望你|我想让你|我要你|需要你)\s*)+" +) +_DECLARATIVE_ACTION_PATTERN = re.compile( + r"^(?:我(?:会|通常|一般|总是|习惯|都)|通常|一般|平时|习惯于)" +) + + +def _chinese_number(value: str) -> float: + if value.isdigit(): + return float(value) + if value == "半": + return 0.5 + digits = { + "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, + "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, + } + if value == "十": + return 10 + if "百" in value: + hundreds, remainder = value.split("百", 1) + return digits.get(hundreds, 1) * 100 + (_chinese_number(remainder) if remainder else 0) + if "十" in value: + tens, ones = value.split("十", 1) + return digits.get(tens, 1) * 10 + digits.get(ones, 0) + if len(value) == 1 and value in digits: + return digits[value] + raise ValueError(f"Unsupported Chinese number: {value}") + + +def _duration_seconds(count: str, unit: str) -> int: + seconds = int(_chinese_number(count) * _DURATION_SECONDS[unit.lower()]) + if seconds <= 0: + raise ValueError("Automation interval must be positive.") + return seconds + + +def _apply_period(hour: int, period: str) -> int: + normalized = period.lower() + if normalized in {"pm", "下午"} and hour < 12: + return hour + 12 + if normalized in {"晚上", "今晚"}: + if hour == 12: + return 0 + return hour + 12 if hour < 12 else hour + if normalized == "中午" and hour < 11: + return hour + 12 + if normalized in {"am", "上午", "早上", "凌晨", "午夜"} and hour == 12: + return 0 + return hour + + +def _validated_clock(hour: int, minute: int, period: str = "") -> time: + hour = _apply_period(hour, period) if period else hour + if hour > 23 or minute > 59: + raise ValueError("Invalid hour or minute in automation schedule.") + return time(hour, minute) + + +def _parse_clocks(message: str) -> list[time]: + clocks: list[time] = [] + chinese_period = r"(?:上午|早上|中午|下午|晚上|今晚|凌晨|午夜)" + + for match in re.finditer( + rf"(?P{chinese_period})?\s*(?P\d{{1,2}})\s*[::]\s*(?P\d{{1,2}})", + message, + ): + clocks.append(_validated_clock( + int(match.group("hour")), + int(match.group("minute")), + match.group("period") or "", + )) + + for match in re.finditer( + rf"(?P{chinese_period})?\s*(?P{_CLOCK_NUMBER_TOKEN})\s*(?:点|时)" + rf"(?:(?P半)|(?P一刻|三刻)|(?P{_CLOCK_NUMBER_TOKEN})\s*分?)?", + message, + ): + minute = 0 + if match.group("half"): + minute = 30 + elif match.group("quarter"): + minute = 15 if match.group("quarter") == "一刻" else 45 + elif match.group("minute"): + minute = int(_chinese_number(match.group("minute"))) + clocks.append(_validated_clock( + int(_chinese_number(match.group("hour"))), + minute, + match.group("period") or "", + )) + + for match in re.finditer( + r"\bat\s+(?P\d{1,2})(?::(?P\d{2}))?\s*(?Pam|pm)?\b", + message, + flags=re.IGNORECASE, + ): + clocks.append(_validated_clock( + int(match.group("hour")), + int(match.group("minute") or 0), + match.group("period") or "", + )) + + if not clocks: + if "中午" in message: + clocks.append(time(12, 0)) + elif "午夜" in message: + clocks.append(time(0, 0)) + + return list(dict.fromkeys(clocks)) + + +def _parse_clock(message: str) -> Optional[time]: + clocks = _parse_clocks(message) + return clocks[0] if clocks else None + + +def _future_date(month: int, day: int, now: datetime, year: Optional[int] = None) -> date: + target_year = year or now.year + target = date(target_year, month, day) + if year is None and target < now.date(): + target = date(target_year + 1, month, day) + return target + + +def _relative_weekday(message: str, now: datetime) -> Optional[date]: + match = _WEEKDAY_PATTERN.search(message) + if not match or message[max(0, match.start() - 1):match.start()] == "每": + return None + target_weekday = (_WEEKDAY_TO_CRON[match.group("day")] - 1) % 7 + prefix = match.group("prefix") or "" + if prefix == "下": + days = 7 - now.weekday() + target_weekday + elif prefix.startswith(("本", "这")): + days = target_weekday - now.weekday() + else: + days = (target_weekday - now.weekday()) % 7 + return (now + timedelta(days=days)).date() + + +def _combine_local(target_date: date, target_time: time, zone: ZoneInfo) -> datetime: + naive = datetime.combine(target_date, target_time) + candidate = naive.replace(tzinfo=zone, fold=0) + round_trip = candidate.astimezone(ZoneInfo("UTC")).astimezone(zone).replace(tzinfo=None) + if round_trip != naive: + raise ValueError("The requested local time does not exist because of a timezone transition.") + alternate = naive.replace(tzinfo=zone, fold=1) + if candidate.utcoffset() != alternate.utcoffset(): + raise ValueError("The requested local time is ambiguous because of a timezone transition.") + return candidate + + +def _parse_weekday_values(raw: str) -> list[int]: + values = [_WEEKDAY_TO_CRON[token] for token in re.findall(r"[一二三四五六日天]", raw)] + return list(dict.fromkeys(values)) + + +def _parse_weekday_range(start: str, end: str) -> str: + start_value = _WEEKDAY_TO_CRON[start] + end_value = _WEEKDAY_TO_CRON[end] + ordered_week = [1, 2, 3, 4, 5, 6, 0] + start_index = ordered_week.index(start_value) + end_index = ordered_week.index(end_value) + if start_index <= end_index: + values = ordered_week[start_index:end_index + 1] + else: + values = ordered_week[start_index:] + ordered_week[:end_index + 1] + if len(values) == 1: + return str(values[0]) + if values == list(range(values[0], values[-1] + 1)): + return f"{values[0]}-{values[-1]}" + return ",".join(str(value) for value in values) + + +def _parse_month_days(raw: str) -> list[int]: + days = [int(value) for value in re.findall(r"\d{1,2}", raw)] + if any(day < 1 or day > 31 for day in days): + raise ValueError("Invalid day of month in automation schedule.") + return list(dict.fromkeys(days)) + + +def _cron_time_fields(clocks: list[time]) -> Optional[tuple[str, str]]: + if not clocks: + return None + minutes = sorted({clock.minute for clock in clocks}) + hours = sorted({clock.hour for clock in clocks}) + if len(minutes) == 1: + return str(minutes[0]), ",".join(str(hour) for hour in hours) + if len(hours) == 1: + return ",".join(str(minute) for minute in minutes), str(hours[0]) + return None + + +def _cron_for_clocks(clocks: list[time], suffix: str) -> Optional[str]: + fields = _cron_time_fields(clocks) + if fields is None: + return None + minute, hour = fields + return f"{minute} {hour} {suffix}" + + +def _next_month_day(relative_month: str, day: int, now: datetime) -> date: + month_offset = 1 if relative_month.startswith("下") else 0 + absolute_month = now.month + month_offset + year = now.year + (absolute_month - 1) // 12 + month = (absolute_month - 1) % 12 + 1 + return date(year, month, day) + + +def _next_day_of_month(day: int, now: datetime) -> date: + target = date(now.year, now.month, day) + if target >= now.date(): + return target + absolute_month = now.month + 1 + year = now.year + (absolute_month - 1) // 12 + month = (absolute_month - 1) % 12 + 1 + return date(year, month, day) + + +def _strip_leading_request(message: str) -> str: + candidate = _LEADING_REQUEST_PATTERN.sub("", message.strip()) + return re.sub(r"^在\s*", "", candidate) + + +def _schedule_leads_task(message: str) -> bool: + candidate = _strip_leading_request(message) + lower = candidate.lower() + schedule_patterns = ( + _HOURLY_OFFSET_PATTERN, + _INTERVAL_PATTERN, + _DAY_INTERVAL_PATTERN, + _RELATIVE_DELAY_PATTERN, + _QUARTERLY_PATTERN, + _MONTH_END_PATTERN, + _YEARLY_PATTERN, + _MONTH_DAY_LIST_PATTERN, + _RECURRING_WEEKDAY_RANGE_PATTERN, + _RECURRING_WEEKDAY_PATTERN, + _RELATIVE_MONTH_DAY_PATTERN, + _EXPLICIT_DATE_PATTERN, + _ISO_DATE_PATTERN, + _SHORT_DATE_PATTERN, + _DAY_OF_MONTH_ONCE_PATTERN, + _WEEKDAY_PATTERN, + _EN_INTERVAL_PATTERN, + _EN_RELATIVE_DELAY_PATTERN, + _EN_RECURRING_WEEKDAY_PATTERN, + _EN_NEXT_WEEKDAY_PATTERN, + _UNSUPPORTED_RECURRENCE_PATTERN, + _RECURRENCE_MARKER_PATTERN, + ) + if any(pattern.match(candidate) for pattern in schedule_patterns): + return True + return candidate.startswith( + ("今天", "明天", "后天", "今晚", "明早", "明晚", "工作日", "周末", "每个工作日") + ) or lower.startswith(("today", "tomorrow", "next ", "every ", "in ")) + + +def has_automation_schedule_signal(message: str) -> bool: + """Return whether a message has enough temporal context to merit semantic analysis.""" + lower = message.lower() + return bool( + any(token.lower() in lower for token in _AUTOMATION_TOKENS) + or _HOURLY_OFFSET_PATTERN.search(message) + or _INTERVAL_PATTERN.search(message) + or _DAY_INTERVAL_PATTERN.search(message) + or _RELATIVE_DELAY_PATTERN.search(message) + or _EN_INTERVAL_PATTERN.search(message) + or _RECURRENCE_MARKER_PATTERN.search(message) + or _UNSUPPORTED_RECURRENCE_PATTERN.search(message) + or _EXPLICIT_DATE_PATTERN.search(message) + or _ISO_DATE_PATTERN.search(message) + or _SHORT_DATE_PATTERN.search(message) + or _RELATIVE_MONTH_DAY_PATTERN.search(message) + or _DAY_OF_MONTH_ONCE_PATTERN.search(message) + or _WEEKDAY_PATTERN.search(message) + or _EN_NEXT_WEEKDAY_PATTERN.search(message) + or _EN_RELATIVE_DELAY_PATTERN.search(message) + or _parse_clocks(message) + or any(token in message for token in ("今天", "明天", "后天", "今晚", "明早", "明晚", "稍后", "待会")) + or re.search(r"\b(?:today|tomorrow|daily|weekly|monthly|yearly|schedule|scheduled)\b", lower) + ) + + +def _looks_like_automation(message: str) -> bool: + lower = message.lower() + has_action = ( + any(token in message for token in _ACTION_TOKENS) + or bool(re.search(r"发(?!现|生|布|起|挥|明|热)", message)) + or bool( + re.search( + r"\b(?:send|remind|generate|summarize|check|notify|run|execute|create|" + r"calculate|fetch|get|retrieve|find|search|analyze|process|save|upload|download)\b", + lower, + ) + ) + ) + question_like = bool(_QUESTION_PATTERN.search(message)) + explicit_request = bool( + re.search( + r"(?:提醒我|告诉我|帮我|给我|为我|麻烦你|请你|请帮我|需要你|我要你|我希望你|我想让你)", + message, + ) + or re.search(r"\b(?:please|remind me|tell me)\b", lower) + ) + has_action_command = has_action and (not question_like or explicit_request) + if not has_automation_schedule_signal(message): + return False + + explicit_automation = bool(_EXPLICIT_AUTOMATION_PATTERN.search(message)) + schedule_leads_task = _schedule_leads_task(message) + business_action = _extract_business_action(message) + + if question_like: + return explicit_automation or (schedule_leads_task and explicit_request and has_action) + if explicit_automation: + return True + if not schedule_leads_task or not business_action: + return False + if _DECLARATIVE_ACTION_PATTERN.search(business_action): + return False + return has_action_command or bool(business_action) + + +def _resolve_timezone(message: str, default_timezone: str) -> str: + for pattern, timezone_name in _TIMEZONE_ALIASES: + if pattern.search(message): + return timezone_name + if match := _IANA_TIMEZONE_PATTERN.search(message): + return match.group(0) + return default_timezone + + +def _normalize_schedule_phrases(message: str) -> str: + replacements = ( + ("明早", "明天早上"), + ("明晚", "明天晚上"), + ("今早", "今天早上"), + ("每晚", "每天晚上"), + ("每早", "每天早上"), + ) + normalized = message + for source, target in replacements: + normalized = normalized.replace(source, target) + return normalized + + +def _extract_business_action(message: str) -> str: + cleaned = message + schedule_patterns = ( + _HOURLY_OFFSET_PATTERN, + _INTERVAL_PATTERN, + _DAY_INTERVAL_PATTERN, + _RELATIVE_DELAY_PATTERN, + _QUARTERLY_PATTERN, + _MONTH_END_PATTERN, + _YEARLY_PATTERN, + _MONTH_DAY_LIST_PATTERN, + _RECURRING_WEEKDAY_RANGE_PATTERN, + _RECURRING_WEEKDAY_PATTERN, + _RELATIVE_MONTH_DAY_PATTERN, + _EXPLICIT_DATE_PATTERN, + _ISO_DATE_PATTERN, + _SHORT_DATE_PATTERN, + _DAY_OF_MONTH_ONCE_PATTERN, + _WEEKDAY_PATTERN, + _EN_INTERVAL_PATTERN, + _EN_RELATIVE_DELAY_PATTERN, + _EN_RECURRING_WEEKDAY_PATTERN, + _EN_NEXT_WEEKDAY_PATTERN, + _TIMEZONE_PATTERN, + _IANA_TIMEZONE_PATTERN, + _UNSUPPORTED_RECURRENCE_PATTERN, + ) + for pattern in schedule_patterns: + cleaned = pattern.sub("", cleaned) + cleaned = re.sub( + r"(?:每天|每日|每晚|每个工作日|工作日|周一到周五|周末|" + r"今天|明天|后天|今晚|明早|明晚)", + "", + cleaned, + ) + cleaned = re.sub(r"(?:上午|早上|中午|下午|晚上|凌晨|午夜)", "", cleaned) + cleaned = re.sub( + rf"{_CLOCK_NUMBER_TOKEN}\s*(?:" + rf"[::]\s*{_CLOCK_NUMBER_TOKEN}|" + rf"(?:点|时)(?:(?:半|一刻|三刻)|(?:{_CLOCK_NUMBER_TOKEN})\s*分?)?" + rf")", + "", + cleaned, + ) + cleaned = re.sub(r"\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub( + r"\b(?:every\s+(?:day|weekday|weekend)|weekdays?|weekends?|" + r"tomorrow(?!['’]s)|today(?!['’]s))\b", + "", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = _strip_leading_request(cleaned) + if re.search(r"(?:创建|新建|添加|设置|设定|安排|建立|配置).*任务", message): + cleaned = re.sub( + r"^(?:创建|新建|添加|设置|设定|安排|建立|配置)(?:一个|一条|个)?\s*", + "", + cleaned, + ) + cleaned = re.sub(r"\s*的?(?:(?:定时|自动|周期|计划)\s*)?任务$", "", cleaned) + cleaned = re.sub(r"^(?:定时执行|定时)\s*", "", cleaned) + cleaned = re.sub(r"^的\s*", "", cleaned) + cleaned = re.sub(r"^(?:[、,,/]|和|及)+\s*", "", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip(" ,,。;;::") + return cleaned + + +def _clean_action(message: str) -> str: + return _extract_business_action(message) or message.strip() + + +def _invalid_schedule(message: str, reason: str, confidence: float = 0.9) -> dict: + return { + "is_automation_intent": True, + "confidence": confidence, + "title": "", + "instruction": _clean_action(message), + "schedule_trigger": None, + "schedule_error": reason, + "capability_intents": [], + "output_requirements": {}, + } + + +def _success(message: str, trigger: ScheduleTrigger, confidence: float = 0.96) -> dict: + instruction = _clean_action(message) + return { + "is_automation_intent": True, + "confidence": confidence, + "title": instruction[:30] or "自动任务", + "instruction": instruction, + "schedule_trigger": trigger, + "schedule_error": None, + "capability_intents": [], + "output_requirements": {}, + } + + +def _cron_trigger(now: datetime, timezone_name: str, expression: str) -> ScheduleTrigger: + return ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.CRON, + timezone=timezone_name, + start_at=now.replace(second=0, microsecond=0), + cron_expr=expression, + ) + + +def _recurring_cron_result( + message: str, + now: datetime, + timezone_name: str, + clocks: list[time], + suffix: str, + missing_time_error: str, +) -> dict: + if not clocks: + return _invalid_schedule(message, missing_time_error) + expression = _cron_for_clocks(clocks, suffix) + if expression is None: + return _invalid_schedule( + message, + "一个任务中的多个执行时刻必须具有相同的小时或分钟,请拆分为多个任务。", + ) + return _success(message, _cron_trigger(now, timezone_name, expression)) + + +def parse_automation_intent( + message: str, + timezone_name: str = "Asia/Shanghai", + tenant_id: str | None = None, + reference_time: Optional[datetime] = None, +) -> dict: + """Parse natural-language scheduling independently from task prompt generation.""" + del tenant_id + message = _normalize_schedule_phrases(message) + timezone_name = _resolve_timezone(message, timezone_name) + try: + zone = ZoneInfo(timezone_name) + except Exception as exc: + raise ValueError(f"Invalid automation timezone: {timezone_name}") from exc + now = reference_time or datetime.now(zone) + now = now.astimezone(zone) if now.tzinfo else now.replace(tzinfo=zone) + + if not _looks_like_automation(message): + return {"is_automation_intent": False, "confidence": 0.0} + + hourly_offset = _HOURLY_OFFSET_PATTERN.search(message) + if hourly_offset: + if hourly_offset.group("marker") == "整点": + minute = 0 + elif hourly_offset.group("marker") == "半点": + minute = 30 + else: + minute = int(_chinese_number(hourly_offset.group("minute"))) + if minute > 59: + return _invalid_schedule(message, "每小时执行的分钟数必须在 0 到 59 之间。") + return _success(message, _cron_trigger(now, timezone_name, f"{minute} * * * *")) + + interval_match = _INTERVAL_PATTERN.search(message) or _DAY_INTERVAL_PATTERN.search(message) + if interval_match: + count = interval_match.group("count") or "一" + unit = interval_match.group("unit") if "unit" in interval_match.groupdict() else "天" + seconds = _duration_seconds(count, unit) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.INTERVAL, + timezone=timezone_name, + start_at=now.replace(microsecond=0) + timedelta(seconds=seconds), + interval_seconds=seconds, + ) + return _success(message, trigger) + + en_interval = _EN_INTERVAL_PATTERN.search(message) + if en_interval: + seconds = _duration_seconds(en_interval.group("count") or "1", en_interval.group("unit")) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.INTERVAL, + timezone=timezone_name, + start_at=now.replace(microsecond=0) + timedelta(seconds=seconds), + interval_seconds=seconds, + ) + return _success(message, trigger) + + delay_match = _RELATIVE_DELAY_PATTERN.search(message) + if delay_match: + seconds = _duration_seconds(delay_match.group("count"), delay_match.group("unit")) + trigger = ScheduleTrigger( + mode=ScheduleMode.ONCE, + rule_type=ScheduleRuleType.AT, + timezone=timezone_name, + start_at=now + timedelta(seconds=seconds), + ) + return _success(message, trigger) + + en_delay = _EN_RELATIVE_DELAY_PATTERN.search(message) + if en_delay: + seconds = _duration_seconds(en_delay.group("count"), en_delay.group("unit")) + trigger = ScheduleTrigger( + mode=ScheduleMode.ONCE, + rule_type=ScheduleRuleType.AT, + timezone=timezone_name, + start_at=now + timedelta(seconds=seconds), + ) + return _success(message, trigger) + + lower = message.lower() + target_clocks = _parse_clocks(message) + target_time = target_clocks[0] if target_clocks else None + if any(token in message for token in ("每天", "每日")) or "every day" in lower: + return _recurring_cron_result( + message, now, timezone_name, target_clocks, "* * *", "请明确每天执行的具体时间。" + ) + + if any(token in message for token in ("工作日", "周一到周五", "每个工作日")): + return _recurring_cron_result( + message, now, timezone_name, target_clocks, "* * 1-5", "请明确工作日执行的具体时间。" + ) + + if re.search(r"\bevery\s+weekday\b", lower): + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + "* * 1-5", + "Please specify the execution time for every weekday.", + ) + + if "周末" in message: + return _recurring_cron_result( + message, now, timezone_name, target_clocks, "* * 0,6", "请明确周末执行的具体时间。" + ) + + if re.search(r"\bevery\s+weekend\b", lower): + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + "* * 0,6", + "Please specify the execution time for every weekend.", + ) + + weekday_range_match = _RECURRING_WEEKDAY_RANGE_PATTERN.search(message) + if weekday_range_match: + weekdays = _parse_weekday_range( + weekday_range_match.group("start"), + weekday_range_match.group("end"), + ) + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"* * {weekdays}", + "请明确每周任务执行的具体时间。", + ) + + weekday_match = _RECURRING_WEEKDAY_PATTERN.search(message) + if weekday_match: + weekdays = ",".join(str(day) for day in _parse_weekday_values(weekday_match.group("days"))) + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"* * {weekdays}", + "请明确每周任务执行的具体时间。", + ) + + for weekday_name, cron_day in _EN_WEEKDAY_TO_CRON.items(): + if re.search(rf"\bevery\s+{weekday_name}\b", lower): + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"* * {cron_day}", + f"Please specify the execution time for every {weekday_name}.", + ) + + month_match = _MONTH_DAY_LIST_PATTERN.search(message) + if month_match: + month_days = ",".join(str(day) for day in _parse_month_days(month_match.group("days"))) + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"{month_days} * *", + "请明确每月任务执行的具体时间。", + ) + + if _MONTH_END_PATTERN.search(message): + return _recurring_cron_result( + message, now, timezone_name, target_clocks, "L * *", "请明确每月任务执行的具体时间。" + ) + + quarter_match = _QUARTERLY_PATTERN.search(message) + if quarter_match: + day_token = quarter_match.group("day") + day = 1 if day_token == "一" else int(day_token) + if day < 1 or day > 31: + return _invalid_schedule(message, "季度任务的日期必须在 1 到 31 之间。") + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"{day} 1,4,7,10 *", + "请明确季度任务执行的具体时间。", + ) + + yearly_match = _YEARLY_PATTERN.search(message) + if yearly_match: + month = int(yearly_match.group("month")) + day = int(yearly_match.group("day")) + _future_date(month, day, now, now.year) + return _recurring_cron_result( + message, + now, + timezone_name, + target_clocks, + f"{day} {month} *", + "请明确每年任务执行的具体时间。", + ) + + if unsupported_recurrence := _UNSUPPORTED_RECURRENCE_PATTERN.search(message): + unit = unsupported_recurrence.group("unit") + return _invalid_schedule( + message, + f"暂不支持按多个{unit}生成单一精确规则,请改用明确月份或拆分任务。", + ) + + if _RECURRENCE_MARKER_PATTERN.search(message): + return _invalid_schedule(message, "周期任务缺少可确定的日期或时间,请补充完整。") + + if target_time is None: + return _invalid_schedule(message, "无法确定任务执行时间,请补充具体日期和时间。") + if len(target_clocks) > 1: + return _invalid_schedule( + message, + "一次性任务只能指定一个执行时刻,请拆分为多个任务。", + ) + + target_date: Optional[date] = None + explicit_date = _EXPLICIT_DATE_PATTERN.search(message) or _ISO_DATE_PATTERN.search(message) + if explicit_date: + target_date = _future_date( + int(explicit_date.group("month")), + int(explicit_date.group("day")), + now, + int(explicit_date.group("year")) if explicit_date.group("year") else None, + ) + elif relative_month_date := _RELATIVE_MONTH_DAY_PATTERN.search(message): + target_date = _next_month_day( + relative_month_date.group("relative_month"), + int(relative_month_date.group("day")), + now, + ) + elif short_date := _SHORT_DATE_PATTERN.search(message): + target_date = _future_date( + int(short_date.group("month")), + int(short_date.group("day")), + now, + ) + elif "后天" in message: + target_date = (now + timedelta(days=2)).date() + elif "明天" in message or "tomorrow" in lower: + target_date = (now + timedelta(days=1)).date() + elif "今天" in message or "今晚" in message or "today" in lower: + target_date = now.date() + elif en_next_weekday := _EN_NEXT_WEEKDAY_PATTERN.search(message): + cron_weekday = _EN_WEEKDAY_TO_CRON[en_next_weekday.group("day").lower()] + target_weekday = (cron_weekday - 1) % 7 + days = (target_weekday - now.weekday()) % 7 or 7 + target_date = (now + timedelta(days=days)).date() + else: + target_date = _relative_weekday(message, now) + if target_date is None and (day_match := _DAY_OF_MONTH_ONCE_PATTERN.search(message)): + target_date = _next_day_of_month(int(day_match.group("day")), now) + + if target_date is None: + return _invalid_schedule(message, "无法确定任务执行日期,请补充具体日期。") + if target_time == time(0, 0) and re.search(r"(?:今晚|晚上)\s*12\s*(?:点|时)", message): + target_date += timedelta(days=1) + start_at = _combine_local(target_date, target_time, zone) + if start_at <= now: + return _invalid_schedule(message, "指定的执行时间已经过去,请提供未来时间。") + trigger = ScheduleTrigger( + mode=ScheduleMode.ONCE, + rule_type=ScheduleRuleType.AT, + timezone=timezone_name, + start_at=start_at, + ) + return _success(message, trigger) diff --git a/backend/services/agent_automation/models.py b/backend/services/agent_automation/models.py new file mode 100644 index 000000000..be0d86af4 --- /dev/null +++ b/backend/services/agent_automation/models.py @@ -0,0 +1,160 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from zoneinfo import ZoneInfo + +from nexent.scheduler import ScheduleMode, ScheduleRuleType +from pydantic import BaseModel, Field, field_validator, model_validator + + +class StrEnum(str, Enum): + pass + + +class AutomationTaskStatus(StrEnum): + DRAFT = "DRAFT" + ACTIVE = "ACTIVE" + PAUSED = "PAUSED" + PAUSED_BY_SYSTEM = "PAUSED_BY_SYSTEM" + COMPLETED = "COMPLETED" + DELETED = "DELETED" + + +class AutomationRunStatus(StrEnum): + QUEUED = "QUEUED" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + SKIPPED = "SKIPPED" + CANCELED = "CANCELED" + TIMEOUT = "TIMEOUT" + + +class AutomationProposalStatus(StrEnum): + PENDING = "PENDING" + ACCEPTED = "ACCEPTED" + REJECTED = "REJECTED" + EXPIRED = "EXPIRED" + + +class AutomationSource(StrEnum): + CHAT_INTENT = "CHAT_INTENT" + + +class CapabilityType(StrEnum): + TOOL = "TOOL" + SKILL = "SKILL" + KNOWLEDGE_BASE = "KNOWLEDGE_BASE" + MANAGED_AGENT = "MANAGED_AGENT" + EXTERNAL_A2A_AGENT = "EXTERNAL_A2A_AGENT" + MEMORY = "MEMORY" + + +class ScheduleTrigger(BaseModel): + mode: ScheduleMode + rule_type: ScheduleRuleType + timezone: str = "Asia/Shanghai" + start_at: datetime + end_at: Optional[datetime] = None + cron_expr: Optional[str] = None + interval_seconds: Optional[int] = Field(default=None, gt=0) + max_fire_count: Optional[int] = Field(default=None, gt=0) + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except Exception as exc: + raise ValueError(f"Invalid timezone: {value}") from exc + return value + + @model_validator(mode="after") + def validate_combination(self): + if self.end_at is not None and self.end_at <= self.start_at: + raise ValueError("end_at must be later than start_at") + if self.mode == ScheduleMode.ONCE: + if self.rule_type != ScheduleRuleType.AT: + raise ValueError("ONCE schedule only supports AT rule_type") + if self.cron_expr is not None or self.interval_seconds is not None: + raise ValueError("ONCE schedule cannot include cron_expr or interval_seconds") + self.max_fire_count = 1 + elif self.mode == ScheduleMode.RECURRING: + if self.rule_type == ScheduleRuleType.AT: + raise ValueError("RECURRING schedule does not support AT rule_type") + if self.rule_type == ScheduleRuleType.CRON and not self.cron_expr: + raise ValueError("cron_expr is required for CRON schedules") + if self.rule_type == ScheduleRuleType.CRON and self.interval_seconds is not None: + raise ValueError("CRON schedule cannot include interval_seconds") + if self.rule_type == ScheduleRuleType.INTERVAL and not self.interval_seconds: + raise ValueError("interval_seconds is required for INTERVAL schedules") + if self.rule_type == ScheduleRuleType.INTERVAL and self.cron_expr is not None: + raise ValueError("INTERVAL schedule cannot include cron_expr") + return self + + +class CapabilityBinding(BaseModel): + type: CapabilityType + name: str + display_name: Optional[str] = None + binding_ref: str + reason: Optional[str] = None + required: bool = True + + +class CapabilityResolution(BaseModel): + matched_capabilities: List[CapabilityBinding] = Field(default_factory=list) + missing_capabilities: List[Dict[str, Any]] = Field(default_factory=list) + optional_capabilities: List[CapabilityBinding] = Field(default_factory=list) + agent_snapshot: Dict[str, Any] = Field(default_factory=dict) + executable: bool = True + + +class AutomationTaskCreateRequest(BaseModel): + title: str = Field(min_length=1) + agent_id: int = Field(gt=0) + instruction: str = Field(min_length=1) + schedule_trigger: ScheduleTrigger + conversation_id: int = Field(gt=0) + original_instruction: Optional[str] = None + agent_version_no: Optional[int] = None + model_id: Optional[int] = None + tool_params: Optional[Dict[str, Any]] = None + capability_bindings: List[CapabilityBinding] = Field(default_factory=list) + timeout_seconds: Optional[int] = Field(default=None, gt=0) + + +class AutomationTaskPatchRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1) + instruction: Optional[str] = Field(default=None, min_length=1) + schedule_trigger: Optional[ScheduleTrigger] = None + capability_bindings: Optional[List[CapabilityBinding]] = None + model_id: Optional[int] = None + tool_params: Optional[Dict[str, Any]] = None + timeout_seconds: Optional[int] = Field(default=None, gt=0) + + +class AutomationProposalCreateRequest(BaseModel): + conversation_id: Optional[int] = Field(default=None, gt=0) + agent_id: int = Field(gt=0) + message: str = Field(min_length=1) + timezone: str = "Asia/Shanghai" + agent_version_no: Optional[int] = None + model_id: Optional[int] = None + tool_params: Optional[Dict[str, Any]] = None + + +class AutomationProposalConfirmRequest(BaseModel): + instruction: Optional[str] = None + + +class AutomationProposalPatchRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1) + instruction: Optional[str] = Field(default=None, min_length=1) + schedule_trigger: Optional[ScheduleTrigger] = None + + +class AutomationResponse(BaseModel): + code: int = 0 + message: str = "success" + data: Any = None diff --git a/backend/services/agent_automation/prompt_generator.py b/backend/services/agent_automation/prompt_generator.py new file mode 100644 index 000000000..1474c518b --- /dev/null +++ b/backend/services/agent_automation/prompt_generator.py @@ -0,0 +1,282 @@ +import asyncio +import json +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from jinja2 import StrictUndefined, Template + +from consts.const import LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING +from utils.prompt_template_utils import get_prompt_template + +logger = logging.getLogger("agent_automation.prompt_generator") + + +_ORCHESTRATION_TERMS = ( + "定时任务", + "自动任务", + "计划时间", + "触发类型", + "时区", + "已绑定", + "工具能力", + "配置文件", + "重试", + "失败", + "错误", + "异常", + "日志", + "当前会话", + "会话上下文", + "不要编造", + "scheduled task", + "automation task", + "scheduled time", + "trigger type", + "timezone", + "bound capabilities", + "configuration file", + "retry", + "if it fails", + "on failure", + "error handling", + "error log", + "current conversation", + "conversation context", + "do not fabricate", + "agent", + "tool", + "utc", +) +_SCHEDULE_NOISE_PATTERNS = ( + re.compile(r"(?:每天|每日|每晚|每周|每星期|每月|每年|每季度|工作日|周末)"), + re.compile(r"每(?:隔\s*)?(?:\d+|[一二两三四五六七八九十百半]+)?\s*(?:秒|分钟|小时|天|周)"), + re.compile(r"(?:上午|早上|中午|下午|晚上|凌晨|午夜)?\s*\d{1,2}\s*(?:[::点时])"), + re.compile(r"\b(?:every|daily|weekly|monthly|yearly)\b", re.IGNORECASE), +) + + +@dataclass(frozen=True) +class AutomationPromptContext: + """Data required to generate stable task content at creation time.""" + + tenant_id: str + instruction: str + language: str = LANGUAGE["ZH"] + + +@dataclass(frozen=True) +class AutomationTaskContent: + """Stable title and single-run instruction stored on an automation task.""" + + title: str + instruction: str + + +def detect_instruction_language(instruction: str) -> str: + """Select the prompt language from the extracted business action.""" + return LANGUAGE["ZH"] if re.search(r"[\u3400-\u9fff]", instruction) else LANGUAGE["EN"] + + +def _normalize_model_output(content: str, fallback: str, max_length: int, source: str = "") -> str: + normalized = re.sub(r"[\s\S]*?", "", content or "", flags=re.IGNORECASE).strip() + normalized = normalized.removeprefix("```text").removeprefix("```markdown").strip("`\n ") + if not normalized: + return fallback + normalized_lower = normalized.casefold() + source_lower = source.casefold() + has_orchestration_noise = any( + term in normalized_lower and term not in source_lower + for term in _ORCHESTRATION_TERMS + ) + has_schedule_noise = any( + pattern.search(normalized) and not pattern.search(source) + for pattern in _SCHEDULE_NOISE_PATTERNS + ) + if has_orchestration_noise or has_schedule_noise: + logger.warning("Generated automation instruction added orchestration details; using direct fallback") + return fallback + if len(normalized) > max_length: + logger.warning("Generated automation instruction exceeded the length limit; using direct fallback") + return fallback + return normalized + + +def _fallback_title(instruction: str) -> str: + title = re.sub(r"\s+", " ", instruction).strip(" ,,。;;::\"'“”") + title = re.sub(r"^算一下", "计算", title) + title = re.sub(r"^查一下", "查询", title) + title = re.sub(r"^看一下", "查看", title) + if title.startswith("提醒我") and len(title) > 3: + title = f"{title[3:]}提醒" + title = re.sub( + r"^(发送|发|生成|整理|检查|汇总|总结|推送|发布|" + r"备份|同步|扫描|清理|更新|导出|统计|记录)" + r"(?:一次|一条|一句|一个|一份)", + r"\1", + title, + ) + if title.startswith("发") and not title.startswith(("发送", "发布", "发现", "发起")): + title = f"发送{title[1:]}" + max_length = 20 if detect_instruction_language(instruction) == LANGUAGE["ZH"] else 60 + return title[:max_length] or "自动任务" + + +def _extract_json(content: str) -> Dict[str, Any]: + normalized = re.sub(r"[\s\S]*?", "", content or "", flags=re.IGNORECASE).strip() + fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", normalized, flags=re.IGNORECASE) + if fence_match: + normalized = fence_match.group(1).strip() + try: + parsed = json.loads(normalized) + except json.JSONDecodeError: + object_match = re.search(r"\{[\s\S]*\}", normalized) + if not object_match: + raise + parsed = json.loads(object_match.group(0)) + if not isinstance(parsed, dict): + raise ValueError("Automation task content must be a JSON object.") + return parsed + + +def _normalize_task_content( + content: str, + fallback: AutomationTaskContent, + source: str, +) -> AutomationTaskContent: + try: + parsed = _extract_json(content) + if set(parsed) != {"title", "instruction"}: + raise ValueError("Automation task content must contain only title and instruction.") + raw_instruction = str(parsed.get("instruction") or "") + instruction = _normalize_model_output( + raw_instruction, + fallback.instruction, + 300, + source=source, + ) + if instruction == fallback.instruction and raw_instruction.strip() != fallback.instruction: + return fallback + source_language = detect_instruction_language(source) + if detect_instruction_language(instruction) != source_language: + logger.warning("Generated automation instruction changed the source language; using direct fallback") + return fallback + raw_title = str(parsed.get("title") or "") + fallback_title = _fallback_title(instruction) + title_max_length = 20 if source_language == LANGUAGE["ZH"] else 60 + title = _normalize_model_output( + raw_title, + fallback_title, + title_max_length, + source=source, + ) + if title == fallback_title and raw_title.strip() != fallback_title: + return fallback + if detect_instruction_language(title) != source_language: + logger.warning("Generated automation title changed the source language; using direct fallback") + return fallback + return AutomationTaskContent(title=title, instruction=instruction) + except Exception as exc: + logger.warning("Failed to parse structured automation task content, using direct fallback: %s", exc) + return fallback + + +class AutomationPromptStrategy(ABC): + """Strategy interface for automation prompt generation.""" + + @abstractmethod + async def generate_task_content(self, context: AutomationPromptContext) -> AutomationTaskContent: + raise NotImplementedError + + +class TemplateAutomationPromptStrategy(AutomationPromptStrategy): + """Deterministic and fail-open prompt generation strategy.""" + + async def generate_task_content(self, context: AutomationPromptContext) -> AutomationTaskContent: + instruction = context.instruction.strip() + return AutomationTaskContent(title=_fallback_title(instruction), instruction=instruction) + + +class LLMAutomationPromptStrategy(AutomationPromptStrategy): + """LLM-backed strategy with a deterministic fallback strategy.""" + + def __init__(self, model_config: Dict[str, Any], fallback: AutomationPromptStrategy): + self._model_config = model_config + self._fallback = fallback + + async def generate_task_content(self, context: AutomationPromptContext) -> AutomationTaskContent: + fallback = await self._fallback.generate_task_content(context) + try: + content = await asyncio.to_thread( + self._generate_sync, + context, + "TASK_CONTENT_SYSTEM_PROMPT", + "TASK_CONTENT_USER_PROMPT", + ) + return _normalize_task_content(content, fallback, context.instruction) + except Exception as exc: + logger.warning("Failed to generate automation task content, using direct fallback: %s", exc) + return fallback + + def _generate_sync( + self, + context: AutomationPromptContext, + system_key: str, + user_key: str, + ) -> str: + from nexent.core.models import OpenAIModel + from utils.config_utils import get_model_name_from_config + + prompt_template = get_prompt_template("agent_automation", context.language) + values = {"instruction": context.instruction.strip()} + user_prompt = Template(prompt_template[user_key], undefined=StrictUndefined).render(**values).strip() + llm = OpenAIModel( + model_id=get_model_name_from_config(self._model_config) if self._model_config.get("model_name") else "", + api_base=self._model_config.get("base_url", ""), + api_key=self._model_config.get("api_key", ""), + temperature=0.2, + top_p=0.9, + model_factory=self._model_config.get("model_factory"), + ssl_verify=self._model_config.get("ssl_verify", True), + timeout_seconds=self._model_config.get("timeout_seconds"), + stream=False, + ) + response = llm.generate([ + {"role": MESSAGE_ROLE["SYSTEM"], "content": prompt_template[system_key]}, + {"role": MESSAGE_ROLE["USER"], "content": user_prompt}, + ]) + return getattr(response, "content", "") or "" + + +class AutomationPromptStrategyFactory: + """Factory that selects an LLM strategy when the tenant has a usable model.""" + + def create(self, tenant_id: str) -> AutomationPromptStrategy: + fallback = TemplateAutomationPromptStrategy() + try: + from utils.config_utils import tenant_config_manager + + model_config = tenant_config_manager.get_model_config( + key=MODEL_CONFIG_MAPPING["llm"], + tenant_id=tenant_id, + ) + if model_config: + return LLMAutomationPromptStrategy(model_config, fallback) + except Exception as exc: + logger.warning("Failed to resolve automation prompt model, using template strategy: %s", exc) + return fallback + + +class AutomationPromptGenerator: + """Application service that keeps prompt strategy selection out of callers.""" + + def __init__(self, factory: Optional[AutomationPromptStrategyFactory] = None): + self._factory = factory or AutomationPromptStrategyFactory() + + async def generate_task_content(self, context: AutomationPromptContext) -> AutomationTaskContent: + return await self._factory.create(context.tenant_id).generate_task_content(context) + + +automation_prompt_generator = AutomationPromptGenerator() diff --git a/backend/services/agent_automation/runner.py b/backend/services/agent_automation/runner.py new file mode 100644 index 000000000..1e5427360 --- /dev/null +++ b/backend/services/agent_automation/runner.py @@ -0,0 +1,347 @@ +import asyncio +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from consts.const import AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS +from consts.model import AgentRequest, HistoryItem, MessageRequest, MessageUnit +from database import agent_automation_db +from services.agent_service import is_agent_running, run_agent_background, stop_agent_tasks +from services.conversation_management_service import ( + get_conversation_history_service, + save_message, + save_message_unit, +) + +from .capability_resolver import validate_bindings_available +from .models import AutomationRunStatus, ScheduleTrigger +from .schedule_engine import compute_next_fire_at + + +logger = logging.getLogger("agent_automation.runner") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_dt(value: Any) -> datetime: + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if isinstance(value, str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + return _utcnow() + + +def _message_content(message: Dict[str, Any]) -> str: + content = message.get("message", "") + if isinstance(content, list): + final = next((unit.get("content") for unit in reversed(content) if unit.get("type") == "final_answer"), "") + visible_units = [ + unit + for unit in content + if unit.get("type") not in {"automation_proposal"} + ] + content = final or " ".join(str(unit.get("content", "")) for unit in visible_units) + return str(content or "") + + +def _history_items(history_payload: List[Dict[str, Any]]) -> List[HistoryItem]: + if not history_payload: + return [] + items: List[HistoryItem] = [] + for msg in history_payload[0].get("message", []): + content = _message_content(msg) + if content: + items.append(HistoryItem(role=msg.get("role", "user"), content=content)) + return items + + +class AgentAutomationRunner: + async def execute_task( + self, + task: Dict[str, Any], + trigger_type: str = "SCHEDULED", + scheduled_fire_at: Optional[datetime] = None, + lease_owner: Optional[str] = None, + ) -> Dict[str, Any]: + scheduled = scheduled_fire_at or _parse_dt(task.get("next_fire_at")) + if agent_automation_db.has_active_run_for_conversation(task["conversation_id"]) or is_agent_running( + task["conversation_id"], + task["user_id"], + ): + skipped_at = _utcnow() + run = agent_automation_db.create_run({ + "task_id": task["task_id"], + "tenant_id": task["tenant_id"], + "user_id": task["user_id"], + "conversation_id": task["conversation_id"], + "scheduled_fire_at": scheduled, + "actual_fire_at": skipped_at, + "trigger_type": trigger_type, + "status": AutomationRunStatus.SKIPPED.value, + "started_at": skipped_at, + "finished_at": skipped_at, + "error_code": "AUTOMATION_RUN_ALREADY_ACTIVE", + "error_message": "Conversation already has an active automation run.", + }, task["user_id"]) + fire_count, next_fire_at, task_status = self._advance_scheduled_task(task, skipped_at) + task_values = { + "status": task_status, + "last_fire_at": skipped_at, + "last_run_status": AutomationRunStatus.SKIPPED.value, + "last_error": "Conversation already has an active automation run.", + "fire_count": fire_count, + "next_fire_at": next_fire_at, + "lock_owner": None, + "lock_until": None, + } + self._update_task_state(task, task_values, trigger_type, lease_owner) + return run + + run = agent_automation_db.create_run({ + "task_id": task["task_id"], + "tenant_id": task["tenant_id"], + "user_id": task["user_id"], + "conversation_id": task["conversation_id"], + "scheduled_fire_at": scheduled, + "actual_fire_at": _utcnow(), + "trigger_type": trigger_type, + "status": AutomationRunStatus.RUNNING.value, + "started_at": _utcnow(), + }, task["user_id"]) + if lease_owner: + run["_lease_owner"] = lease_owner + + timeout_seconds = float(task.get("timeout_seconds") or AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS) + try: + return await asyncio.wait_for( + self._execute_active_run(run, task, scheduled, trigger_type), + timeout=max(1, timeout_seconds), + ) + except asyncio.TimeoutError: + self.cancel_for_conversation(task["conversation_id"], task["user_id"]) + return self._finish_run(run, task, AutomationRunStatus.TIMEOUT.value, { + "error_code": "AUTOMATION_RUN_TIMEOUT", + "error_message": f"Automation run exceeded {timeout_seconds} seconds.", + }) + except asyncio.CancelledError: + self.cancel_for_conversation(task["conversation_id"], task["user_id"]) + agent_automation_db.cancel_run( + run["run_id"], + task["tenant_id"], + task["user_id"], + "Scheduler execution was interrupted before completion.", + ) + raise + except Exception as exc: + return self._fail_run(run, task, "AUTOMATION_RUN_FAILED", str(exc), None) + + async def _execute_active_run( + self, + run: Dict[str, Any], + task: Dict[str, Any], + scheduled: datetime, + trigger_type: str, + ) -> Dict[str, Any]: + capability_check = await validate_bindings_available( + agent_id=task["agent_id"], + tenant_id=task["tenant_id"], + user_id=task["user_id"], + instruction=task["instruction"], + bindings=task.get("capability_bindings") or [], + version_no=task.get("agent_version_no") or 0, + ) + if not capability_check["available"]: + return self._fail_run( + run, + task, + "AUTOMATION_CAPABILITY_UNAVAILABLE", + "Required automation capability is unavailable.", + capability_check, + ) + + history_payload = get_conversation_history_service(task["conversation_id"], task["user_id"]) + history = _history_items(history_payload) + stored_snapshot = task.get("runtime_snapshot") or {"agent_id": task["agent_id"]} + current_resolution = capability_check.get("resolution") or {} + current_agent_snapshot = current_resolution.get("agent_snapshot") or {} + runtime_snapshot = { + **stored_snapshot, + **current_agent_snapshot, + # Runtime selections belong to the task even when the Agent metadata changes. + "model_id": stored_snapshot.get("model_id"), + "tool_params": stored_snapshot.get("tool_params"), + "original_instruction": ( + stored_snapshot.get("original_instruction") or task["instruction"] + ), + } + generated_prompt = task["instruction"].strip() + message_request = MessageRequest( + conversation_id=task["conversation_id"], + message_idx=len(history_payload[0].get("message", [])) if history_payload else 0, + role="user", + message=[MessageUnit(type="string", content=generated_prompt)], + ) + user_message_id = save_message(message_request, task["user_id"], task["tenant_id"]) + save_message_unit( + message_id=user_message_id, + conversation_id=task["conversation_id"], + unit_index=0, + unit_type="automation_prompt", + unit_content=generated_prompt, + user_id=task["user_id"], + ) + + agent_request = AgentRequest( + query=generated_prompt, + conversation_id=task["conversation_id"], + history=history, + agent_id=task["agent_id"], + model_id=runtime_snapshot.get("model_id"), + version_no=task.get("agent_version_no"), + tool_params=runtime_snapshot.get("tool_params"), + ) + result = await run_agent_background( + agent_request=agent_request, + user_id=task["user_id"], + tenant_id=task["tenant_id"], + skip_user_save=True, + ) + return self._finish_run(run, task, AutomationRunStatus.SUCCEEDED.value, { + "generated_prompt": generated_prompt, + "user_message_id": user_message_id, + "assistant_message_id": result.get("assistant_message_id"), + "capability_check": capability_check, + }) + + def cancel_for_conversation(self, conversation_id: int, user_id: str) -> None: + stop_agent_tasks(conversation_id, user_id) + + def _fail_run( + self, + run: Dict[str, Any], + task: Dict[str, Any], + error_code: str, + error_message: str, + capability_check: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + return self._finish_run(run, task, AutomationRunStatus.FAILED.value, { + "error_code": error_code, + "error_message": error_message, + "capability_check": capability_check, + }) + + def _finish_run( + self, + run: Dict[str, Any], + task: Dict[str, Any], + status: str, + extra: Dict[str, Any], + ) -> Dict[str, Any]: + now = _utcnow() + started_at = _parse_dt(run.get("started_at")) + duration_ms = int((now - started_at).total_seconds() * 1000) + updated_run = agent_automation_db.update_run(run["run_id"], { + **extra, + "status": status, + "finished_at": now, + "duration_ms": duration_ms, + }, task["user_id"], expected_statuses=[AutomationRunStatus.RUNNING.value]) + if not updated_run: + current_run = agent_automation_db.get_run( + run["run_id"], + task["tenant_id"], + task["user_id"], + ) + return current_run or run + + fire_count = int(task.get("fire_count") or 0) + next_fire_at = task.get("next_fire_at") + task_status = task.get("status", "ACTIVE") + consecutive_failures = int(task.get("consecutive_failures") or 0) + is_scheduled_run = run.get("trigger_type") == "SCHEDULED" + if is_scheduled_run: + fire_count, next_fire_at, task_status = self._advance_scheduled_task(task, now) + if status == AutomationRunStatus.SUCCEEDED.value: + consecutive_failures = 0 + elif status in {AutomationRunStatus.FAILED.value, AutomationRunStatus.TIMEOUT.value}: + consecutive_failures += 1 + if consecutive_failures >= 5 and next_fire_at is not None: + task_status = "PAUSED_BY_SYSTEM" + + current_task = agent_automation_db.get_task( + task["task_id"], + task["tenant_id"], + task["user_id"], + ) + if current_task and current_task.get("status") in { + "PAUSED", + "PAUSED_BY_SYSTEM", + }: + task_status = current_task["status"] + + task_values = { + "status": task_status, + "last_fire_at": now, + "last_run_status": status, + "last_error": extra.get("error_message"), + "consecutive_failures": consecutive_failures, + "fire_count": fire_count, + "next_fire_at": next_fire_at, + "lock_owner": None, + "lock_until": None, + } + self._update_task_state( + task, + task_values, + str(run.get("trigger_type") or "SCHEDULED"), + run.get("_lease_owner"), + ) + return updated_run or run + + @staticmethod + def _update_task_state( + task: Dict[str, Any], + values: Dict[str, Any], + trigger_type: str, + lease_owner: Optional[str], + ) -> Optional[Dict[str, Any]]: + if trigger_type == "SCHEDULED" and lease_owner: + updated = agent_automation_db.update_task_if_lock_owner( + task["task_id"], + task["tenant_id"], + task["user_id"], + lease_owner, + values, + ) + if not updated: + logger.warning( + "Discarded stale scheduled task update after lease loss: task_id=%s owner=%s", + task["task_id"], + lease_owner, + ) + return updated + return agent_automation_db.update_task( + task["task_id"], + task["tenant_id"], + task["user_id"], + values, + ) + + @staticmethod + def _advance_scheduled_task( + task: Dict[str, Any], + after: datetime, + ) -> tuple[int, Optional[datetime], str]: + """Advance exactly one scheduled occurrence without consuming manual runs.""" + fire_count = int(task.get("fire_count") or 0) + 1 + trigger = ScheduleTrigger.model_validate(task["schedule_config"]) + next_fire_at = compute_next_fire_at(trigger, after, fire_count) + task_status = task.get("status", "ACTIVE") + if next_fire_at is None: + task_status = "COMPLETED" + return fire_count, next_fire_at, task_status + + +agent_automation_runner = AgentAutomationRunner() diff --git a/backend/services/agent_automation/schedule_engine.py b/backend/services/agent_automation/schedule_engine.py new file mode 100644 index 000000000..0846df0e0 --- /dev/null +++ b/backend/services/agent_automation/schedule_engine.py @@ -0,0 +1,33 @@ +"""Compatibility adapter from API models to the SDK schedule engine.""" + +from datetime import datetime + +from nexent.scheduler import ( + ScheduleSpec, + compute_next_fire_at as compute_sdk_next_fire_at, + is_valid_cron_expression as validate_sdk_cron_expression, +) + +from .models import ScheduleTrigger + + +def is_valid_cron_expression(expression: str) -> bool: + return validate_sdk_cron_expression(expression) + + +def compute_next_fire_at( + trigger: ScheduleTrigger, + after: datetime, + fire_count: int, +) -> datetime | None: + spec = ScheduleSpec( + mode=trigger.mode, + rule_type=trigger.rule_type, + timezone=trigger.timezone, + start_at=trigger.start_at, + end_at=trigger.end_at, + cron_expr=trigger.cron_expr, + interval_seconds=trigger.interval_seconds, + max_fire_count=trigger.max_fire_count, + ) + return compute_sdk_next_fire_at(spec, after, fire_count) diff --git a/backend/services/agent_automation/scheduler.py b/backend/services/agent_automation/scheduler.py new file mode 100644 index 000000000..127574d7c --- /dev/null +++ b/backend/services/agent_automation/scheduler.py @@ -0,0 +1,104 @@ +"""Backend adapter for the SDK's durable lease scheduler.""" + +import asyncio +import logging +from typing import Any, Dict, Hashable + +from consts.const import ( + AGENT_AUTOMATION_ENABLED, + AGENT_AUTOMATION_LEASE_SECONDS, + AGENT_AUTOMATION_MAX_CONCURRENT_RUNS, + AGENT_AUTOMATION_POLL_INTERVAL_SECONDS, + AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS, +) +from database import agent_automation_db +from nexent.scheduler import ClaimedJob, ExecutionLease, LeaseScheduler, SchedulerConfig + +from .runner import agent_automation_runner + + +logger = logging.getLogger("agent_automation.scheduler") + + +class AgentAutomationLeaseStore: + """Adapt synchronous PostgreSQL operations to the async scheduler contract.""" + + async def recover(self) -> None: + await asyncio.to_thread(agent_automation_db.recover_orphaned_runs) + await asyncio.to_thread(agent_automation_db.release_expired_locks) + + async def claim_due( + self, + owner_id: str, + limit: int, + lease_seconds: float, + ) -> list[ClaimedJob[Dict[str, Any]]]: + tasks = await asyncio.to_thread( + agent_automation_db.claim_due_tasks, + owner_id, + limit, + lease_seconds, + ) + return [ClaimedJob(job_id=task["task_id"], payload=task) for task in tasks] + + async def renew(self, job_id: Hashable, owner_id: str, lease_seconds: float) -> bool: + return await asyncio.to_thread( + agent_automation_db.renew_task_lock, + int(job_id), + owner_id, + lease_seconds, + ) + + async def release(self, job_id: Hashable, owner_id: str) -> bool: + return await asyncio.to_thread( + agent_automation_db.release_task_lock, + int(job_id), + owner_id, + ) + + +async def execute_agent_automation( + job: ClaimedJob[Dict[str, Any]], + lease: ExecutionLease, +) -> None: + await agent_automation_runner.execute_task( + job.payload, + trigger_type="SCHEDULED", + lease_owner=lease.owner_id, + ) + + +class AgentAutomationScheduler: + """Application lifecycle wrapper around the reusable SDK scheduler.""" + + def __init__(self) -> None: + self._scheduler = LeaseScheduler( + store=AgentAutomationLeaseStore(), + executor=execute_agent_automation, + config=SchedulerConfig( + poll_interval_seconds=AGENT_AUTOMATION_POLL_INTERVAL_SECONDS, + lease_seconds=AGENT_AUTOMATION_LEASE_SECONDS, + max_concurrency=AGENT_AUTOMATION_MAX_CONCURRENT_RUNS, + shutdown_grace_seconds=AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS, + ), + ) + + @property + def instance_id(self) -> str: + return self._scheduler.owner_id + + @property + def is_running(self) -> bool: + return self._scheduler.is_running + + async def start(self) -> None: + if not AGENT_AUTOMATION_ENABLED: + logger.info("Agent automation scheduler disabled") + return + await self._scheduler.start() + + async def stop(self) -> None: + await self._scheduler.stop() + + +agent_automation_scheduler = AgentAutomationScheduler() diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 5f66e8a1a..facf937d6 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -3336,6 +3336,86 @@ async def stream_with_agent_context(): ) +async def run_agent_background( + agent_request: AgentRequest, + user_id: str, + tenant_id: str, + language: str = LANGUAGE["ZH"], + skip_user_save: bool = False, +) -> Dict[str, Any]: + """ + Run an agent without returning an SSE response. + + This path is used by background automation tasks. It reuses the same + preparation, monitoring, memory and message persistence flow as + run_agent_stream, but consumes generated chunks internally. + """ + if not agent_request.conversation_id: + raise ValueError("conversation_id is required for background agent runs") + + if not agent_request.is_debug and not skip_user_save: + save_messages( + agent_request, + target=MESSAGE_ROLE["USER"], + user_id=user_id, + tenant_id=tenant_id, + ) + + memory_ctx_preview = build_memory_context( + user_id, tenant_id, agent_request.agent_id, skip_query=agent_request.is_debug + ) + memory_enabled = memory_ctx_preview.user_config.memory_switch + + agent_metadata = monitoring_manager.bind_agent_context(AgentRunMetadata( + agent_id=agent_request.agent_id, + conversation_id=agent_request.conversation_id, + user_id=user_id, + tenant_id=tenant_id, + query=agent_request.query, + is_debug=agent_request.is_debug, + language=language, + memory_enabled=memory_enabled, + history_count=len(agent_request.history) if agent_request.history else 0, + minio_files_count=len(agent_request.minio_files) if agent_request.minio_files else 0, + extra_metadata={ + "background": True, + "skip_user_save": skip_user_save, + "agent_share_option": getattr( + memory_ctx_preview.user_config, + "agent_share_option", + "unknown", + ), + }, + )) + + if memory_enabled and not agent_request.is_debug: + stream_gen = generate_stream_with_memory( + agent_request, + user_id=user_id, + tenant_id=tenant_id, + language=language, + ) + else: + stream_gen = generate_stream_no_memory( + agent_request, + user_id=user_id, + tenant_id=tenant_id, + language=language, + ) + + chunks = 0 + with agent_monitoring_context(agent_metadata): + async for _ in stream_gen: + chunks += 1 + + latest_message = get_latest_assistant_message(agent_request.conversation_id, user_id) + return { + "conversation_id": agent_request.conversation_id, + "assistant_message_id": latest_message.get("message_id") if latest_message else None, + "chunks": chunks, + } + + def stop_agent_tasks(conversation_id: int, user_id: str): """ Stop agent run and preprocess tasks for the specified conversation_id. @@ -3364,6 +3444,10 @@ def stop_agent_tasks(conversation_id: int, user_id: str): return {"status": "success", "message": message, "already_stopped": True} +def is_agent_running(conversation_id: int, user_id: str) -> bool: + return agent_run_manager.get_agent_run_info(conversation_id, user_id) is not None + + async def get_agent_id_by_name(agent_name: str, tenant_id: str) -> int: """ Resolve unique agent id by its unique name under the same tenant. diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 772d8d31a..b27f2afaa 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -374,6 +374,16 @@ def delete_conversation_service(conversation_id: int, user_id: str) -> bool: bool: Whether the deletion was successful """ try: + try: + from services.agent_automation.facade import agent_automation_facade + agent_automation_facade.on_conversation_deleted(conversation_id, user_id) + except Exception as automation_error: + logging.warning( + "Failed to cleanup automation task for conversation %s: %s", + conversation_id, + automation_error, + ) + success = delete_conversation(conversation_id, user_id) if not success: raise Exception(f"Conversation {conversation_id} does not exist or has been deleted") diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index 299d3bf94..b9426fc2f 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -118,6 +118,10 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw 'skill_creation_complicated': { LANGUAGE["ZH"]: 'backend/prompts/skill_creation_complicate_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/skill_creation_complicate_en.yaml' + }, + 'agent_automation': { + LANGUAGE["ZH"]: 'backend/prompts/agent_automation_zh.yaml', + LANGUAGE["EN"]: 'backend/prompts/agent_automation_en.yaml' } } diff --git a/deploy/sql/init.sql b/deploy/sql/init.sql index f62970a66..43856fa5c 100644 --- a/deploy/sql/init.sql +++ b/deploy/sql/init.sql @@ -392,6 +392,117 @@ EXECUTE FUNCTION update_ag_user_agent_update_time(); -- Add comment to the trigger COMMENT ON TRIGGER update_ag_user_agent_update_time_trigger ON nexent.ag_user_agent_t IS 'Trigger to call update_ag_user_agent_update_time function before each update on ag_user_agent_t table'; +-- Agent automation tasks, proposals, and run history. +CREATE TABLE IF NOT EXISTS nexent.agent_automation_task_t ( + task_id BIGSERIAL PRIMARY KEY NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + agent_version_no INTEGER, + title VARCHAR(255) NOT NULL, + instruction TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + source VARCHAR(32) NOT NULL, + schedule_mode VARCHAR(16) NOT NULL, + schedule_rule_type VARCHAR(16) NOT NULL, + schedule_expr TEXT, + schedule_config JSONB NOT NULL, + capability_requirements JSONB, + capability_bindings JSONB, + runtime_snapshot JSONB, + timezone VARCHAR(64) NOT NULL, + next_fire_at TIMESTAMPTZ, + last_fire_at TIMESTAMPTZ, + fire_count INTEGER NOT NULL DEFAULT 0, + last_run_status VARCHAR(32), + last_error TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + timeout_seconds INTEGER NOT NULL, + overlap_policy VARCHAR(16) NOT NULL, + misfire_policy VARCHAR(16) NOT NULL, + lock_owner VARCHAR(128), + lock_until TIMESTAMPTZ, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE TABLE IF NOT EXISTS nexent.agent_automation_run_t ( + run_id BIGSERIAL PRIMARY KEY NOT NULL, + task_id BIGINT NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + scheduled_fire_at TIMESTAMPTZ NOT NULL, + actual_fire_at TIMESTAMPTZ, + trigger_type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + generated_prompt TEXT, + user_message_id BIGINT, + assistant_message_id BIGINT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + duration_ms BIGINT, + error_code VARCHAR(64), + error_message TEXT, + capability_check JSONB, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE TABLE IF NOT EXISTS nexent.agent_automation_proposal_t ( + proposal_id BIGSERIAL PRIMARY KEY NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + proposed_task JSONB NOT NULL, + capability_resolution JSONB NOT NULL, + status VARCHAR(32) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE INDEX IF NOT EXISTS idx_agent_automation_due + ON nexent.agent_automation_task_t (status, next_fire_at) + WHERE delete_flag = 'N'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_owner + ON nexent.agent_automation_task_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_automation_conversation_active + ON nexent.agent_automation_task_t (conversation_id) + WHERE delete_flag = 'N' AND status <> 'DELETED'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_run_task + ON nexent.agent_automation_run_t (task_id, scheduled_fire_at) + WHERE delete_flag = 'N'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_run_conversation + ON nexent.agent_automation_run_t (conversation_id, status) + WHERE delete_flag = 'N'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_automation_active_occurrence + ON nexent.agent_automation_run_t (task_id, scheduled_fire_at) + WHERE delete_flag = 'N' + AND trigger_type = 'SCHEDULED' + AND status IN ('QUEUED', 'RUNNING'); + +CREATE INDEX IF NOT EXISTS idx_agent_automation_proposal_owner + ON nexent.agent_automation_proposal_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + -- Create the ag_tool_instance_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_tool_instance_t ( tool_instance_id SERIAL PRIMARY KEY NOT NULL, diff --git a/deploy/sql/migrations/v2.3.0_0713_add_agent_automation.sql b/deploy/sql/migrations/v2.3.0_0713_add_agent_automation.sql new file mode 100644 index 000000000..d3b056fd9 --- /dev/null +++ b/deploy/sql/migrations/v2.3.0_0713_add_agent_automation.sql @@ -0,0 +1,144 @@ +-- Add durable scheduled agent tasks, run history, chat proposals, and navigation permissions. + +SET search_path TO nexent; + +BEGIN; + +CREATE TABLE IF NOT EXISTS nexent.agent_automation_task_t ( + task_id BIGSERIAL PRIMARY KEY NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + agent_version_no INTEGER, + title VARCHAR(255) NOT NULL, + instruction TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + source VARCHAR(32) NOT NULL, + schedule_mode VARCHAR(16) NOT NULL, + schedule_rule_type VARCHAR(16) NOT NULL, + schedule_expr TEXT, + schedule_config JSONB NOT NULL, + capability_requirements JSONB, + capability_bindings JSONB, + runtime_snapshot JSONB, + timezone VARCHAR(64) NOT NULL, + next_fire_at TIMESTAMPTZ, + last_fire_at TIMESTAMPTZ, + fire_count INTEGER NOT NULL DEFAULT 0, + last_run_status VARCHAR(32), + last_error TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + timeout_seconds INTEGER NOT NULL, + overlap_policy VARCHAR(16) NOT NULL, + misfire_policy VARCHAR(16) NOT NULL, + lock_owner VARCHAR(128), + lock_until TIMESTAMPTZ, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE TABLE IF NOT EXISTS nexent.agent_automation_run_t ( + run_id BIGSERIAL PRIMARY KEY NOT NULL, + task_id BIGINT NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + scheduled_fire_at TIMESTAMPTZ NOT NULL, + actual_fire_at TIMESTAMPTZ, + trigger_type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + generated_prompt TEXT, + user_message_id BIGINT, + assistant_message_id BIGINT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + duration_ms BIGINT, + error_code VARCHAR(64), + error_message TEXT, + capability_check JSONB, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE TABLE IF NOT EXISTS nexent.agent_automation_proposal_t ( + proposal_id BIGSERIAL PRIMARY KEY NOT NULL, + tenant_id VARCHAR(100) NOT NULL, + user_id VARCHAR(100) NOT NULL, + conversation_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + proposed_task JSONB NOT NULL, + capability_resolution JSONB NOT NULL, + status VARCHAR(32) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +CREATE INDEX IF NOT EXISTS idx_agent_automation_due + ON nexent.agent_automation_task_t (status, next_fire_at) + WHERE delete_flag = 'N'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_owner + ON nexent.agent_automation_task_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_automation_conversation_active + ON nexent.agent_automation_task_t (conversation_id) + WHERE delete_flag = 'N' AND status <> 'DELETED'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_run_task + ON nexent.agent_automation_run_t (task_id, scheduled_fire_at) + WHERE delete_flag = 'N'; + +CREATE INDEX IF NOT EXISTS idx_agent_automation_run_conversation + ON nexent.agent_automation_run_t (conversation_id, status) + WHERE delete_flag = 'N'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_automation_active_occurrence + ON nexent.agent_automation_run_t (task_id, scheduled_fire_at) + WHERE delete_flag = 'N' + AND trigger_type = 'SCHEDULED' + AND status IN ('QUEUED', 'RUNNING'); + +CREATE INDEX IF NOT EXISTS idx_agent_automation_proposal_owner + ON nexent.agent_automation_proposal_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + +-- The menu schema was introduced by v2.2.2_0622_update_left_nav_menu.sql. +-- Synchronize the sequence because that migration inserted explicit IDs. +SELECT setval( + pg_get_serial_sequence('nexent.role_permission_t', 'role_permission_id'), + COALESCE(MAX(role_permission_id), 0) + 1, + false +) +FROM nexent.role_permission_t; + +INSERT INTO nexent.role_permission_t ( + user_role, + permission_category, + permission_type, + permission_subtype, + parent_key +) +SELECT role_name, 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-tasks', NULL +FROM unnest(ARRAY['SU', 'ADMIN', 'DEV', 'USER', 'SPEED', 'ASSET_OWNER']) AS roles(role_name) +WHERE NOT EXISTS ( + SELECT 1 + FROM nexent.role_permission_t existing + WHERE existing.user_role = roles.role_name + AND existing.permission_category = 'VISIBILITY' + AND existing.permission_type = 'LEFT_NAV_MENU' + AND existing.permission_subtype = '/agent-tasks' +); + +COMMIT; diff --git a/doc/docs/zh/backend/agent-automation-task-design.md b/doc/docs/zh/backend/agent-automation-task-design.md new file mode 100644 index 000000000..9161ad40b --- /dev/null +++ b/doc/docs/zh/backend/agent-automation-task-design.md @@ -0,0 +1,884 @@ +# 智能体自动化任务设计文档 + +## 1. 背景与目标 + +Nexent 需要支持用户在自然语言对话中为当前选择的智能体创建自动化任务,任务管理页面只负责管理,不提供脱离会话的手工创建入口。任务可以是一次性执行,也可以是周期性执行。每个任务唯一绑定创建它的会话;创建时生成并确认稳定的标题和单次执行指令,后续每次触发原样复用该指令,用户可以在同一会话中持续查看历史结果。 + +本设计的核心目标: + +- 自动化任务作为相对独立的业务模块建设,避免把调度、运行记录、触发规则分散到 Agent、Conversation、Frontend 组件里。 +- 一次性任务和周期性任务使用同一套 `ScheduleTrigger` 抽象、同一套任务表、同一套运行表和同一套执行链路。 +- 保持和现有 Nexent 会话模型一致:任务绑定 conversation,删除 conversation 时同步删除任务;删除任务时不删除 conversation。 +- 后台执行复用现有智能体运行能力,但不伪造浏览器 SSE 请求。 +- 可支持多实例部署下的抢占、锁、恢复、超时和失败可观测。 + +参考设计来源: + +- OpenClaw:独立 cron service、持久化 job/run、锁、启动恢复、超时与失败日志。参考 [OpenClaw cron schema](https://github.com/openclaw/openclaw/blob/7e0324263b867d3d47138d1d2b1e9afd1dd2016f/packages/gateway-protocol/src/schema/cron.ts) 和 [OpenClaw cron jobs](https://docs.openclaw.ai/automation/cron-jobs)。 +- LangGraph/LangSmith:cron job 可以绑定 thread,也可以每次创建新 thread。Nexent v1 选择 thread-bound 的 conversation-bound 模式。参考 [LangSmith cron jobs](https://docs.langchain.com/langsmith/cron-jobs)。 +- n8n:Schedule Trigger 明确区分启用状态、时区、固定时间、间隔和 cron 表达式。参考 [n8n Schedule Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.scheduletrigger/)。 +- Codex 产品形态:用户可在对话中创建自动任务,有独立页面管理任务;一个自动任务对应一个会话,每次执行都在该会话内继续追加任务执行提示词。 + +## 2. 模块边界 + +新增模块命名为 Agent Automation,后端目录建议如下: + +```text +backend/apps/agent_automation_app.py +backend/services/agent_automation/ + __init__.py + facade.py + models.py + repository.py + schedule_engine.py + scheduler.py + runner.py + intent_parser.py + capability_resolver.py + errors.py +backend/database/agent_automation_db.py +sdk/nexent/scheduler/ + core.py + triggers.py +``` + +模块职责: + +| 子模块 | 职责 | +| --- | --- | +| `agent_automation_app.py` | HTTP 边界,解析请求、调用 facade、映射异常 | +| `facade.py` | 对外唯一服务入口,屏蔽内部 repository、scheduler、runner | +| `models.py` | 自动化任务领域 DTO、枚举、校验模型 | +| `repository.py` / `agent_automation_db.py` | 自动化任务与运行记录的数据库访问 | +| `schedule_engine.py` | Backend `ScheduleTrigger` 到 SDK 调度模型的兼容适配 | +| `scheduler.py` | PostgreSQL 与 Agent Runner 的薄适配,不实现通用调度循环 | +| `runner.py` | 把一次触发转换成会话消息,并调用后台智能体执行入口 | +| `intent_analyzer.py` | 使用当前选择的 LLM 判断自动任务意图,并结构化生成标题、单次指令和调度配置 | +| `intent_parser.py` | 确定性时间解析与 LLM 不可用时的降级策略 | +| `capability_resolver.py` | 基于 Nexent 现有 Agent 配置匹配可用技能、工具、知识库、子 Agent 和 A2A Agent | +| `sdk/nexent/scheduler/core.py` | 独立的持久化租约调度内核:轮询、容量、续租、退避、租约丢失取消和有限停机 | +| `sdk/nexent/scheduler/triggers.py` | 不依赖 Backend/Pydantic 的单次、间隔和 Cron 触发时间计算 | + +与现有模块的依赖方向: + +```mermaid +flowchart LR + UI["Frontend Chat / Task Page"] --> API["agent_automation_app"] + API --> Auto["Agent Automation Facade"] + Auto --> Repo["Automation Repository"] + Auto --> Schedule["Schedule Engine"] + Auto --> Capability["Capability Resolver"] + Auto --> Runner["Automation Runner"] + BackendScheduler["Backend Scheduler Adapter"] --> SDK["SDK Lease Scheduler"] + SDK --> Repo + SDK --> BackendScheduler + BackendScheduler --> Runner + Runner --> ConvPort["Conversation Port"] + Runner --> AgentPort["Agent Runtime Port"] + ConvDelete["Conversation Service"] --> AutoHook["Automation Delete Hook"] + AutoHook --> Auto +``` + +解耦约束: + +- Agent Automation 可以调用 Conversation 和 Agent Runtime 暴露的端口;Conversation 和 Agent Runtime 不能直接读取自动任务表。 +- Conversation 模块只在删除会话时调用 `AgentAutomationFacade.on_conversation_deleted(conversation_id, user_id)`。 +- Agent Runtime 只新增后台执行端口,不感知任务表和调度规则。 +- Frontend 使用独立 `agentAutomationService` 访问任务接口,不把任务 CRUD 混入 `conversationService`。 +- SDK 不读取环境变量、不依赖 FastAPI、数据库或 Agent 业务;它提供可复用的调度内核和触发时间计算,Backend 通过 `LeaseStore` 与执行器端口注入具体能力。 +- 自然语言只生成“任务提案”,不能绕过 Nexent 当前 Agent 的技能/工具选择机制直接创建可执行任务。 +- 自动化任务保存创建时的能力快照,但每次运行仍通过 Agent Runtime 装配真实工具实例;快照用于校验、提示和审计,不复制工具执行逻辑。 + +需要新增的端口: + +| 端口 | 建议函数 | 实现位置 | +| --- | --- | --- | +| Conversation Port | `append_automation_user_message`、`get_conversation_history_for_task`、`mark_conversation_task_deleted` | 复用 `conversation_management_service` 与 `conversation_db` | +| Agent Runtime Port | `run_agent_background(agent_request, runtime_context)` | 从 `agent_service.run_agent_stream` 抽出公共 runner | +| Automation Delete Hook | `on_conversation_deleted(conversation_id, user_id)` | `agent_automation.facade` | +| Agent Capability Port | `resolve_agent_capabilities(agent_id, tenant_id, user_id, version_no)` | 复用 `create_agent_config`、`SkillService.get_enabled_skills_for_agent`、`search_tools_for_sub_agent` | +| Prompt Strategy Port | `generate_task_content(context)` | `agent_automation.prompt_generator`,在创建阶段一次性生成标题和执行指令 | + +## 3. 领域模型 + +### 3.1 自动化任务 + +一个自动化任务描述“谁、在哪个会话、用哪个智能体、按什么触发规则、执行什么指令”。 + +```text +AutomationTask + task_id + tenant_id + user_id + conversation_id + agent_id + agent_version_no + title + instruction + status + source + schedule_trigger + capability_requirements + capability_bindings + execution_policy + runtime_snapshot + next_fire_at + last_fire_at + last_run_status + last_error + consecutive_failures +``` + +任务状态: + +| 状态 | 含义 | +| --- | --- | +| `DRAFT` | 对话识别出任务意图后生成提案,等待确认 | +| `ACTIVE` | 已启用,调度器可触发 | +| `PAUSED` | 用户暂停 | +| `PAUSED_BY_SYSTEM` | 连续失败、Agent 不可用、权限失效等原因被系统暂停 | +| `COMPLETED` | 一次性任务执行成功且不再触发 | +| `DELETED` | 软删除 | + +任务来源: + +| 来源 | 含义 | +| --- | --- | +| `CHAT_INTENT` | 用户在对话中用自然语言创建 | + +### 3.2 能力匹配模型 + +Nexent 的自动化任务不能只保存一句自然语言指令。任务必须明确“执行这件事依赖当前 Agent 的哪些能力”,否则到点后 Agent 可能缺少对应技能、工具或知识库,导致任务只是形式上被创建,实际不可执行。 + +因此自然语言创建任务分为两层: + +1. `IntentParser`:识别用户是否在创建自动任务,并抽取任务目标、时间、频率、输出要求。 +2. `CapabilityResolver`:基于当前 Agent 的可用能力判断任务是否可执行,并生成待确认的能力绑定。 + +能力来源以现有 Nexent 装配链路为准: + +| 能力类型 | 当前来源 | 用途 | +| --- | --- | --- | +| `TOOL` | `create_tool_config_list` / `search_tools_for_sub_agent` | 搜索、文件、终端、多模态、MCP 工具等 | +| `SKILL` | `SkillService.get_enabled_skills_for_agent` | 已启用技能及其说明、脚本、配置 | +| `KNOWLEDGE_BASE` | `KnowledgeBaseSearchTool` 的 `index_names` 和 metadata | 需要检索指定知识库的任务 | +| `MANAGED_AGENT` | `query_sub_agent_relations` + `create_agent_config` | 主 Agent 可调度的内部子 Agent | +| `EXTERNAL_A2A_AGENT` | `_get_external_a2a_agents` | 外部 A2A Agent 能力 | +| `MEMORY` | `build_memory_context` 与记忆工具 | 需要长期记忆上下文的任务 | + +能力需求结构: + +```json +{ + "required": [ + { + "type": "KNOWLEDGE_BASE", + "name": "销售线索知识库", + "reason": "任务要求每天总结新增销售线索", + "match_status": "MATCHED" + }, + { + "type": "TOOL", + "name": "linkup_search", + "reason": "任务要求检索公开网页信息", + "match_status": "MISSING" + } + ], + "optional": [ + { + "type": "SKILL", + "name": "report-writing", + "reason": "可用于结构化生成日报", + "match_status": "MATCHED" + } + ] +} +``` + +能力绑定结构: + +```json +{ + "agent": { + "agent_id": 456, + "version_no": 3, + "name": "销售助手" + }, + "matched_capabilities": [ + { + "type": "KNOWLEDGE_BASE", + "name": "sales_leads", + "display_name": "销售线索知识库", + "binding_ref": "tool:KnowledgeBaseSearchTool:index:sales_leads" + }, + { + "type": "SKILL", + "name": "report-writing", + "binding_ref": "skill:report-writing" + } + ], + "missing_capabilities": [ + { + "type": "TOOL", + "name": "linkup_search", + "suggestion": "请先在该 Agent 中启用联网搜索工具,或删除任务中的公开网页检索要求" + } + ], + "executable": false +} +``` + +创建规则: + +- `executable=false` 时不能确认创建 `ACTIVE` 任务,只能保存为 `DRAFT` 提案并提示用户补齐 Agent 能力。 +- 用户可以选择“修改任务要求”,让任务只使用已匹配能力。 +- 用户也可以跳转到 Agent 配置页启用工具、技能或知识库,回来后重新执行能力匹配。 +- `executable=true` 时才允许确认创建 `ACTIVE` 任务。 +- 任务确认时保存 `capability_requirements`、`capability_bindings` 和 `runtime_snapshot`,用于后续审计和运行前校验。 +- 每次执行前重新校验绑定能力是否仍可用;如果能力缺失,本次 run 记为 `FAILED`,错误码为 `AUTOMATION_CAPABILITY_UNAVAILABLE`,连续失败达到阈值后任务进入 `PAUSED_BY_SYSTEM`。 + +### 3.3 统一 ScheduleTrigger + +一次性任务和周期性任务都使用 `ScheduleTrigger`,差异只体现在 `mode` 与 `rule_type`。 + +```json +{ + "mode": "ONCE", + "rule_type": "AT", + "timezone": "Asia/Shanghai", + "start_at": "2026-07-09T09:00:00+08:00", + "end_at": null, + "cron_expr": null, + "interval_seconds": null, + "max_fire_count": 1 +} +``` + +```json +{ + "mode": "RECURRING", + "rule_type": "CRON", + "timezone": "Asia/Shanghai", + "start_at": "2026-07-08T00:00:00+08:00", + "end_at": null, + "cron_expr": "0 9 * * *", + "interval_seconds": null, + "max_fire_count": null +} +``` + +字段规则: + +| 字段 | 说明 | +| --- | --- | +| `mode` | `ONCE` 或 `RECURRING` | +| `rule_type` | `AT`、`INTERVAL`、`CRON` | +| `timezone` | 用户选择的 IANA 时区,默认浏览器时区 | +| `start_at` | 首次可触发时间;一次性任务即执行时间 | +| `end_at` | 周期性任务截止时间,可为空 | +| `cron_expr` | `rule_type=CRON` 时必填 | +| `interval_seconds` | `rule_type=INTERVAL` 时必填 | +| `max_fire_count` | 一次性任务固定为 1;周期性任务可为空或指定最大次数 | + +合法组合: + +| mode | rule_type | 使用场景 | +| --- | --- | --- | +| `ONCE` | `AT` | 指定时间执行一次 | +| `RECURRING` | `INTERVAL` | 每 N 分钟/小时/天执行 | +| `RECURRING` | `CRON` | 每天 9 点、每周一 10 点等日历规则 | + +v1 不支持的组合: + +- `ONCE + INTERVAL` +- `ONCE + CRON` +- `RECURRING + AT` + +`ScheduleEngine` 对外只提供统一方法: + +```python +def compute_next_fire_at( + trigger: ScheduleTrigger, + after: datetime, + fire_count: int, +) -> datetime | None: + ... +``` + +返回 `None` 表示任务不再触发: + +- 一次性任务已经执行过; +- 达到 `max_fire_count`; +- 下一次时间超过 `end_at`。 + +### 3.4 运行记录 + +每次触发生成一条运行记录。 + +```text +AutomationRun + run_id + task_id + tenant_id + user_id + conversation_id + scheduled_fire_at + actual_fire_at + trigger_type + status + generated_prompt + user_message_id + assistant_message_id + started_at + finished_at + duration_ms + error_code + error_message +``` + +运行状态: + +| 状态 | 含义 | +| --- | --- | +| `QUEUED` | 已抢占,等待执行 | +| `RUNNING` | 正在执行 | +| `SUCCEEDED` | 执行成功 | +| `FAILED` | 执行失败 | +| `SKIPPED` | 因 overlap、任务过期等策略跳过 | +| `CANCELED` | 用户或会话删除导致取消 | +| `TIMEOUT` | 超时中止 | + +## 4. 数据库设计 + +新增表 `agent_automation_task_t`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `task_id` | BIGSERIAL PK | 任务 ID | +| `tenant_id` | VARCHAR(100) | 租户 | +| `user_id` | VARCHAR(100) | 所属用户 | +| `conversation_id` | BIGINT NOT NULL | 绑定会话 | +| `agent_id` | BIGINT NOT NULL | 绑定智能体 | +| `agent_version_no` | VARCHAR(100) NULL | 创建时版本,可为空表示使用当前版本 | +| `title` | VARCHAR(255) | 任务名 | +| `instruction` | TEXT | 每次执行的基础指令 | +| `status` | VARCHAR(32) | 任务状态 | +| `source` | VARCHAR(32) | 来源 | +| `schedule_mode` | VARCHAR(16) | `ONCE` / `RECURRING` | +| `schedule_rule_type` | VARCHAR(16) | `AT` / `INTERVAL` / `CRON` | +| `schedule_expr` | TEXT NULL | cron 表达式或 interval 表达式的展示值 | +| `schedule_config` | JSONB | 完整 `ScheduleTrigger` | +| `capability_requirements` | JSONB | 从自然语言或表单解析出的能力需求 | +| `capability_bindings` | JSONB | 用户确认时匹配到的技能、工具、知识库、子 Agent | +| `runtime_snapshot` | JSONB | 创建时 Agent 名称、版本、模型、能力摘要,用于审计和变更提示 | +| `timezone` | VARCHAR(64) | IANA 时区 | +| `next_fire_at` | TIMESTAMPTZ NULL | 下次触发时间 | +| `last_fire_at` | TIMESTAMPTZ NULL | 上次触发时间 | +| `fire_count` | INT DEFAULT 0 | 已触发次数 | +| `last_run_status` | VARCHAR(32) NULL | 最近运行状态 | +| `last_error` | TEXT NULL | 最近错误 | +| `consecutive_failures` | INT DEFAULT 0 | 连续失败次数 | +| `timeout_seconds` | INT | 单次运行超时 | +| `overlap_policy` | VARCHAR(16) | v1 固定 `SKIP` | +| `misfire_policy` | VARCHAR(16) | v1 固定 `RUN_ONCE` | +| `lock_owner` | VARCHAR(128) NULL | 调度抢占 owner | +| `lock_until` | TIMESTAMPTZ NULL | lease 过期时间 | +| `created_by` / `updated_by` / `delete_flag` | 复用现有审计字段 | 软删除与审计 | + +索引: + +```sql +CREATE INDEX idx_agent_automation_due +ON agent_automation_task_t (status, next_fire_at) +WHERE delete_flag = 'N'; + +CREATE INDEX idx_agent_automation_owner +ON agent_automation_task_t (tenant_id, user_id, status) +WHERE delete_flag = 'N'; + +CREATE UNIQUE INDEX uq_agent_automation_conversation_active +ON agent_automation_task_t (conversation_id) +WHERE delete_flag = 'N' AND status <> 'DELETED'; +``` + +新增表 `agent_automation_run_t`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `run_id` | BIGSERIAL PK | 运行 ID | +| `task_id` | BIGINT NOT NULL | 任务 ID | +| `tenant_id` | VARCHAR(100) | 租户 | +| `user_id` | VARCHAR(100) | 用户 | +| `conversation_id` | BIGINT NOT NULL | 会话 | +| `scheduled_fire_at` | TIMESTAMPTZ | 计划触发时间 | +| `actual_fire_at` | TIMESTAMPTZ | 实际触发时间 | +| `trigger_type` | VARCHAR(32) | `SCHEDULED` / `MANUAL` | +| `status` | VARCHAR(32) | 运行状态 | +| `generated_prompt` | TEXT | 本次写入会话的自动提示词 | +| `user_message_id` | BIGINT NULL | 自动 user message | +| `assistant_message_id` | BIGINT NULL | assistant message | +| `started_at` / `finished_at` | TIMESTAMPTZ | 执行时间 | +| `duration_ms` | BIGINT NULL | 耗时 | +| `error_code` | VARCHAR(64) NULL | 错误码 | +| `error_message` | TEXT NULL | 错误详情 | +| `capability_check` | JSONB NULL | 本次运行前能力校验结果 | + +新增表 `agent_automation_proposal_t`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `proposal_id` | BIGSERIAL PK | 提案 ID | +| `tenant_id` / `user_id` | VARCHAR(100) | 所属用户 | +| `conversation_id` | BIGINT NOT NULL | 来源会话 | +| `agent_id` | BIGINT NOT NULL | 智能体 | +| `proposed_task` | JSONB | 待确认任务配置 | +| `capability_resolution` | JSONB | 能力匹配结果 | +| `status` | VARCHAR(32) | `PENDING` / `ACCEPTED` / `REJECTED` / `EXPIRED` | +| `expires_at` | TIMESTAMPTZ | 过期时间 | + +迁移要求: + +- 新增 `deploy/sql/migrations/v2.3.0_0713_add_agent_automation.sql`,并同步更新 `deploy/sql/init.sql` 以覆盖 fresh deploy。 +- 同步更新 `deploy/sql/init.sql`。 +- 同步更新 `deploy/k8s/helm/nexent/charts/nexent-common/files/init.sql`。 +- ORM 模型新增到 `backend/database/db_models.py`,遵循现有 `TableBase` 约定。 + +## 5. API 设计 + +所有接口挂到 Runtime API,前缀为 `/agent/automations`。 + +### 5.1 对话创建提案 + +```http +POST /agent/automations/proposals +``` + +请求: + +```json +{ + "conversation_id": 123, + "agent_id": 456, + "message": "明天上午 9 点帮我总结这个项目的进展", + "timezone": "Asia/Shanghai" +} +``` + +`conversation_id` 在已有会话中必填;用户从“新对话”直接输入定时指令时可以省略。后端先做无副作用的意图识别,只有确认属于自动任务意图后才创建新会话,并把用户原始指令和 `automation_proposal` 卡片连续写入该会话。普通聊天消息不会因此创建空会话。 + +响应: + +```json +{ + "proposal_id": 1001, + "conversation_id": 123, + "confidence": 0.92, + "executable": false, + "task": { + "title": "总结项目进展", + "instruction": "总结这个项目的最新进展,并给出风险和下一步建议", + "schedule_trigger": { + "mode": "ONCE", + "rule_type": "AT", + "timezone": "Asia/Shanghai", + "start_at": "2026-07-09T09:00:00+08:00", + "max_fire_count": 1 + } + }, + "capability_resolution": { + "matched_capabilities": [ + { + "type": "KNOWLEDGE_BASE", + "name": "project_docs", + "display_name": "项目文档知识库", + "binding_ref": "tool:KnowledgeBaseSearchTool:index:project_docs" + } + ], + "missing_capabilities": [ + { + "type": "TOOL", + "name": "project_management_api", + "suggestion": "如果需要读取实时项目管理系统,请先为该 Agent 启用对应 MCP 工具;否则可改为仅基于当前会话和知识库总结" + } + ], + "agent_snapshot": { + "agent_id": 456, + "version_no": 3, + "name": "项目助手" + } + } +} +``` + +### 5.2 确认提案 + +```http +POST /agent/automations/proposals/{proposal_id}/confirm +``` + +创建提案时即在当前会话依次持久化用户定时指令和 `automation_proposal` message unit;刷新会话后完整的提案交互仍然存在。前端在 Agent 普通执行之前调用提案接口;识别成功后不再把“每周发周报”作为普通查询立即执行一次。确认后创建 `agent_automation_task_t`,并原位更新该 message unit 的 `confirmed_task_id`,避免出现只存在于前端内存的临时卡片。 + +确认规则: + +- 如果 `capability_resolution.executable=false`,接口返回 `AUTOMATION_CAPABILITY_NOT_READY`,前端展示缺失能力和处理建议。 +- 用户在确认卡片中选择“仅使用已匹配能力创建”时,前端必须提交修改后的 `instruction`,后端重新解析并匹配能力。 +- 用户跳转配置页启用工具、技能或知识库后,需要重新调用 `POST /agent/automations/proposals` 刷新提案,不能复用旧的不可执行提案直接确认。 + +### 5.3 创建边界 + +- 不暴露 `POST /agent/automations` 直接创建接口,也不接受脱离聊天消息的任务 DTO。 +- 新对话允许 proposal 命令在识别到定时意图后创建绑定会话;这是会话命令的一部分,不是任务管理页的手工创建能力。 +- 任务只能由当前会话中的 proposal 确认创建;`conversation_id` 和当前选择的 `agent_id` 在提案阶段确定。 +- 创建提案和确认提案时都校验会话归属;如果会话已有未删除任务,返回 `AUTOMATION_CONVERSATION_ALREADY_BOUND`。 +- 创建时基于所选 Agent 的名称、职责和能力快照优化基础任务指令,并保存原始意图用于审计。 + +### 5.4 管理接口 + +| 接口 | 说明 | +| --- | --- | +| `GET /agent/automations` | 列表,支持状态、关键词、agent、时间范围过滤 | +| `GET /agent/automations/{task_id}` | 任务详情 | +| `PATCH /agent/automations/{task_id}` | 编辑任务配置 | +| `POST /agent/automations/{task_id}/pause` | 暂停 | +| `POST /agent/automations/{task_id}/resume` | 恢复 | +| `POST /agent/automations/{task_id}/run` | 立即运行 | +| `DELETE /agent/automations/{task_id}` | 删除任务,不删除会话 | +| `GET /agent/automations/{task_id}/runs` | 运行历史 | +| `POST /agent/automations/runs/{run_id}/cancel` | 取消运行 | +| `GET /conversation/{conversation_id}/automation` | 查询会话绑定任务 | + +## 6. 执行流程 + +### 6.1 对话内创建任务 + +```mermaid +sequenceDiagram + participant U as User + participant Chat as Chat UI + participant Auto as Agent Automation + participant Cap as Capability Resolver + participant Conv as Conversation + + U->>Chat: 输入“明天上午9点帮我总结进展” + Chat->>Auto: POST /proposals + Auto->>Auto: intent_analyzer 调用 LLM 判断意图并生成结构化任务 + Auto->>Auto: 校验 AT / INTERVAL / Cron / 时区 / 时间范围 + Auto->>Cap: 基于当前 Agent 匹配技能、工具、知识库、子 Agent + Cap-->>Auto: 返回 matched/missing capabilities + Auto->>Conv: 写入 automation proposal card + Chat->>U: 展示任务计划、能力匹配和缺失能力 + U->>Chat: 点击确认 + Chat->>Auto: POST /proposals/{id}/confirm + Auto->>Auto: executable=true 时创建 AutomationTask + Auto->>Conv: 写入任务创建成功卡片 +``` + +自然语言识别规则: + +- 消息包含日期、时间、周期、提醒或计划信号时,`IntentAnalyzer` 优先调用当前选择的 LLM;选择模型不可用时使用租户默认 LLM,两者均不可用或模型输出非法时才降级到 `IntentParser`。 +- LLM 在一次调用中同时输出 `is_automation_intent`、`title`、`instruction`、`schedule` 和 `schedule_error`,不再为标题和提示词发起第二次模型调用。 +- `IntentParser` 作为确定性降级策略,支持固定间隔、相对延迟、明确/短日期、今天/明天/后天、相对月份、工作日/周末、星期范围、多星期、多月日、月底、季度、年度日期和五段 Cron 可表达的日历周期。 +- 固定间隔生成 `INTERVAL`;指定一次时间生成 `AT`;每天、每周、每月、每年等日历周期生成标准五段 Cron。 +- 同一周期任务可以包含具有相同分钟或相同小时的多个时刻,例如“每天上午 9 点、下午 3 点”生成 `0 9,15 * * *`;无法用单个 Cron 精确表达的多个时刻要求拆分任务,不生成近似规则。 +- 用户在文本中明确常见地区时间、UTC 或 IANA 时区时优先使用文本时区,否则使用请求携带的 IANA 时区;一次性任务遇到 DST 不存在或歧义时间时拒绝创建。 +- 缺少日期、缺少具体时刻、日期非法或指定时间已过去时,返回明确的 `schedule_error`,不默认猜测为上午 9 点,也不创建 proposal。 +- 意图判定同时检查调度语义、业务动作和两者在句子中的修饰关系,不使用“时间词 + 动作词”简单碰撞规则。 +- 调度语义位于任务动作之前时识别为创建命令,例如“每天八点获取黄历”和“帮我每天八点分析销量”;动作位于调度短语之前且调度短语属于查询对象时继续走普通任务,例如“分析一下每天八点的销量”。 +- 调度短语但没有业务动作的问句、说明句和个人习惯陈述不会被识别为创建命令,例如“每周的销量是多少”“每天八点如何获取黄历”“我每天八点都会查看黄历”。 +- LLM 生成的标题和单次指令必须通过语言、长度、调度噪音和编排噪音校验;规则降级路径才调用 `AutomationPromptGenerator` 生成任务内容。 +- `CapabilityResolver` 使用最终 `instruction` 调用现有 Agent 能力装配链路,生成 matched/missing capabilities。 +- 低置信度或非任务意图不创建 proposal,只让普通 Agent 对话继续。 +- 所有对话创建都必须用户确认,不能由模型直接创建 `ACTIVE` 任务。 +- 如果缺少必要能力,确认卡片展示“需要先启用的技能/工具/知识库”;用户不能直接确认,只能修改任务要求或去配置 Agent。 + +`intent_analyzer` 输出结构: + +```json +{ + "is_automation_intent": true, + "confidence": 0.92, + "title": "", + "instruction": "总结项目进展", + "schedule_trigger": { + "mode": "ONCE", + "rule_type": "AT", + "timezone": "Asia/Shanghai", + "start_at": "2026-07-09T09:00:00+08:00", + "max_fire_count": 1 + }, + "schedule_error": null, + "capability_intents": [], + "output_requirements": {} +} +``` + +`CapabilityResolver` 匹配策略: + +- 先调用 `resolve_agent_capabilities` 获取当前 Agent 能力快照,快照包含 tools、enabled skills、knowledge indexes、managed agents、external A2A agents、memory enabled。 +- 对 `capability_intents.required=true` 的能力做强校验;任何必需能力缺失时 `executable=false`。 +- 对 optional 能力只给建议,不阻断创建。 +- 知识库匹配优先使用 `KnowledgeBaseSearchTool` 的 `index_names` 和 display metadata。 +- 技能匹配使用技能 name、description、content 摘要,不在创建阶段读取或执行 skill script。 +- 工具匹配使用 tool name、class_name、description、labels、inputs,不在创建阶段执行工具。 +- 子 Agent/A2A Agent 匹配使用名称、描述和 Agent Card skills。 +- 匹配结果必须给出自然语言 reason,供确认卡片展示。 + +### 6.2 调度执行 + +```mermaid +sequenceDiagram + participant S as Automation Scheduler + participant R as Automation Repository + participant Run as Automation Runner + participant C as Conversation Port + participant A as Agent Runtime Port + + S->>R: claim_due_tasks(now) + R-->>S: claimed task list + S->>Run: execute(task) + Run->>R: create run RUNNING + Run->>Run: recheck capability_bindings + Run->>C: append automation user message + Run->>C: load conversation history + Run->>A: run_agent_background + A-->>Run: assistant message completed + Run->>R: mark run SUCCEEDED + Run->>R: compute and update next_fire_at +``` + +自动任务意图、标题、提示词和执行周期由 `AutomationIntentAnalyzer` 在创建提案时统一生成: + +- `AutomationIntentStrategyFactory` 优先使用请求中的当前选择 LLM,否则使用租户默认 LLM;没有可用模型时使用规则策略。 +- LLM 策略严格输出结构化 JSON;标题是任务目标短语,指令是一次触发真正执行的业务动作,调度类型只能是 `AT`、`INTERVAL` 或 `CRON`。 +- 普通任务由 LLM 返回 `is_automation_intent=false`,继续进入原有 Agent 对话,不创建会话或 proposal。 +- LLM 生成的 Cron、时区、时间范围、最小间隔和字段组合必须经过确定性校验,校验失败时不允许落库或作为普通任务立即执行。 +- 不允许加入调度时间、Agent、能力、工具、配置、重试、错误处理或用户未要求的步骤和输出格式。 +- 模型不可用、JSON 非法、输出过长或任一字段引入编排噪音时,整组分析结果降级为确定性规则,不保留半清洗输出。 +- 任务触发时直接复用用户已确认的执行指令,Agent 配置、工具参数和会话历史通过运行时参数独立传递。 + +执行策略: + +- `overlap_policy=SKIP`:同一 conversation 已有运行中的 Agent 时,本次 run 记为 `SKIPPED`。 +- `misfire_policy=RUN_ONCE`:服务停机期间错过多次触发时,恢复后只补执行一次,然后计算下一次。 +- `timeout_seconds` 默认 1800 秒,Runner 使用异步超时控制强制终止超时执行并记录 `TIMEOUT`。 +- Scheduler 在任务运行期间按 lease 周期续租,避免长任务被其他实例重复抢占。 +- 用户取消任务运行后,Runner 只能从 `RUNNING` 原子更新到终态,迟到结果不能覆盖 `CANCELED`。 +- 只有计划触发会消费一次 `fire_count` 并推进 `next_fire_at`;任务页“立即运行”是旁路执行,不改变原计划和触发次数。 +- 周期任务的计划执行即使 `FAILED`、`TIMEOUT` 或 `SKIPPED`,也推进到下一计划时间,避免调度器对已到期时间进行秒级重试;连续 5 个计划周期失败后进入 `PAUSED_BY_SYSTEM`。 +- 一次性任务完成一次计划尝试后进入 `COMPLETED`,不会无限重试;用户仍可在任务页通过“立即运行”手动重试,且手动执行不改变原计划计数。 +- 每次运行前重新校验 `capability_bindings`。如果创建时绑定的必需技能、工具、知识库、子 Agent 或 A2A Agent 已被删除/禁用,本次不调用 Agent,直接记录 `FAILED + AUTOMATION_CAPABILITY_UNAVAILABLE`,并在绑定会话写入一条任务失败卡片。 +- 运行时不把 `capability_bindings` 当成硬编码工具调用序列;它只作为提示词约束和可用性校验。实际工具选择仍由 Nexent Agent ReAct loop 根据当前上下文动态决定。 + +### 6.3 会话删除联动 + +`conversation_management_service.delete_conversation_service` 增加删除 hook: + +```text +delete_conversation_service + -> AgentAutomationFacade.on_conversation_deleted(conversation_id, user_id) + -> soft delete bound task + -> cancel running automation run + -> release task lock + -> delete_conversation(...) + -> agent_run_manager.clear_conversation_context_manager(...) +``` + +联动规则: + +- 删除会话时同步删除绑定任务。 +- 如果任务正在运行,先调用 Agent Runtime stop 端口取消该 conversation 的运行。 +- 删除任务时不删除会话,只停止未来触发。 + +## 7. 调度器设计 + +`AgentAutomationScheduler` 在 Runtime API 启动时启动,在 shutdown 时停止。 + +环境变量统一放入 `backend/consts/const.py`: + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `AGENT_AUTOMATION_ENABLED` | `true` | 是否启用调度器 | +| `AGENT_AUTOMATION_POLL_INTERVAL_SECONDS` | `5` | 扫描间隔 | +| `AGENT_AUTOMATION_MAX_CONCURRENT_RUNS` | `2` | 单实例并发执行数 | +| `AGENT_AUTOMATION_LEASE_SECONDS` | `120` | 任务抢占 lease | +| `AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS` | `1800` | 默认执行超时 | +| `AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS` | `30` | 停机等待运行中任务完成的最大秒数 | +| `AGENT_AUTOMATION_MIN_INTERVAL_SECONDS` | `5` | 普通用户最小周期 | + +多实例抢占: + +```sql +WITH due AS ( + SELECT task_id + FROM agent_automation_task_t + WHERE delete_flag = 'N' + AND status = 'ACTIVE' + AND next_fire_at <= now() + AND (lock_until IS NULL OR lock_until < now()) + ORDER BY next_fire_at ASC + LIMIT :batch_size + FOR UPDATE SKIP LOCKED +), claimed AS ( + UPDATE agent_automation_task_t task + SET lock_owner = :instance_id, + lock_until = now() + interval '120 seconds' + FROM due + WHERE task.task_id = due.task_id + RETURNING task.* +), orphaned_runs AS ( + UPDATE agent_automation_run_t run + SET status = 'TIMEOUT', + error_code = 'AUTOMATION_LEASE_EXPIRED' + WHERE run.task_id IN (SELECT task_id FROM claimed) + AND run.trigger_type = 'SCHEDULED' + AND run.status IN ('QUEUED', 'RUNNING') +) +SELECT * FROM claimed +WHERE (SELECT count(*) FROM orphaned_runs) >= 0; +``` + +高可用与恢复语义: + +- Runtime 启动不等待数据库恢复成功;SDK 调度循环对恢复和抢占失败做指数退避,数据库恢复后自动继续工作,避免调度依赖拖垮 HTTP 服务启动。 +- 正常停机最多等待 `AGENT_AUTOMATION_SHUTDOWN_GRACE_SECONDS`;超时后取消执行并按 owner 释放 lease,后继实例可重新抢占同一计划时间。 +- 异常退出时数据库中的任务与 `next_fire_at` 均保留。其他实例只在 `lock_until` 过期后接管,因此不会因为一个 Runtime 重启而丢失任务。 +- 重新抢占任务时,在同一数据库语句中把上一个未完成的计划 run 标记为 `AUTOMATION_LEASE_EXPIRED`,随后由 Runner 创建新 run,避免遗留 `RUNNING` 永久阻塞任务。 +- lease 续期和有效性判断统一使用 PostgreSQL `now()`;已过期 lease 不允许“复活”。续期持续失败或 owner 已变化时,SDK 会设置 `lease.lost` 并取消旧执行器。 +- Runner 完成计划任务时必须通过 `task_id + lock_owner + lock_until > now()` fencing 条件提交任务游标;旧实例即使晚返回,也不能覆盖新 owner 的 `fire_count`、`next_fire_at` 或状态。 +- `uq_agent_automation_active_occurrence(task_id, scheduled_fire_at)` 部分唯一索引阻止同一计划时间同时存在两个活动 run。 +- 对 `ACTIVE` 且 `next_fire_at` 已过期的任务按 `misfire_policy=RUN_ONCE` 补一次;成功、失败、超时和跳过都只推进一个计划周期。 + +交付保证: + +- 调度层提供持久化的“至少一次”执行与陈旧写入隔离,不承诺任意外部副作用的 exactly-once。Agent 工具若调用第三方写接口,应使用 `task_id + scheduled_fire_at` 作为幂等键,或由目标系统提供幂等能力。 +- 在正常续租条件下,同一计划时间只由一个实例执行;在进程失联、网络分区且外部调用不可取消的极端窗口中,新的 owner 可能重试该计划时间,因此业务动作仍需幂等。 + +## 8. 前端设计 + +新增页面 `/agent-tasks`,导航文案“自动任务”。 + +页面能力: + +- 列表:任务名、状态、类型、计划、绑定会话、Agent、下次执行、上次结果。 +- 操作:打开会话、暂停、恢复、立即运行、编辑、删除。 +- 详情抽屉:任务配置、最近运行记录、生成提示词、错误信息、关联消息。 +- 创建入口:跳转聊天页,由用户在当前会话中用自然语言描述任务并确认提案。 + +一次性任务 UI: + +- 控件:日期时间选择器 + 时区。 +- 展示:`2026-07-09 09:00 执行一次`。 +- 成功后状态展示为 `已完成`。 + +周期性任务 UI: + +- 快捷模式:每 N 分钟/小时/天、每天指定时间、每周指定星期和时间。 +- 高级模式:cron 表达式。 +- 展示:`每天 09:00 执行`、`每周一 10:00 执行`。 + +聊天页增强: + +- 支持 `automation_proposal` message unit,渲染确认卡片。 +- 确认卡片必须展示任务计划、执行指令、绑定 Agent、已匹配能力、缺失能力和处理建议。 +- 能力不满足时,卡片主按钮不是“创建任务”,而是“修改任务要求”或“去配置 Agent”。 +- 当前会话绑定任务时,聊天头部展示任务 badge 和下次执行时间。 +- 会话列表 item 展示 clock 标识。 +- 删除会话时,如果存在绑定任务,确认文案明确提示会同步删除任务。 + +前端模块: + +```text +frontend/app/[locale]/agent-tasks/page.tsx +frontend/app/[locale]/agent-tasks/components/TaskList.tsx +frontend/app/[locale]/agent-tasks/components/TaskEditor.tsx +frontend/app/[locale]/agent-tasks/components/RunHistoryDrawer.tsx +frontend/services/agentAutomationService.ts +frontend/types/agentAutomation.ts +``` + +## 9. 错误码与边界行为 + +| 错误码 | 场景 | +| --- | --- | +| `AUTOMATION_CONVERSATION_ALREADY_BOUND` | 会话已有未删除任务 | +| `AUTOMATION_SCHEDULE_INVALID` | ScheduleTrigger 非法、时间已过去、Cron 非法或周期低于最小间隔 | +| `AUTOMATION_CONVERSATION_NOT_FOUND` | 会话不存在或无权限 | +| `AUTOMATION_AGENT_NOT_FOUND` | Agent 不存在或无权限 | +| `AUTOMATION_TASK_NOT_FOUND` | 任务不存在或无权限 | +| `AUTOMATION_TASK_NOT_ACTIVE` | 对非 active 任务执行 run/pause/resume 冲突操作 | +| `AUTOMATION_RUN_ALREADY_ACTIVE` | 同会话已有运行,且策略不允许重叠 | +| `AUTOMATION_CAPABILITY_NOT_READY` | 创建或确认任务时必需能力缺失 | +| `AUTOMATION_CAPABILITY_UNAVAILABLE` | 运行前发现已绑定能力被删除、禁用或无权限 | +| `AUTOMATION_CAPABILITY_BINDING_INVALID` | 前端提交的能力绑定不属于该 Agent | + +边界行为: + +- 用户暂停任务后,`next_fire_at` 保留但 scheduler 不触发。 +- 用户恢复任务时,基于当前时间重新计算 `next_fire_at`。 +- 用户编辑时间规则时,重置 `next_fire_at`,不删除历史 run。 +- Agent 被删除或不可用时,本次 run 记 `FAILED`;连续失败达到阈值后任务 `PAUSED_BY_SYSTEM`。 +- Agent 的技能、工具、知识库配置变化后,不主动批量修改任务;任务详情页显示“能力可能已变化”,下一次运行前做强校验。 +- 用户编辑任务指令或切换 Agent 时,必须重新执行能力匹配,并覆盖 `capability_requirements`、`capability_bindings`、`runtime_snapshot`。 +- 会话被删除后,任务不可恢复;如需恢复,用户需要重新创建任务。 + +## 10. 测试计划 + +后端测试: + +- `ScheduleEngine`: + - `ONCE + AT` 首次返回 start_at,执行后返回 `None`。 + - `RECURRING + INTERVAL` 正确计算下一次。 + - `RECURRING + CRON` 正确处理 timezone。 + - Cron 校验拒绝越界分钟/小时,支持多时刻、星期范围、月底和季度表达式。 + - `end_at`、`max_fire_count` 生效。 +- Repository: + - 创建任务、编辑任务、软删除任务。 + - 同一 conversation 重复创建任务失败。 + - `claim_due_tasks` 在并发场景只返回一次。 +- Facade: + - 直接 `POST /agent/automations` 不可用,任务只能由会话 proposal 创建。 + - 新对话只在识别到自动任务意图后创建,并同时持久化用户指令与 proposal;普通聊天不创建空会话。 + - 并发确认同一会话时,数据库唯一约束竞争映射为 `AUTOMATION_CONVERSATION_ALREADY_BOUND`,不返回 500。 + - 对话 proposal 确认后创建 task。 + - 创建提案时生成不含调度与编排噪音的标题和单次执行指令。 + - proposal 能解析出 schedule,但必需能力缺失时不能确认创建 `ACTIVE` 任务。 + - 用户修改 instruction 后重新匹配能力,匹配成功才允许确认。 + - 前端提交不属于该 Agent 的 capability binding 时返回 `AUTOMATION_CAPABILITY_BINDING_INVALID`。 + - 删除 conversation 后同步删除 task。 +- Runner: + - 自动 user message 写入同一 conversation。 + - 成功执行后 run 状态为 `SUCCEEDED`。 + - Agent 执行失败后 run 状态为 `FAILED`,任务记录 last_error。 + - overlap 时 run 状态为 `SKIPPED`。 + - 计划失败或跳过后推进到下一周期,手动立即运行不消费下一计划触发。 + - 运行前发现绑定技能、工具或知识库被禁用时,不调用 Agent,run 状态为 `FAILED`,错误码为 `AUTOMATION_CAPABILITY_UNAVAILABLE`。 + - 每次运行原样复用创建时已确认的执行指令;Agent 配置、能力和会话历史只通过结构化运行参数传入。 +- Scheduler: + - 启动恢复 timeout run。 + - 停机错过多次触发后按 `RUN_ONCE` 只补一次。 + - 连续失败 5 次后任务进入 `PAUSED_BY_SYSTEM`。 + +前端测试: + +- `/agent-tasks` 列表、编辑、暂停、恢复、立即运行、删除,以及“通过会话创建”跳转。 +- 聊天页 proposal card 的确认、编辑、取消。 +- proposal card 在能力满足时展示“创建任务”,能力缺失时展示“修改任务要求”和“去配置 Agent”。 +- 会话删除确认文案在绑定任务时切换。 +- 会话列表和聊天头部正确展示任务状态。 + +验收场景: + +- 用户在对话中输入“明天上午 9 点帮我总结项目进展”,确认后生成一次性任务;到点后同一会话新增自动提示词和智能体回复,任务状态变为 `COMPLETED`。 +- 用户在对话中输入“每天 9 点联网查询竞品新闻并总结”,但当前 Agent 未启用联网搜索工具;系统生成提案但不可确认,卡片提示需要启用对应工具或修改任务要求。 +- 用户为 Agent 启用联网搜索工具后重新创建上述任务;能力匹配通过,任务可以确认并保存能力快照。 +- 用户输入“每周发一个周报”时,因为缺少星期和具体时间,系统明确要求补充信息,不默认猜测执行时间。 +- 用户从新对话输入“每周五上午 9 点发一个周报”时,系统创建并绑定新会话、展示提案,不先让 Agent 立即生成一次周报。 +- 用户询问“今天项目进展如何”或“明天上午的天气怎么样”时不会误生成定时任务提案。 +- 用户输入“每周五下午 3 点发一个周报”时生成 `0 15 * * 5`,输入“每月 15 日上午 10 点汇总销售数据”时生成 `0 10 15 * *`。 +- 用户输入“每周一至周五上午 9 点发送日报”时生成 `0 9 * * 1-5`;输入“每天上午 9 点、下午 3 点检查状态”时生成 `0 9,15 * * *`。 +- 用户输入“每月最后一天上午 9 点生成月报”时生成 `0 9 L * *`;输入“每季度第一天上午 9 点生成季报”时生成 `0 9 1 1,4,7,10 *`。 +- 用户输入“每 5 秒发送一次你好”时生成 `INTERVAL=5`,调度器按 5 秒轮询基线发现到期任务;重叠运行仍按 `SKIP` 策略保护。 +- 周期任务创建后,管理员禁用其绑定知识库;下一次运行前校验失败,run 记录 `AUTOMATION_CAPABILITY_UNAVAILABLE`,不会让 Agent 编造总结。 +- 删除绑定会话后,任务页不再展示该任务,后台不会再触发。 +- 暂停周期任务后不会执行;恢复后基于当前时间重新计算下一次触发。 +- 多个 Runtime 实例同时运行时,同一个到期任务只会被一个实例执行。 diff --git a/frontend/app/[locale]/agent-tasks/components/AutomationProposalCard.tsx b/frontend/app/[locale]/agent-tasks/components/AutomationProposalCard.tsx new file mode 100644 index 000000000..77a122e55 --- /dev/null +++ b/frontend/app/[locale]/agent-tasks/components/AutomationProposalCard.tsx @@ -0,0 +1 @@ +export { default } from "@/features/agentAutomation/components/AutomationProposalCard"; diff --git a/frontend/app/[locale]/agent-tasks/page.tsx b/frontend/app/[locale]/agent-tasks/page.tsx new file mode 100644 index 000000000..3e756e2a5 --- /dev/null +++ b/frontend/app/[locale]/agent-tasks/page.tsx @@ -0,0 +1,810 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { + Button, + Drawer, + Dropdown, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import type { TableProps } from "antd"; +import type { MenuProps } from "antd"; +import { + CalendarClock, + History, + MessageCirclePlus, + MoreHorizontal, + Pause, + Pencil, + Play, + RefreshCw, + Search, + Square, + Trash2, +} from "lucide-react"; + +import { agentAutomationService } from "@/services/agentAutomationService"; +import { getAutomationErrorMessage } from "@/features/agentAutomation/errorMessage"; +import type { + AgentAutomationRun, + AgentAutomationTask, + AutomationTaskStatus, + UpdateAutomationTaskPayload, +} from "@/types/agentAutomation"; + +const statusColor: Record = { + ACTIVE: "green", + PAUSED: "gold", + PAUSED_BY_SYSTEM: "red", + COMPLETED: "blue", +}; + +interface TaskFormValues { + title: string; + instruction: string; + mode: "ONCE" | "RECURRING"; + rule_type: "INTERVAL" | "CRON"; + start_at: string; + cron_expr?: string; + interval_seconds?: number; + timeout_seconds?: number; +} + +function toLocalInputValue(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function buildPatchPayload( + values: TaskFormValues +): UpdateAutomationTaskPayload { + const mode = values.mode; + const startAt = new Date(values.start_at).toISOString(); + const timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai"; + const scheduleTrigger = + mode === "ONCE" + ? { + mode: "ONCE" as const, + rule_type: "AT" as const, + timezone, + start_at: startAt, + max_fire_count: 1, + } + : values.rule_type === "INTERVAL" + ? { + mode: "RECURRING" as const, + rule_type: "INTERVAL" as const, + timezone, + start_at: startAt, + interval_seconds: values.interval_seconds, + } + : { + mode: "RECURRING" as const, + rule_type: "CRON" as const, + timezone, + start_at: startAt, + cron_expr: values.cron_expr, + }; + + return { + title: values.title, + instruction: values.instruction, + schedule_trigger: scheduleTrigger, + timeout_seconds: values.timeout_seconds || 1800, + }; +} + +function taskToFormValues(task: AgentAutomationTask) { + const trigger = task.schedule_config; + return { + title: task.title, + agent_id: task.agent_id, + instruction: task.instruction, + mode: trigger.mode, + rule_type: trigger.rule_type === "AT" ? "CRON" : trigger.rule_type, + start_at: toLocalInputValue(new Date(trigger.start_at)), + cron_expr: trigger.cron_expr || "0 9 * * *", + interval_seconds: trigger.interval_seconds || 3600, + timeout_seconds: task.timeout_seconds || 1800, + }; +} + +export default function AgentTasksPage() { + const router = useRouter(); + const params = useParams<{ locale: string }>(); + const { t, i18n } = useTranslation("common"); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [taskNameSearch, setTaskNameSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState< + AutomationTaskStatus | undefined + >(); + const [modalOpen, setModalOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [selectedTask, setSelectedTask] = useState( + null + ); + const [editingTask, setEditingTask] = useState( + null + ); + const [runs, setRuns] = useState([]); + const [form] = Form.useForm(); + const loadRequestIdRef = useRef(0); + + const formatDateTime = (value?: string | null) => + value + ? new Intl.DateTimeFormat( + i18n.language.startsWith("zh") ? "zh-CN" : "en-US", + { + dateStyle: "medium", + timeStyle: "medium", + } + ).format(new Date(value)) + : "-"; + + const formatTaskStatus = (status: string) => + t(`agentAutomation.status.${status}`, { defaultValue: status }); + + const formatRunStatus = (status?: string | null) => + status + ? t(`agentAutomation.runStatus.${status}`, { defaultValue: status }) + : "-"; + + const formatTriggerType = (triggerType: string) => + t(`agentAutomation.triggerType.${triggerType}`, { + defaultValue: triggerType, + }); + + const formatScheduleDetail = (task: AgentAutomationTask) => { + const trigger = task.schedule_config; + if (trigger.rule_type === "AT") { + return formatDateTime(trigger.start_at); + } + if (trigger.rule_type === "INTERVAL") { + return t("agentAutomation.page.everySeconds", { + count: trigger.interval_seconds, + }); + } + return t("agentAutomation.page.cronSchedule", { + expression: trigger.cron_expr, + }); + }; + + const loadTasks = useCallback(async () => { + const requestId = ++loadRequestIdRef.current; + setLoading(true); + try { + const loadedTasks = await agentAutomationService.list({ + status: statusFilter, + search: taskNameSearch, + }); + if (requestId === loadRequestIdRef.current) { + setTasks(loadedTasks); + } + } catch (error: unknown) { + if (requestId === loadRequestIdRef.current) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.loadFailed") + ); + } + } finally { + if (requestId === loadRequestIdRef.current) { + setLoading(false); + } + } + }, [statusFilter, taskNameSearch, t]); + + useEffect(() => { + void loadTasks(); + }, [loadTasks]); + + const handleTableChange: TableProps["onChange"] = ( + _pagination, + filters + ) => { + const nextTaskName = filters.title?.[0]; + const nextStatus = filters.status?.[0]; + setTaskNameSearch( + typeof nextTaskName === "string" ? nextTaskName.trim() : "" + ); + setStatusFilter( + typeof nextStatus === "string" + ? (nextStatus as AutomationTaskStatus) + : undefined + ); + }; + + const openEdit = (task: AgentAutomationTask) => { + setEditingTask(task); + form.setFieldsValue(taskToFormValues(task)); + setModalOpen(true); + }; + + const submitTask = async () => { + if (!editingTask) return; + const values = (await form.validateFields()) as TaskFormValues; + try { + await agentAutomationService.update( + editingTask.task_id, + buildPatchPayload(values) + ); + message.success(t("agentAutomation.page.updateSuccess")); + setModalOpen(false); + setEditingTask(null); + await loadTasks(); + } catch (error: unknown) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.updateFailed") + ); + } + }; + + const openRuns = async (task: AgentAutomationTask) => { + setSelectedTask(task); + setHistoryOpen(true); + try { + setRuns(await agentAutomationService.runs(task.task_id)); + } catch (error: unknown) { + message.error( + getAutomationErrorMessage( + error, + t, + "agentAutomation.page.historyLoadFailed" + ) + ); + } + }; + + const cancelRun = async (run: AgentAutomationRun) => { + try { + await agentAutomationService.cancelRun(run.run_id); + message.success(t("agentAutomation.page.cancelRunSuccess")); + if (selectedTask) { + setRuns(await agentAutomationService.runs(selectedTask.task_id)); + await loadTasks(); + } + } catch (error: unknown) { + message.error( + getAutomationErrorMessage( + error, + t, + "agentAutomation.page.cancelRunFailed" + ) + ); + } + }; + + const confirmDeleteRun = (run: AgentAutomationRun) => { + Modal.confirm({ + title: t("agentAutomation.page.deleteRunTitle"), + content: t("agentAutomation.page.deleteRunDescription"), + okText: t("agentAutomation.page.deleteRunConfirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await agentAutomationService.deleteRun(run.run_id); + message.success(t("agentAutomation.page.deleteRunSuccess")); + if (selectedTask) { + setRuns(await agentAutomationService.runs(selectedTask.task_id)); + await loadTasks(); + } + } catch (error: unknown) { + message.error( + getAutomationErrorMessage( + error, + t, + "agentAutomation.page.deleteRunFailed" + ) + ); + } + }, + }); + }; + + const runTask = async (task: AgentAutomationTask) => { + try { + await agentAutomationService.run(task.task_id); + message.success(t("agentAutomation.page.runSuccess")); + await loadTasks(); + } catch (error) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.runFailed") + ); + } + }; + + const pauseTask = async (task: AgentAutomationTask) => { + try { + await agentAutomationService.pause(task.task_id); + message.success(t("agentAutomation.page.pauseSuccess")); + await loadTasks(); + } catch (error) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.pauseFailed") + ); + } + }; + + const resumeTask = async (task: AgentAutomationTask) => { + try { + await agentAutomationService.resume(task.task_id); + message.success(t("agentAutomation.page.resumeSuccess")); + await loadTasks(); + } catch (error) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.resumeFailed") + ); + } + }; + + const deleteTask = async (task: AgentAutomationTask) => { + try { + await agentAutomationService.delete(task.task_id); + message.success(t("agentAutomation.page.deleteSuccess")); + await loadTasks(); + } catch (error) { + message.error( + getAutomationErrorMessage(error, t, "agentAutomation.page.deleteFailed") + ); + throw error; + } + }; + + const confirmDeleteTask = (task: AgentAutomationTask) => { + Modal.confirm({ + title: t("agentAutomation.page.deleteTitle"), + content: t("agentAutomation.page.deleteDescription"), + okText: t("agentAutomation.page.delete"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: () => deleteTask(task), + }); + }; + + const getMoreActionItems = ( + task: AgentAutomationTask + ): MenuProps["items"] => [ + { + key: "history", + icon: , + label: t("agentAutomation.page.history"), + onClick: () => openRuns(task), + }, + { + key: "edit", + icon: , + label: t("agentAutomation.page.edit"), + onClick: () => openEdit(task), + }, + { type: "divider" }, + { + key: "delete", + danger: true, + icon: , + label: t("agentAutomation.page.delete"), + onClick: () => confirmDeleteTask(task), + }, + ]; + + const columns: ColumnsType = [ + { + title: t("agentAutomation.page.task"), + dataIndex: "title", + filteredValue: taskNameSearch ? [taskNameSearch] : null, + filterIcon: (filtered) => ( + + ), + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, close }) => ( + event.stopPropagation()} + > + + setSelectedKeys(event.target.value ? [event.target.value] : []) + } + onPressEnter={() => confirm()} + /> + + { + setSelectedKeys([]); + confirm(); + }} + > + {t("agentAutomation.page.reset")} + + close()}> + {t("common.cancel")} + + } + onClick={() => confirm()} + > + {t("agentAutomation.page.search")} + + + + ), + render: (_, task) => ( + + + {task.title} + + + {t("agentAutomation.page.agentConversation", { + agentName: + task.agent_name || + t("agentAutomation.page.agentFallback", { + agentId: task.agent_id, + }), + agentId: task.agent_id, + conversationId: task.conversation_id, + })} + + + ), + }, + { + title: t("agentAutomation.page.status"), + dataIndex: "status", + width: 140, + filters: [ + "DRAFT", + "ACTIVE", + "PAUSED", + "PAUSED_BY_SYSTEM", + "COMPLETED", + ].map((status) => ({ text: formatTaskStatus(status), value: status })), + filteredValue: statusFilter ? [statusFilter] : null, + filterMultiple: false, + render: (status) => ( + + {formatTaskStatus(status)} + + ), + }, + { + title: t("agentAutomation.page.schedule"), + width: 180, + render: (_, task) => ( + + + {task.schedule_mode === "ONCE" + ? t("agentAutomation.page.once") + : t("agentAutomation.page.recurring")} + + + {formatScheduleDetail(task)} + + + ), + }, + { + title: t("agentAutomation.page.nextFireAt"), + dataIndex: "next_fire_at", + width: 220, + render: (value) => formatDateTime(value), + }, + { + title: t("agentAutomation.page.lastResult"), + width: 180, + render: (_, task) => ( + + {formatRunStatus(task.last_run_status)} + {task.last_error && ( + + {task.last_error} + + )} + + ), + }, + { + title: t("agentAutomation.page.actions"), + width: 280, + render: (_, task) => ( + + } + onClick={() => runTask(task)} + > + {t("agentAutomation.page.run")} + + {task.status === "ACTIVE" ? ( + } + onClick={() => pauseTask(task)} + > + {t("agentAutomation.page.pause")} + + ) : ["PAUSED", "PAUSED_BY_SYSTEM"].includes(task.status) ? ( + } + onClick={() => resumeTask(task)} + > + {t("agentAutomation.page.resume")} + + ) : null} + + } + title={t("agentAutomation.page.moreActions")} + aria-label={t("agentAutomation.page.moreActions")} + /> + + + ), + }, + ]; + + return ( + + + + + + + {t("agentAutomation.page.title")} + + + {t("agentAutomation.page.subtitle")} + + + + + } onClick={loadTasks}> + {t("common.refresh")} + + } + onClick={() => router.push(`/${params.locale}/chat`)} + > + {t("agentAutomation.page.createInChat")} + + + + + + + { + setModalOpen(false); + setEditingTask(null); + }} + onOk={submitTask} + okText={t("common.save")} + cancelText={t("common.cancel")} + width={720} + > + + + + + + + + + + + + + + + prev.mode !== cur.mode || prev.rule_type !== cur.rule_type + } + > + {({ getFieldValue }) => ( + <> + + + + {getFieldValue("mode") === "RECURRING" && ( + <> + + + + {getFieldValue("rule_type") === "INTERVAL" ? ( + + + + ) : ( + + + + )} + > + )} + > + )} + + + + + + + + setHistoryOpen(false)} + width={720} + > + formatRunStatus(value), + }, + { + title: t("agentAutomation.page.trigger"), + dataIndex: "trigger_type", + width: 120, + render: (value) => formatTriggerType(value), + }, + { + title: t("agentAutomation.page.scheduledAt"), + dataIndex: "scheduled_fire_at", + render: (value) => formatDateTime(value), + }, + { + title: t("agentAutomation.page.errorLog"), + dataIndex: "error_message", + render: (value) => value || "-", + }, + { + title: t("agentAutomation.page.actions"), + width: 130, + render: (_, run) => { + const isActive = ["QUEUED", "RUNNING"].includes(run.status); + return isActive ? ( + } + onClick={() => cancelRun(run)} + > + {t("agentAutomation.page.cancelRun")} + + ) : ( + } + aria-label={t("agentAutomation.page.deleteRun")} + title={t("agentAutomation.page.deleteRun")} + onClick={() => confirmDeleteRun(run)} + /> + ); + }, + }, + ]} + locale={{ emptyText: t("agentAutomation.page.noRuns") }} + /> + + + ); +} diff --git a/frontend/app/[locale]/chat/components/chatHeader.tsx b/frontend/app/[locale]/chat/components/chatHeader.tsx index adee568dd..4f8bfe46b 100644 --- a/frontend/app/[locale]/chat/components/chatHeader.tsx +++ b/frontend/app/[locale]/chat/components/chatHeader.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Button, Tooltip } from "antd"; -import { Share2, X } from "lucide-react"; +import { CalendarClock, Share2, X } from "lucide-react"; import { loadMemoryConfig, setMemorySwitch } from "@/services/memoryService"; import { useConfig } from "@/hooks/useConfig"; import log from "@/lib/logger"; @@ -19,6 +19,7 @@ interface ChatHeaderProps { onRename?: (newTitle: string) => void; onShareClick?: () => void; isShareMode?: boolean; + hasAutomation?: boolean; } export function ChatHeader({ @@ -26,6 +27,7 @@ export function ChatHeader({ onRename, onShareClick, isShareMode = false, + hasAutomation = false, }: ChatHeaderProps) { const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(title); @@ -141,13 +143,20 @@ export function ChatHeader({ autoFocus /> ) : ( - - {title} - + + + {title} + + {hasAutomation && ( + + + + )} + )} {onShareClick && ( diff --git a/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx b/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx index 6ef06e023..ece1f5ba7 100644 --- a/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx +++ b/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from "react"; import { Clock, + CalendarClock, Plus, Pencil, Trash2, @@ -13,9 +14,8 @@ import { Button, Dropdown, Input, Layout, Tooltip, message } from "antd"; import { useTranslation } from "react-i18next"; import { useConfirmModal } from "@/hooks/useConfirmModal"; import { conversationService } from "@/services/conversationService"; -import { - type ConversationManagement, -} from "@/hooks/chat/useConversationManagement"; +import { hasAutomationForConversation } from "@/features/agentAutomation/chatAdapter"; +import { type ConversationManagement } from "@/hooks/chat/useConversationManagement"; import { ConversationListItem, SettingsMenuItem } from "@/types/chat"; import log from "@/lib/logger"; @@ -89,7 +89,10 @@ export interface ChatSidebarProps { completedConversations: Set; conversationManagement: ConversationManagement; /** Called when user clicks a conversation - loads messages and updates selection */ - onConversationSelect: (conversation: ConversationListItem) => void | Promise; + onConversationSelect: ( + conversation: ConversationListItem + ) => void | Promise; + automationConversationIds?: Set; } const CONVERSATION_TITLE_MAX_LENGTH = 100; @@ -99,6 +102,7 @@ export function ChatSidebar({ completedConversations, conversationManagement, onConversationSelect, + automationConversationIds = new Set(), }: ChatSidebarProps) { const { t } = useTranslation(); const { confirm } = useConfirmModal(); @@ -177,16 +181,26 @@ export function ChatSidebar({ }; // Handle delete - const handleDelete = (conversationId: number) => { + const handleDelete = async (conversationId: number) => { + let hasAutomation = false; + try { + hasAutomation = await hasAutomationForConversation(conversationId); + } catch (error) { + log.warn("Failed to check conversation automation before delete", error); + } confirm({ title: t("chatLeftSidebar.confirmDeletionTitle"), - content: t("chatLeftSidebar.confirmDeletionDescription"), + content: hasAutomation + ? t("chatLeftSidebar.confirmDeletionWithAutomationDescription") + : t("chatLeftSidebar.confirmDeletionDescription"), onOk: async () => { try { await conversationService.delete(conversationId); await conversationManagement.fetchConversationList(); - if (conversationManagement.selectedConversationId === conversationId) { + if ( + conversationManagement.selectedConversationId === conversationId + ) { conversationManagement.setSelectedConversationId(null); conversationManagement.setConversationTitle( t("chatInterface.newConversation") @@ -201,14 +215,15 @@ export function ChatSidebar({ }; // Render dialog list items - const renderConversationList = (conversation: ConversationListItem[], title: string) => { + const renderConversationList = ( + conversation: ConversationListItem[], + title: string + ) => { if (conversation.length === 0) return null; return ( - + {title} {conversation.map((conversation) => { @@ -223,117 +238,134 @@ export function ChatSidebar({ : "hover:bg-slate-100" }`} > - - - {conversation.conversation_title} - - ) : null} - placement="bottom" - > - { - if (!isEditing) { - onConversationSelect(conversation); - } - }} + + + {conversation.conversation_title} + + ) : null + } + placement="bottom" > - { + if (!isEditing) { + onConversationSelect(conversation); + } + }} + > + + {automationConversationIds.has( conversation.conversation_id + ) && ( + + + )} - /> - - {isEditing ? ( - { - const nextValue = event.target.value; - setRenameValue(nextValue); - setRenameError(validateRenameTitle(nextValue)); - }} - onPressEnter={() => handleRenameSubmit(conversation.conversation_id)} - onBlur={() => handleRenameSubmit(conversation.conversation_id)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - handleRenameCancel(); + + {isEditing ? ( + { + const nextValue = event.target.value; + setRenameValue(nextValue); + setRenameError(validateRenameTitle(nextValue)); + }} + onPressEnter={() => + handleRenameSubmit(conversation.conversation_id) } - }} - onClick={(event) => event.stopPropagation()} - className="ml-0.5 flex-1 min-w-0 !h-8 !leading-8 !py-0 !text-base whitespace-nowrap" - /> - ) : ( - - {conversation.conversation_title} - - )} - + onBlur={() => + handleRenameSubmit(conversation.conversation_id) + } + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + handleRenameCancel(); + } + }} + onClick={(event) => event.stopPropagation()} + className="ml-0.5 flex-1 min-w-0 !h-8 !leading-8 !py-0 !text-base whitespace-nowrap" + /> + ) : ( + + {conversation.conversation_title} + + )} + + + - - - - setOpenDropdownId(open ? conversation.conversation_id : null)} - menu={{ - items: [ - { - key: "rename", - label: ( - - - {t("chatLeftSidebar.rename")} - - ), - }, - { - key: "delete", - label: ( - - - {t("chatLeftSidebar.delete")} - - ), - }, - ], - onClick: ({ key }) => { - if (key === "rename") { - handleRenameClick( - conversation.conversation_id, - conversation.conversation_title - ); - } else if (key === "delete") { - handleDelete(conversation.conversation_id); - } - }, - }} - placement="bottomRight" - trigger={["click"]} - > - - - - - + + setOpenDropdownId( + open ? conversation.conversation_id : null + ) + } + menu={{ + items: [ + { + key: "rename", + label: ( + + + {t("chatLeftSidebar.rename")} + + ), + }, + { + key: "delete", + label: ( + + + {t("chatLeftSidebar.delete")} + + ), + }, + ], + onClick: ({ key }) => { + if (key === "rename") { + handleRenameClick( + conversation.conversation_id, + conversation.conversation_title + ); + } else if (key === "delete") { + handleDelete(conversation.conversation_id); + } + }, + }} + placement="bottomRight" + trigger={["click"]} + > + + + + + ); })} @@ -361,7 +393,10 @@ export function ChatSidebar({ {/* New conversation button */} - + {!collapsed ? ( - - + + + + + + {t("chatLeftSidebar.newConversation")} + + + - - - {t("chatLeftSidebar.newConversation")} - + - - - - - - + + - - - - {conversationManagement.conversationList.length > 0 ? - ( - <> - {renderConversationList(today, t("chatLeftSidebar.today"))} - {renderConversationList(week, t("chatLeftSidebar.last7Days"))} - {renderConversationList(older, t("chatLeftSidebar.older"))} - > - ) : ( - - - {t("chatLeftSidebar.recentConversations")} - - - - {t("chatLeftSidebar.noHistory")} - - - )} - + + + + {conversationManagement.conversationList.length > 0 ? ( + <> + {renderConversationList(today, t("chatLeftSidebar.today"))} + {renderConversationList( + week, + t("chatLeftSidebar.last7Days") + )} + {renderConversationList(older, t("chatLeftSidebar.older"))} + > + ) : ( + + + {t("chatLeftSidebar.recentConversations")} + + + + {t("chatLeftSidebar.noHistory")} + + + )} - ) : ( - renderCollapsedSidebar() - )} + + ) : ( + renderCollapsedSidebar() + )}
+ {t("agentAutomation.page.subtitle")} +
+
{title}
- {t("chatLeftSidebar.recentConversations")} -
+ {t("chatLeftSidebar.recentConversations")} +