From c4f86a73c8c6e14109f175ad7a734206456ba893 Mon Sep 17 00:00:00 2001 From: Dallas98 <990259227@qq.com> Date: Mon, 13 Jul 2026 10:02:40 +0800 Subject: [PATCH 1/7] feat: implement agent automation features including task management and proposal handling --- backend/apps/agent_automation_app.py | 185 ++++ backend/apps/runtime_app.py | 17 + backend/consts/const.py | 20 + backend/database/agent_automation_db.py | 400 +++++++++ backend/database/db_models.py | 127 +++ backend/prompts/agent_automation_en.yaml | 38 + backend/prompts/agent_automation_zh.yaml | 52 ++ backend/pyproject.toml | 1 + backend/services/agent_automation/__init__.py | 1 + .../agent_automation/capability_resolver.py | 185 ++++ .../agent_automation/conversation_adapter.py | 82 ++ backend/services/agent_automation/errors.py | 33 + backend/services/agent_automation/facade.py | 497 ++++++++++ .../agent_automation/intent_parser.py | 172 ++++ backend/services/agent_automation/models.py | 146 +++ .../agent_automation/prompt_generator.py | 217 +++++ backend/services/agent_automation/runner.py | 322 +++++++ .../agent_automation/schedule_engine.py | 113 +++ .../services/agent_automation/scheduler.py | 122 +++ backend/services/agent_service.py | 84 ++ .../conversation_management_service.py | 10 + backend/utils/prompt_template_utils.py | 4 + deploy/sql/init.sql | 142 +++ .../v_next_add_agent_automation.sql | 112 +++ .../v_next_add_agent_automation_nav.sql | 37 + .../backend/agent-automation-task-design.md | 849 ++++++++++++++++++ .../components/AutomationProposalCard.tsx | 91 ++ frontend/app/[locale]/agent-tasks/page.tsx | 474 ++++++++++ .../[locale]/chat/components/chatHeader.tsx | 27 +- .../chat/components/chatLeftSidebar.tsx | 377 ++++---- .../[locale]/chat/internal/chatInterface.tsx | 279 ++++-- .../chat/streaming/chatStreamFinalMessage.tsx | 75 +- .../components/navigation/SideNavigation.tsx | 31 +- frontend/lib/chatMessageExtractor.ts | 24 +- frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + frontend/server.js | 23 +- frontend/services/agentAutomationService.ts | 143 +++ frontend/services/api.ts | 20 + frontend/types/agentAutomation.ts | 104 +++ frontend/types/chat.ts | 2 + test/backend/app/test_agent_automation_app.py | 115 +++ .../database/test_agent_automation_db.py | 69 ++ ...t_agent_automation_conversation_adapter.py | 69 ++ ...test_agent_automation_conversation_hook.py | 30 + .../services/test_agent_automation_facade.py | 522 +++++++++++ .../test_agent_automation_intent_parser.py | 66 ++ .../test_agent_automation_lifecycle.py | 250 ++++++ .../test_agent_automation_prompt_generator.py | 53 ++ .../services/test_agent_automation_runner.py | 498 ++++++++++ .../test_agent_automation_schedule_engine.py | 90 ++ .../test_agent_automation_scheduler.py | 60 ++ test/backend/services/test_agent_service.py | 39 + 53 files changed, 7225 insertions(+), 278 deletions(-) create mode 100644 backend/apps/agent_automation_app.py create mode 100644 backend/database/agent_automation_db.py create mode 100644 backend/prompts/agent_automation_en.yaml create mode 100644 backend/prompts/agent_automation_zh.yaml create mode 100644 backend/services/agent_automation/__init__.py create mode 100644 backend/services/agent_automation/capability_resolver.py create mode 100644 backend/services/agent_automation/conversation_adapter.py create mode 100644 backend/services/agent_automation/errors.py create mode 100644 backend/services/agent_automation/facade.py create mode 100644 backend/services/agent_automation/intent_parser.py create mode 100644 backend/services/agent_automation/models.py create mode 100644 backend/services/agent_automation/prompt_generator.py create mode 100644 backend/services/agent_automation/runner.py create mode 100644 backend/services/agent_automation/schedule_engine.py create mode 100644 backend/services/agent_automation/scheduler.py create mode 100644 deploy/sql/migrations/v_next_add_agent_automation.sql create mode 100644 deploy/sql/migrations/v_next_add_agent_automation_nav.sql create mode 100644 doc/docs/zh/backend/agent-automation-task-design.md create mode 100644 frontend/app/[locale]/agent-tasks/components/AutomationProposalCard.tsx create mode 100644 frontend/app/[locale]/agent-tasks/page.tsx create mode 100644 frontend/services/agentAutomationService.ts create mode 100644 frontend/types/agentAutomation.ts create mode 100644 test/backend/app/test_agent_automation_app.py create mode 100644 test/backend/database/test_agent_automation_db.py create mode 100644 test/backend/services/test_agent_automation_conversation_adapter.py create mode 100644 test/backend/services/test_agent_automation_conversation_hook.py create mode 100644 test/backend/services/test_agent_automation_facade.py create mode 100644 test/backend/services/test_agent_automation_intent_parser.py create mode 100644 test/backend/services/test_agent_automation_lifecycle.py create mode 100644 test/backend/services/test_agent_automation_prompt_generator.py create mode 100644 test/backend/services/test_agent_automation_runner.py create mode 100644 test/backend/services/test_agent_automation_schedule_engine.py create mode 100644 test/backend/services/test_agent_automation_scheduler.py diff --git a/backend/apps/agent_automation_app.py b/backend/apps/agent_automation_app.py new file mode 100644 index 000000000..e5503d5e4 --- /dev/null +++ b/backend/apps/agent_automation_app.py @@ -0,0 +1,185 @@ +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, + 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.get("", response_model=AutomationResponse) +async def list_tasks(status: Optional[str] = Query(default=None), 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)) + 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) + + +@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, 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 cf950d109..1259c3f99 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -46,6 +46,26 @@ 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", "30") +) +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_MIN_INTERVAL_SECONDS = int( + os.getenv("AGENT_AUTOMATION_MIN_INTERVAL_SECONDS", "300") +) + # 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..8a336d3d5 --- /dev/null +++ b/backend/database/agent_automation_db.py @@ -0,0 +1,400 @@ +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import desc, 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 list_tasks(tenant_id: str, user_id: str, status: 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) + 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 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 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 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: int) -> List[Dict[str, Any]]: + sql = text(""" + UPDATE nexent.agent_automation_task_t + SET lock_owner = :instance_id, + lock_until = now() + (:lease_seconds * interval '1 second'), + update_time = now() + WHERE task_id IN ( + 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 + ) + RETURNING * + """) + 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: int) -> bool: + with get_db_session() as session: + result = session.execute( + update(AgentAutomationTask) + .where( + AgentAutomationTask.task_id == task_id, + AgentAutomationTask.lock_owner == lock_owner, + AgentAutomationTask.delete_flag == "N", + AgentAutomationTask.status == "ACTIVE", + ) + .values( + lock_until=_utcnow() + timedelta(seconds=lease_seconds), + update_time=_utcnow(), + ) + ) + return bool(result.rowcount) + + +def recover_stale_runs(timeout_seconds: int) -> int: + sql = text(""" + UPDATE nexent.agent_automation_run_t + SET status = 'TIMEOUT', + error_code = 'AUTOMATION_RUN_TIMEOUT', + error_message = 'Automation run timed out during runtime recovery.', + finished_at = now(), + update_time = now() + WHERE delete_flag = 'N' + AND status = 'RUNNING' + AND started_at < now() - (:timeout_seconds * interval '1 second') + """) + with get_db_session() as session: + result = session.execute(sql, {"timeout_seconds": timeout_seconds}) + 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 < _utcnow(), + ) + .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 3c078d9b4..b6bfe6c5d 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -94,6 +94,133 @@ 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'"), + ), + {"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..bf58aa8c4 --- /dev/null +++ b/backend/prompts/agent_automation_en.yaml @@ -0,0 +1,38 @@ +INSTRUCTION_SYSTEM_PROMPT: |- + You design execution-ready prompts for scheduled Agent tasks. Rewrite the user's brief intent as a direct instruction for the selected Agent. + Return only the instruction. Preserve the goal, omit scheduling details, use only real listed capabilities, and keep it under 300 words. + +INSTRUCTION_USER_PROMPT: |- + Original task: {{ instruction }} + Agent name: {{ agent_name }} + Agent responsibility: {{ agent_description or "Not provided" }} + Bound capabilities: + {{ capability_summary }} + +FALLBACK_INSTRUCTION_PROMPT: |- + Act within the responsibilities of "{{ agent_name }}" ({{ agent_description or "No responsibility description provided" }}) and use the current conversation context and configured capabilities to complete this task: {{ instruction }}. Provide a clear, verifiable result and explicitly report missing information or capabilities instead of fabricating them. + +EXECUTION_SYSTEM_PROMPT: |- + Generate an execution-ready prompt for this scheduled Agent run. Return only the final prompt. Preserve the task goal, use the Agent context and real capabilities, define an appropriate output, and never invent data or tools. Keep it under 500 words. + +EXECUTION_USER_PROMPT: |- + Task title: {{ title }} + Base instruction: {{ instruction }} + Agent name: {{ agent_name }} + Agent responsibility: {{ agent_description or "Not provided" }} + Scheduled time: {{ scheduled_fire_at }} + Timezone: {{ timezone }} + Trigger type: {{ trigger_type }} + Bound capabilities: + {{ capability_summary }} + Recent conversation context: + {{ conversation_context or "No recent messages are available." }} + +FALLBACK_EXECUTION_PROMPT: |- + Execute the scheduled task "{{ title }}" in this conversation. Act within the responsibilities of "{{ agent_name }}" ({{ agent_description or "No responsibility description provided" }}) and use the current conversation context and configured capabilities to complete: {{ instruction }} + + Scheduled time: {{ scheduled_fire_at }} ({{ timezone }}) + Bound capabilities: + {{ capability_summary }} + + Return a result appropriate to the task and identify key evidence. Explicitly report missing information or capabilities instead of fabricating them. diff --git a/backend/prompts/agent_automation_zh.yaml b/backend/prompts/agent_automation_zh.yaml new file mode 100644 index 000000000..0259c3561 --- /dev/null +++ b/backend/prompts/agent_automation_zh.yaml @@ -0,0 +1,52 @@ +INSTRUCTION_SYSTEM_PROMPT: |- + 你是一名 Agent 自动任务提示词设计专家。请把用户的简短任务意图改写为可直接交给指定 Agent 执行的中文指令。 + + 要求: + 1. 只输出改写后的任务指令,不要解释,不要使用 Markdown 代码块。 + 2. 保留用户的业务目标,不重复时间、频率等调度信息。 + 3. 结合 Agent 的名称、职责和真实可用能力,不得虚构工具、知识库或数据来源。 + 4. 指令应说明信息依据、输出内容和质量要求;缺少必要信息时要求 Agent 明确说明。 + 5. 内容简洁,最多 300 个汉字。 + +INSTRUCTION_USER_PROMPT: |- + 用户原始任务:{{ instruction }} + Agent 名称:{{ agent_name }} + Agent 职责:{{ agent_description or "未提供" }} + 已绑定能力: + {{ capability_summary }} + +FALLBACK_INSTRUCTION_PROMPT: |- + 请以「{{ agent_name }}」的职责({{ agent_description or "未提供职责描述" }})为准,基于当前会话上下文和已配置的真实能力完成以下任务:{{ instruction }}。请给出清晰、可核验的结果;如果信息或能力不足,请明确说明,不要编造。 + +EXECUTION_SYSTEM_PROMPT: |- + 你是一名 Agent 自动任务提示词优化专家。请为本次定时触发生成一条可直接发送给指定 Agent 的中文提示词。 + + 要求: + 1. 只输出最终提示词,不要解释,不要使用 Markdown 代码块。 + 2. 保持任务原始目标,并结合 Agent 职责、已绑定能力、本次计划时间及当前会话语境。 + 3. 不得虚构工具、知识库、事实或数据来源。 + 4. 提示词应明确本次要完成的工作、信息范围、输出结构和异常处理方式。 + 5. 不要要求固定输出与任务无关的“后续建议”等通用章节。 + 6. 最多 500 个汉字。 + +EXECUTION_USER_PROMPT: |- + 任务名称:{{ title }} + 基础任务指令:{{ instruction }} + Agent 名称:{{ agent_name }} + Agent 职责:{{ agent_description or "未提供" }} + 本次计划时间:{{ scheduled_fire_at }} + 时区:{{ timezone }} + 触发类型:{{ trigger_type }} + 已绑定能力: + {{ capability_summary }} + 最近会话语境: + {{ conversation_context or "当前没有可摘要的历史消息,请根据基础任务指令执行。" }} + +FALLBACK_EXECUTION_PROMPT: |- + 你正在当前会话中执行自动任务「{{ title }}」。请以「{{ agent_name }}」的职责({{ agent_description or "未提供职责描述" }})为准,基于当前会话上下文和已配置的真实能力完成以下任务:{{ instruction }} + + 本次计划时间:{{ scheduled_fire_at }}({{ timezone }}) + 已绑定能力: + {{ capability_summary }} + + 请直接给出与任务目标匹配的结果,并说明关键信息依据;如果信息或能力不足,请明确说明,不要编造。 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/capability_resolver.py b/backend/services/agent_automation/capability_resolver.py new file mode 100644 index 000000000..6f99c13f7 --- /dev/null +++ b/backend/services/agent_automation/capability_resolver.py @@ -0,0 +1,185 @@ +import re +from typing import Any, Dict, Iterable, List, Optional + +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] + + return CapabilityResolution( + matched_capabilities=matched[:20], + missing_capabilities=missing, + agent_snapshot={ + "agent_id": agent_id, + "version_no": version_no, + "name": 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..f8c392433 --- /dev/null +++ b/backend/services/agent_automation/facade.py @@ -0,0 +1,497 @@ +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 .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_parser import parse_automation_intent +from .models import ( + AutomationProposalConfirmRequest, + AutomationProposalCreateRequest, + AutomationProposalStatus, + AutomationRunStatus, + AutomationSource, + AutomationTaskCreateRequest, + AutomationTaskPatchRequest, + AutomationTaskStatus, + CapabilityBinding, + ScheduleTrigger, +) +from .prompt_generator import AutomationPromptContext, automation_prompt_generator +from .schedule_engine import compute_next_fire_at + +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 _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.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]: + parsed = parse_automation_intent(request.message, request.timezone, tenant_id) + 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, + } + + conversation_id = request.conversation_id + if conversation_id is None: + conversation = create_new_conversation( + parsed["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=parsed["instruction"], + version_no=request.agent_version_no or 0, + ) + optimized_instruction = await automation_prompt_generator.optimize_instruction(AutomationPromptContext( + tenant_id=tenant_id, + instruction=parsed["instruction"], + agent_snapshot=resolution.agent_snapshot, + capability_bindings=resolution.model_dump(mode="json")["matched_capabilities"], + title=parsed["title"], + timezone=request.timezone, + )) + proposed_task = { + "title": parsed["title"], + "instruction": optimized_instruction, + "original_instruction": parsed["instruction"], + "agent_id": request.agent_id, + "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"), + } + 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 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("_")} + 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]: + 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"] + + trigger = request.schedule_trigger + _validate_schedule_policy(trigger) + next_fire_at = compute_next_fire_at(trigger, _utcnow(), 0) + runtime_snapshot = { + **resolution.agent_snapshot, + "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 task + + def list_tasks(self, tenant_id: str, user_id: str, status: Optional[str] = None) -> List[Dict[str, Any]]: + return agent_automation_db.list_tasks(tenant_id, user_id, status) + + 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 task + + def get_task_for_conversation(self, conversation_id: int, user_id: str) -> Optional[Dict[str, Any]]: + return agent_automation_db.get_task_by_conversation(conversation_id, user_id) + + 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 updated + + 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 task + + 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 updated + + 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 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_parser.py b/backend/services/agent_automation/intent_parser.py new file mode 100644 index 000000000..80dccbc46 --- /dev/null +++ b/backend/services/agent_automation/intent_parser.py @@ -0,0 +1,172 @@ +import re +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from .models import ScheduleMode, ScheduleRuleType, ScheduleTrigger + + +RECURRING_TOKENS = ("每天", "每日", "每周", "每星期", "每礼拜", "每月", "周期") +EXPLICIT_AUTOMATION_TOKENS = ("定时", "提醒") +RELATIVE_DATE_TOKENS = ("明天", "今天") +ACTION_TOKENS = ( + "帮我", + "请", + "提醒", + "发送", + "发一个", + "发一份", + "生成", + "汇总", + "总结", + "执行", + "运行", + "通知", +) + + +def _has_explicit_time(message: str) -> bool: + return bool( + re.search(r"\d{1,2}(?::|:|点|时)\d{0,2}", message) + or any(period in message for period in ("上午", "中午", "下午", "晚上", "凌晨", "早上")) + ) + + +def _is_automation_intent(message: str) -> bool: + if any(token in message for token in RECURRING_TOKENS + EXPLICIT_AUTOMATION_TOKENS): + return True + return ( + any(token in message for token in RELATIVE_DATE_TOKENS) + and _has_explicit_time(message) + and any(token in message for token in ACTION_TOKENS) + ) + + +def _parse_hour_minute(message: str, default_hour: int = 9) -> tuple[int, int]: + match = re.search(r"(\d{1,2})[::点时](\d{1,2})?", message) + if match: + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + if any(period in message for period in ("下午", "晚上")) and hour < 12: + hour += 12 + elif "中午" in message and hour < 11: + hour += 12 + elif "凌晨" in message and hour == 12: + hour = 0 + if hour > 23 or minute > 59: + raise ValueError("Invalid hour or minute in automation schedule.") + return hour, minute + if "上午" in message or "早上" in message: + return default_hour, 0 + if "下午" in message or "晚上" in message: + return 18, 0 + return default_hour, 0 + + +def _clean_instruction(message: str) -> str: + cleaned = re.sub(r"(?:每)?(?:周|星期|礼拜)[一二三四五六日天]", "", message) + cleaned = re.sub(r"每月\s*\d{1,2}(?:号|日)?", "", cleaned) + cleaned = re.sub( + r"(请|帮我|定时|提醒我|每天|每日|每周|每星期|每礼拜|每月|" + r"明天|今天|上午|中午|下午|晚上|凌晨)", + "", + cleaned, + ) + cleaned = re.sub(r"\d{1,2}[::点时]\d{0,2}", "", cleaned) + cleaned = cleaned.strip(" ,,。") + return cleaned or message + + +def _parse_weekday(message: str, default: int = 1) -> int: + weekday_map = { + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "日": 0, + "天": 0, + } + match = re.search(r"(?:周|星期|礼拜)([一二三四五六日天])", message) + return weekday_map.get(match.group(1), default) if match else default + + +def _parse_month_day(message: str, default: int = 1) -> int: + match = re.search(r"每月\s*(\d{1,2})(?:号|日)?", message) + day = int(match.group(1)) if match else default + if day < 1 or day > 31: + raise ValueError("Invalid day of month in automation schedule.") + return day + + +def parse_automation_intent( + message: str, + timezone_name: str = "Asia/Shanghai", + tenant_id: str | None = None, +) -> dict: + """Parse a natural-language automation request into a conservative proposal.""" + zone = ZoneInfo(timezone_name) + now = datetime.now(zone) + + if not _is_automation_intent(message): + return {"is_automation_intent": False, "confidence": 0.0} + + hour, minute = _parse_hour_minute(message) + instruction = _clean_instruction(message) + title = instruction[:30] if instruction else "自动任务" + + if "每天" in message or "每日" in message: + start_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if start_at <= now: + start_at += timedelta(days=1) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.CRON, + timezone=timezone_name, + start_at=start_at, + cron_expr=f"{minute} {hour} * * *", + ) + elif any(token in message for token in ("每周", "每星期", "每礼拜")): + weekday = _parse_weekday(message) + cron_weekday = weekday + start_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.CRON, + timezone=timezone_name, + start_at=start_at, + cron_expr=f"{minute} {hour} * * {cron_weekday}", + ) + elif "每月" in message: + month_day = _parse_month_day(message) + start_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + trigger = ScheduleTrigger( + mode=ScheduleMode.RECURRING, + rule_type=ScheduleRuleType.CRON, + timezone=timezone_name, + start_at=start_at, + cron_expr=f"{minute} {hour} {month_day} * *", + ) + else: + days = 1 if "明天" in message else 0 + start_at = (now + timedelta(days=days)).replace(hour=hour, minute=minute, second=0, microsecond=0) + if start_at <= now: + start_at += timedelta(days=1) + trigger = ScheduleTrigger( + mode=ScheduleMode.ONCE, + rule_type=ScheduleRuleType.AT, + timezone=timezone_name, + start_at=start_at, + max_fire_count=1, + ) + + base_result = { + "is_automation_intent": True, + "confidence": 0.86, + "title": title, + "instruction": instruction, + "schedule_trigger": trigger, + "capability_intents": [], + "output_requirements": {"language": "zh"}, + } + return base_result diff --git a/backend/services/agent_automation/models.py b/backend/services/agent_automation/models.py new file mode 100644 index 000000000..1c4420e18 --- /dev/null +++ b/backend/services/agent_automation/models.py @@ -0,0 +1,146 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, 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 ScheduleMode(StrEnum): + ONCE = "ONCE" + RECURRING = "RECURRING" + + +class ScheduleRuleType(StrEnum): + AT = "AT" + INTERVAL = "INTERVAL" + CRON = "CRON" + + +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) + + @model_validator(mode="after") + def validate_combination(self): + if self.mode == ScheduleMode.ONCE: + if self.rule_type != ScheduleRuleType.AT: + raise ValueError("ONCE schedule only supports AT rule_type") + 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.INTERVAL and not self.interval_seconds: + raise ValueError("interval_seconds is required for INTERVAL schedules") + 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 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..4870d04bb --- /dev/null +++ b/backend/services/agent_automation/prompt_generator.py @@ -0,0 +1,217 @@ +import asyncio +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, 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") + + +@dataclass(frozen=True) +class AutomationPromptContext: + """Data required to generate a task instruction or a single-run prompt.""" + + tenant_id: str + instruction: str + agent_snapshot: Dict[str, Any] = field(default_factory=dict) + capability_bindings: List[Dict[str, Any]] = field(default_factory=list) + title: str = "" + timezone: str = "Asia/Shanghai" + scheduled_fire_at: Optional[datetime] = None + trigger_type: str = "SCHEDULED" + conversation_context: str = "" + language: str = LANGUAGE["ZH"] + + +def _capability_summary(bindings: List[Dict[str, Any]], language: str) -> str: + if not bindings: + return ( + "当前没有绑定特定能力。" + if language == LANGUAGE["ZH"] + else "No specific capabilities are bound." + ) + + lines = [] + for binding in bindings: + label = binding.get("display_name") or binding.get("name") or binding.get("binding_ref") + lines.append(f"- {binding.get('type', 'CAPABILITY')}: {label}") + return "\n".join(lines) + + +def _normalize_model_output(content: str, fallback: str, max_length: int) -> 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 + return normalized[:max_length].rstrip() + + +class AutomationPromptStrategy(ABC): + """Strategy interface for automation prompt generation.""" + + @abstractmethod + async def optimize_instruction(self, context: AutomationPromptContext) -> str: + raise NotImplementedError + + @abstractmethod + async def generate_execution_prompt(self, context: AutomationPromptContext) -> str: + raise NotImplementedError + + +class TemplateAutomationPromptStrategy(AutomationPromptStrategy): + """Deterministic and fail-open prompt generation strategy.""" + + def _render(self, context: AutomationPromptContext, template_key: str) -> str: + prompt_template = get_prompt_template("agent_automation", context.language) + agent_name = context.agent_snapshot.get("name") or f"Agent #{context.agent_snapshot.get('agent_id', '')}" + values = { + "title": context.title or context.instruction[:30], + "instruction": context.instruction.strip(), + "agent_name": agent_name, + "agent_description": context.agent_snapshot.get("description", ""), + "capability_summary": _capability_summary(context.capability_bindings, context.language), + "scheduled_fire_at": context.scheduled_fire_at.isoformat() if context.scheduled_fire_at else "", + "timezone": context.timezone, + "trigger_type": context.trigger_type, + "conversation_context": context.conversation_context, + } + return Template(prompt_template[template_key], undefined=StrictUndefined).render(**values).strip() + + async def optimize_instruction(self, context: AutomationPromptContext) -> str: + return self._render(context, "FALLBACK_INSTRUCTION_PROMPT") + + async def generate_execution_prompt(self, context: AutomationPromptContext) -> str: + return self._render(context, "FALLBACK_EXECUTION_PROMPT") + + +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 optimize_instruction(self, context: AutomationPromptContext) -> str: + fallback = await self._fallback.optimize_instruction(context) + return await self._generate( + context, + system_key="INSTRUCTION_SYSTEM_PROMPT", + user_key="INSTRUCTION_USER_PROMPT", + fallback=fallback, + max_length=1200, + ) + + async def generate_execution_prompt(self, context: AutomationPromptContext) -> str: + fallback = await self._fallback.generate_execution_prompt(context) + return await self._generate( + context, + system_key="EXECUTION_SYSTEM_PROMPT", + user_key="EXECUTION_USER_PROMPT", + fallback=fallback, + max_length=2000, + ) + + async def _generate( + self, + context: AutomationPromptContext, + system_key: str, + user_key: str, + fallback: str, + max_length: int, + ) -> str: + try: + return await asyncio.to_thread( + self._generate_sync, + context, + system_key, + user_key, + fallback, + max_length, + ) + except Exception as exc: + logger.warning("Failed to optimize agent automation prompt, using template fallback: %s", exc) + return fallback + + def _generate_sync( + self, + context: AutomationPromptContext, + system_key: str, + user_key: str, + fallback: str, + max_length: int, + ) -> 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) + agent_name = context.agent_snapshot.get("name") or f"Agent #{context.agent_snapshot.get('agent_id', '')}" + values = { + "title": context.title or context.instruction[:30], + "instruction": context.instruction.strip(), + "agent_name": agent_name, + "agent_description": context.agent_snapshot.get("description", ""), + "capability_summary": _capability_summary(context.capability_bindings, context.language), + "scheduled_fire_at": context.scheduled_fire_at.isoformat() if context.scheduled_fire_at else "", + "timezone": context.timezone, + "trigger_type": context.trigger_type, + "conversation_context": context.conversation_context, + } + 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 _normalize_model_output(getattr(response, "content", "") or "", fallback, max_length) + + +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 optimize_instruction(self, context: AutomationPromptContext) -> str: + return await self._factory.create(context.tenant_id).optimize_instruction(context) + + async def generate_execution_prompt(self, context: AutomationPromptContext) -> str: + return await self._factory.create(context.tenant_id).generate_execution_prompt(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..0d4ba239b --- /dev/null +++ b/backend/services/agent_automation/runner.py @@ -0,0 +1,322 @@ +import asyncio +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 .prompt_generator import AutomationPromptContext, automation_prompt_generator +from .schedule_engine import compute_next_fire_at + + +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 + + +def _conversation_context(history_payload: List[Dict[str, Any]], max_messages: int = 6, max_chars: int = 4000) -> str: + if not history_payload: + return "" + lines = [] + for message in history_payload[0].get("message", [])[-max_messages:]: + content = _message_content(message).strip() + if content: + lines.append(f"{message.get('role', 'user')}: {content}") + return "\n".join(lines)[-max_chars:] + + +class AgentAutomationRunner: + async def execute_task( + self, + task: Dict[str, Any], + trigger_type: str = "SCHEDULED", + scheduled_fire_at: Optional[datetime] = 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) + agent_automation_db.update_task(task["task_id"], task["tenant_id"], task["user_id"], { + "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, + }) + 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"]) + + 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 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"] + ), + } + current_capability_bindings = ( + current_resolution.get("matched_capabilities") + or task.get("capability_bindings") + or [] + ) + generated_prompt = await automation_prompt_generator.generate_execution_prompt(AutomationPromptContext( + tenant_id=task["tenant_id"], + instruction=runtime_snapshot["original_instruction"], + agent_snapshot=runtime_snapshot, + capability_bindings=current_capability_bindings, + title=task["title"], + timezone=task.get("timezone", "Asia/Shanghai"), + scheduled_fire_at=scheduled, + trigger_type=trigger_type, + conversation_context=_conversation_context(history_payload), + )) + 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"] + + agent_automation_db.update_task(task["task_id"], task["tenant_id"], task["user_id"], { + "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, + }) + return updated_run or run + + @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..56e89715b --- /dev/null +++ b/backend/services/agent_automation/schedule_engine.py @@ -0,0 +1,113 @@ +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +try: + from croniter import croniter +except ImportError: # pragma: no cover - dependency fallback for isolated tests + croniter = None + +from .models import ScheduleMode, ScheduleRuleType, ScheduleTrigger + + +def _ensure_aware(value: datetime, timezone_name: str) -> datetime: + zone = ZoneInfo(timezone_name) + if value.tzinfo is None: + return value.replace(tzinfo=zone) + return value.astimezone(zone) + + +def compute_next_fire_at( + trigger: ScheduleTrigger, + after: datetime, + fire_count: int, +) -> datetime | None: + """Compute the next fire time for both one-shot and recurring schedules.""" + local_after = _ensure_aware(after, trigger.timezone) + start_at = _ensure_aware(trigger.start_at, trigger.timezone) + end_at = _ensure_aware(trigger.end_at, trigger.timezone) if trigger.end_at else None + + if trigger.max_fire_count is not None and fire_count >= trigger.max_fire_count: + return None + + if trigger.mode == ScheduleMode.ONCE: + if fire_count > 0: + return None + next_fire = start_at if start_at >= local_after else local_after + elif trigger.rule_type == ScheduleRuleType.INTERVAL: + if local_after <= start_at: + next_fire = start_at + else: + elapsed = (local_after - start_at).total_seconds() + steps = int(elapsed // trigger.interval_seconds) + 1 + next_fire = start_at + timedelta(seconds=steps * trigger.interval_seconds) + elif trigger.rule_type == ScheduleRuleType.CRON: + base = max(local_after, start_at) + if local_after <= start_at and _cron_matches_start(trigger.cron_expr, start_at): + next_fire = start_at + elif croniter is not None: + next_fire = croniter(trigger.cron_expr, base).get_next(datetime) + if next_fire < start_at: + next_fire = croniter(trigger.cron_expr, start_at).get_next(datetime) + else: + next_fire = _fallback_next_cron(trigger.cron_expr, base) + else: + raise ValueError(f"Unsupported schedule combination: {trigger.mode}/{trigger.rule_type}") + + if end_at and next_fire > end_at: + return None + return next_fire.astimezone(timezone.utc) + + +def _cron_matches_start(expr: str, start_at: datetime) -> bool: + parts = (expr or "").split() + if len(parts) != 5: + if croniter is not None: + try: + return croniter.match(expr, start_at) + except Exception: + return False + return False + + minute, hour, day_of_month, month, day_of_week = parts + checks = ( + _cron_field_matches(minute, start_at.minute), + _cron_field_matches(hour, start_at.hour), + _cron_field_matches(day_of_month, start_at.day), + _cron_field_matches(month, start_at.month), + _cron_weekday_matches(day_of_week, start_at), + ) + return all(checks) + + +def _cron_field_matches(field: str, value: int) -> bool: + return field == "*" or (field.isdigit() and int(field) == value) + + +def _cron_weekday_matches(field: str, value: datetime) -> bool: + if field == "*": + return True + if not field.isdigit(): + return False + return (int(field) - 1) % 7 == value.weekday() + + +def _fallback_next_cron(expr: str, base: datetime) -> datetime: + """Fallback for simple five-field cron expressions generated by v1 UI.""" + parts = (expr or "").split() + if len(parts) != 5: + raise ValueError("croniter is required for complex cron expressions") + minute, hour, day_of_month, month, day_of_week = parts + if not minute.isdigit() or not hour.isdigit(): + raise ValueError("croniter is required for non-numeric minute/hour cron expressions") + target = base.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0) + if target <= base: + target += timedelta(days=1) + for _ in range(366 * 5): + if ( + _cron_field_matches(day_of_month, target.day) + and _cron_field_matches(month, target.month) + and _cron_weekday_matches(day_of_week, target) + ): + return target + target += timedelta(days=1) + raise ValueError("Unable to find the next fire time for cron expression") diff --git a/backend/services/agent_automation/scheduler.py b/backend/services/agent_automation/scheduler.py new file mode 100644 index 000000000..ef1a75363 --- /dev/null +++ b/backend/services/agent_automation/scheduler.py @@ -0,0 +1,122 @@ +import asyncio +import logging +import socket +import uuid +from typing import Optional, Set + +from consts.const import ( + AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS, + AGENT_AUTOMATION_ENABLED, + AGENT_AUTOMATION_LEASE_SECONDS, + AGENT_AUTOMATION_MAX_CONCURRENT_RUNS, + AGENT_AUTOMATION_POLL_INTERVAL_SECONDS, +) +from database import agent_automation_db + +from .runner import agent_automation_runner + +logger = logging.getLogger("agent_automation.scheduler") + + +class AgentAutomationScheduler: + def __init__(self): + self.instance_id = f"{socket.gethostname()}-{uuid.uuid4()}" + self._task: Optional[asyncio.Task] = None + self._stop_event = asyncio.Event() + self._semaphore = asyncio.Semaphore(AGENT_AUTOMATION_MAX_CONCURRENT_RUNS) + self._running_tasks: Set[asyncio.Task] = set() + + async def start(self): + if not AGENT_AUTOMATION_ENABLED: + logger.info("Agent automation scheduler disabled") + return + if self._task and not self._task.done(): + return + self._stop_event.clear() + agent_automation_db.recover_stale_runs(AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS) + agent_automation_db.release_expired_locks() + self._task = asyncio.create_task(self._loop(), name="agent-automation-scheduler") + logger.info("Agent automation scheduler started: %s", self.instance_id) + + async def stop(self): + self._stop_event.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + if self._running_tasks: + await asyncio.gather(*self._running_tasks, return_exceptions=True) + logger.info("Agent automation scheduler stopped") + + async def _loop(self): + while not self._stop_event.is_set(): + try: + capacity = self._available_capacity() + if capacity > 0: + due_tasks = agent_automation_db.claim_due_tasks( + instance_id=self.instance_id, + batch_size=capacity, + lease_seconds=AGENT_AUTOMATION_LEASE_SECONDS, + ) + for task in due_tasks: + running_task = asyncio.create_task(self._execute_claimed_task(task)) + self._running_tasks.add(running_task) + running_task.add_done_callback(self._running_tasks.discard) + except Exception as exc: + logger.error("Agent automation scheduler tick failed: %s", exc, exc_info=True) + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=AGENT_AUTOMATION_POLL_INTERVAL_SECONDS, + ) + except asyncio.TimeoutError: + pass + + def _available_capacity(self) -> int: + return max(0, getattr(self._semaphore, "_value", 0)) + + async def _execute_claimed_task(self, task: dict): + async with self._semaphore: + lease_stop = asyncio.Event() + lease_task = asyncio.create_task( + self._renew_task_lease(task["task_id"], lease_stop), + name=f"agent-automation-lease-{task['task_id']}", + ) + try: + await agent_automation_runner.execute_task(task, trigger_type="SCHEDULED") + except Exception as exc: + logger.error( + "Automation task execution failed: task_id=%s error=%s", + task.get("task_id"), + exc, + exc_info=True, + ) + agent_automation_db.release_task_lock(task["task_id"], self.instance_id) + finally: + lease_stop.set() + lease_task.cancel() + try: + await lease_task + except asyncio.CancelledError: + pass + + async def _renew_task_lease(self, task_id: int, stop_event: asyncio.Event): + renew_interval = max(0.1, AGENT_AUTOMATION_LEASE_SECONDS / 3) + while not stop_event.is_set(): + try: + await asyncio.wait_for(stop_event.wait(), timeout=renew_interval) + return + except asyncio.TimeoutError: + renewed = agent_automation_db.renew_task_lock( + task_id, + self.instance_id, + AGENT_AUTOMATION_LEASE_SECONDS, + ) + if not renewed: + logger.warning("Automation task lease was lost: task_id=%s", task_id) + return + + +agent_automation_scheduler = AgentAutomationScheduler() diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 1ebd9b0af..8dfcd9f33 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -3198,6 +3198,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. @@ -3226,6 +3306,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 4dba737bf..6a332d441 100644 --- a/deploy/sql/init.sql +++ b/deploy/sql/init.sql @@ -443,3 +443,145 @@ EXECUTE FUNCTION update_ag_tool_instance_update_time(); -- Add comment to the trigger COMMENT ON TRIGGER update_ag_tool_instance_update_time_trigger ON nexent.ag_tool_instance_t IS 'Trigger to call update_ag_tool_instance_update_time function before each update on ag_tool_instance_t table'; + +-- Create agent automation tables +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 INDEX IF NOT EXISTS idx_agent_automation_proposal_owner + ON nexent.agent_automation_proposal_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + +-- Add Agent Automation task page to role-based left navigation. +ALTER TABLE nexent.role_permission_t +ADD COLUMN IF NOT EXISTS parent_key VARCHAR(50); + +-- Keep the serial sequence ahead of rows inserted with explicit IDs by older migrations. +SELECT setval( + pg_get_serial_sequence('nexent.role_permission_t', 'role_permission_id'), + COALESCE((SELECT MAX(role_permission_id) FROM nexent.role_permission_t), 0) + 1, + false +); + +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 ( + VALUES + ('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' +); diff --git a/deploy/sql/migrations/v_next_add_agent_automation.sql b/deploy/sql/migrations/v_next_add_agent_automation.sql new file mode 100644 index 000000000..a9b565ac8 --- /dev/null +++ b/deploy/sql/migrations/v_next_add_agent_automation.sql @@ -0,0 +1,112 @@ +-- Migration: Add agent automation task tables +-- Description: Durable scheduled agent tasks, run history, and chat proposals. + +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 INDEX IF NOT EXISTS idx_agent_automation_proposal_owner + ON nexent.agent_automation_proposal_t (tenant_id, user_id, status) + WHERE delete_flag = 'N'; + +COMMIT; diff --git a/deploy/sql/migrations/v_next_add_agent_automation_nav.sql b/deploy/sql/migrations/v_next_add_agent_automation_nav.sql new file mode 100644 index 000000000..0b53c8286 --- /dev/null +++ b/deploy/sql/migrations/v_next_add_agent_automation_nav.sql @@ -0,0 +1,37 @@ +-- Add Agent Automation task page to role-based left navigation. + +ALTER TABLE nexent.role_permission_t +ADD COLUMN IF NOT EXISTS parent_key VARCHAR(50); + +-- Keep the serial sequence ahead of rows inserted with explicit IDs by older migrations. +SELECT setval( + pg_get_serial_sequence('nexent.role_permission_t', 'role_permission_id'), + COALESCE((SELECT MAX(role_permission_id) FROM nexent.role_permission_t), 0) + 1, + false +); + +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 ( + VALUES + ('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' +); 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..c8677b51d --- /dev/null +++ b/doc/docs/zh/backend/agent-automation-task-design.md @@ -0,0 +1,849 @@ +# 智能体自动化任务设计文档 + +## 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 +``` + +模块职责: + +| 子模块 | 职责 | +| --- | --- | +| `agent_automation_app.py` | HTTP 边界,解析请求、调用 facade、映射异常 | +| `facade.py` | 对外唯一服务入口,屏蔽内部 repository、scheduler、runner | +| `models.py` | 自动化任务领域 DTO、枚举、校验模型 | +| `repository.py` / `agent_automation_db.py` | 自动化任务与运行记录的数据库访问 | +| `schedule_engine.py` | 统一计算一次性与周期性任务的下一次触发时间 | +| `scheduler.py` | 后台轮询、抢占到期任务、lease 续期、恢复异常运行 | +| `runner.py` | 把一次触发转换成会话消息,并调用后台智能体执行入口 | +| `intent_parser.py` | 对话中识别“创建自动任务”的意图,只负责抽取时间、频率、任务目标 | +| `capability_resolver.py` | 基于 Nexent 现有 Agent 配置匹配可用技能、工具、知识库、子 Agent 和 A2A Agent | + +与现有模块的依赖方向: + +```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"] + Scheduler["Automation Scheduler"] --> Auto + 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 不读取环境变量,不新增调度逻辑;自动化任务是 Nexent backend runtime 能力。 +- 自然语言只生成“任务提案”,不能绕过 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 | `optimize_instruction(context)`、`generate_execution_prompt(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/v_next_add_agent_automation.sql`。 +- 同步更新 `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_parser 识别时间、任务指令、一次性/周期性 + 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: 写入任务创建成功卡片 +``` + +自然语言识别规则: + +- 第一阶段用规则识别明显时间表达,例如“明天 9 点”“每天上午 9 点”“每周一”,抽取 `ScheduleTrigger`。 +- 第二阶段用轻量 LLM prompt 把用户话术拆成 `instruction`、`capability_intents`、`output_requirements`,不能直接生成工具调用。 +- 第三阶段由 `CapabilityResolver` 调用现有 Agent 能力装配链路,生成 matched/missing capabilities。 +- 置信度低于 0.75 时不创建 proposal,只让普通 Agent 对话继续。 +- 所有对话创建都必须用户确认,不能由模型直接创建 `ACTIVE` 任务。 +- 如果缺少必要能力,确认卡片展示“需要先启用的技能/工具/知识库”;用户不能直接确认,只能修改任务要求或去配置 Agent。 + +`intent_parser` 输出结构: + +```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 + }, + "capability_intents": [ + { + "type": "KNOWLEDGE_BASE", + "query": "项目文档、项目进展、风险", + "required": true + }, + { + "type": "TOOL", + "query": "读取项目管理系统实时进展", + "required": false + } + ], + "output_requirements": { + "format": "summary_with_risks_and_next_steps", + "language": "zh" + } +} +``` + +`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 +``` + +后台执行提示词由 `AutomationPromptGenerator` 在每次触发时生成: + +- `AutomationPromptStrategyFactory` 根据租户模型配置选择 LLM 策略或模板策略。 +- LLM 策略结合任务指令、每次执行时重新解析到的最新 Agent 名称与职责、最新能力摘要、本次计划时间和最近会话语境生成本次提示词;创建时保存的模型、工具参数和原始要求仍由任务绑定。 +- 模型不可用或生成失败时,自动降级到 YAML 模板策略,保证调度任务可继续执行。 +- 输出结构由任务目标决定,不统一追加与任务无关的固定章节。 + +执行策略: + +- `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` | `30` | 扫描间隔 | +| `AGENT_AUTOMATION_MAX_CONCURRENT_RUNS` | `2` | 单实例并发执行数 | +| `AGENT_AUTOMATION_LEASE_SECONDS` | `120` | 任务抢占 lease | +| `AGENT_AUTOMATION_DEFAULT_TIMEOUT_SECONDS` | `1800` | 默认执行超时 | +| `AGENT_AUTOMATION_MIN_INTERVAL_SECONDS` | `300` | 普通用户最小周期 | + +多实例抢占: + +```sql +UPDATE agent_automation_task_t +SET lock_owner = :instance_id, + lock_until = now() + interval '120 seconds' +WHERE task_id IN ( + 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 +) +RETURNING *; +``` + +启动恢复: + +- 启动时扫描 `RUNNING` 且 `started_at` 超过 timeout 的 run,标记为 `TIMEOUT`。 +- 扫描 `lock_until < now()` 的 task,释放 lock。 +- 对 `ACTIVE` 且 `next_fire_at` 已过期的任务按 `misfire_policy=RUN_ONCE` 补一次。 + +## 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_INVALID_SCHEDULE` | ScheduleTrigger 非法 | +| `AUTOMATION_MIN_INTERVAL_VIOLATED` | 周期低于最小间隔 | +| `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。 + - `end_at`、`max_fire_count` 生效。 +- Repository: + - 创建任务、编辑任务、软删除任务。 + - 同一 conversation 重复创建任务失败。 + - `claim_due_tasks` 在并发场景只返回一次。 +- Facade: + - 直接 `POST /agent/automations` 不可用,任务只能由会话 proposal 创建。 + - 新对话只在识别到自动任务意图后创建,并同时持久化用户指令与 proposal;普通聊天不创建空会话。 + - 并发确认同一会话时,数据库唯一约束竞争映射为 `AUTOMATION_CONVERSATION_ALREADY_BOUND`,不返回 500。 + - 对话 proposal 确认后创建 task。 + - 创建提案时基于当前选择 Agent 优化基础任务指令。 + - 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 启用联网搜索工具后重新创建上述任务;能力匹配通过,任务可以确认并保存能力快照。 +- 用户在当前会话输入“每周发一个周报”,系统结合当前选择的 Agent 生成合理任务指令;确认后任务页展示下一次执行时间,每次执行都重新优化提示词并追加到同一会话。 +- 用户从新对话输入“每周发一个周报”时,系统直接创建并绑定新会话、展示提案,不先让 Agent 立即生成一次周报。 +- 用户询问“今天项目进展如何”或“明天上午的天气怎么样”时不会误生成定时任务提案。 +- 用户输入“每周五下午 3 点发一个周报”时生成 `0 15 * * 5`,输入“每月 15 日上午 10 点汇总销售数据”时生成 `0 10 15 * *`。 +- 周期任务创建后,管理员禁用其绑定知识库;下一次运行前校验失败,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..9fc37edc1 --- /dev/null +++ b/frontend/app/[locale]/agent-tasks/components/AutomationProposalCard.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Button, Space, Tag } from "antd"; +import { CalendarClock, Settings } from "lucide-react"; + +import type { AgentAutomationProposalData } from "@/types/agentAutomation"; + +interface AutomationProposalCardProps { + proposal: AgentAutomationProposalData; + onConfirm?: () => void; + onEdit?: () => void; + onConfigureAgent?: () => void; + confirming?: boolean; +} + +export default function AutomationProposalCard({ + proposal, + onConfirm, + onEdit, + onConfigureAgent, + confirming = false, +}: AutomationProposalCardProps) { + const matched = proposal.capability_resolution?.matched_capabilities || []; + const missing = proposal.capability_resolution?.missing_capabilities || []; + + return ( +
+
+ +
+
+ {proposal.task?.title || "自动任务提案"} +
+
+ {proposal.task?.instruction} +
+
+ {matched.map((capability) => ( + + {capability.display_name || capability.name} + + ))} + {missing.map((capability) => ( + + 缺少 {capability.name} + + ))} +
+ {missing.length > 0 && ( +
+ 当前 Agent 缺少必要能力,需要先配置工具、技能或知识库后再创建。 +
+ )} + {proposal.confirmed_task_id && ( +
+ 已创建任务 #{proposal.confirmed_task_id} +
+ )} + + {proposal.executable && !proposal.confirmed_task_id ? ( + + ) : !proposal.confirmed_task_id ? ( + <> + + + + ) : null} + +
+
+
+ ); +} diff --git a/frontend/app/[locale]/agent-tasks/page.tsx b/frontend/app/[locale]/agent-tasks/page.tsx new file mode 100644 index 000000000..d482bbdfe --- /dev/null +++ b/frontend/app/[locale]/agent-tasks/page.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Button, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + CalendarClock, + MessageCirclePlus, + Pause, + Pencil, + Play, + RefreshCw, + Square, + Trash2, +} from "lucide-react"; + +import { agentAutomationService } from "@/services/agentAutomationService"; +import type { + AgentAutomationRun, + AgentAutomationTask, + 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 errorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +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 [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + 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 loadTasks = async () => { + setLoading(true); + try { + setTasks(await agentAutomationService.list()); + } catch (error: unknown) { + message.error(errorMessage(error, "加载自动任务失败")); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTasks(); + }, []); + + 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("自动任务已更新"); + setModalOpen(false); + setEditingTask(null); + await loadTasks(); + } catch (error: unknown) { + message.error(errorMessage(error, "更新自动任务失败")); + } + }; + + const openRuns = async (task: AgentAutomationTask) => { + setSelectedTask(task); + setHistoryOpen(true); + try { + setRuns(await agentAutomationService.runs(task.task_id)); + } catch (error: unknown) { + message.error(errorMessage(error, "加载运行历史失败")); + } + }; + + const cancelRun = async (run: AgentAutomationRun) => { + try { + await agentAutomationService.cancelRun(run.run_id); + message.success("运行已取消"); + if (selectedTask) { + setRuns(await agentAutomationService.runs(selectedTask.task_id)); + await loadTasks(); + } + } catch (error: unknown) { + message.error(errorMessage(error, "取消运行失败")); + } + }; + + const columns: ColumnsType = [ + { + title: "任务", + dataIndex: "title", + render: (_, task) => ( +
+
{task.title}
+
+ Agent #{task.agent_id} · 会话 #{task.conversation_id} +
+
+ ), + }, + { + title: "状态", + dataIndex: "status", + width: 140, + render: (status) => ( + {status} + ), + }, + { + title: "计划", + width: 180, + render: (_, task) => ( +
+
{task.schedule_mode === "ONCE" ? "一次性" : "周期性"}
+
+ {task.schedule_expr || task.schedule_rule_type} +
+
+ ), + }, + { + title: "下次执行", + dataIndex: "next_fire_at", + width: 220, + render: (value) => (value ? new Date(value).toLocaleString() : "-"), + }, + { + title: "最近结果", + width: 180, + render: (_, task) => ( +
+
{task.last_run_status || "-"}
+ {task.last_error && ( +
+ {task.last_error} +
+ )} +
+ ), + }, + { + title: "操作", + width: 260, + render: (_, task) => ( + + + {task.status === "ACTIVE" ? ( + + + + + + + + + { + setModalOpen(false); + setEditingTask(null); + }} + onOk={submitTask} + okText="保存" + width={720} + > +
+ + + + + + + + + + + + + {getFieldValue("mode") === "RECURRING" && ( + <> + + + + )} + + )} + + )} + + + + + +
+ + setHistoryOpen(false)} + width={720} + > +
+ value ? new Date(value).toLocaleString() : "-", + }, + { title: "错误", dataIndex: "error_message" }, + { + title: "操作", + width: 100, + render: (_, run) => + ["QUEUED", "RUNNING"].includes(run.status) ? ( + + ) : null, + }, + ]} + /> + + + ); +} diff --git a/frontend/app/[locale]/chat/components/chatHeader.tsx b/frontend/app/[locale]/chat/components/chatHeader.tsx index adee568dd..8f80fde4f 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,22 @@ 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..5f66b6afe 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 { agentAutomationService } from "@/services/agentAutomationService"; +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,28 @@ export function ChatSidebar({ }; // Handle delete - const handleDelete = (conversationId: number) => { + const handleDelete = async (conversationId: number) => { + let hasAutomation = false; + try { + const automation = + await agentAutomationService.getByConversation(conversationId); + hasAutomation = Boolean(automation); + } 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 +217,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 +240,136 @@ 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 +397,10 @@ export function ChatSidebar({ {/* New conversation button */}
- + + - - - -
+ + -
-
-
- {conversationManagement.conversationList.length > 0 ? - ( - <> - {renderConversationList(today, t("chatLeftSidebar.today"))} - {renderConversationList(week, t("chatLeftSidebar.last7Days"))} - {renderConversationList(older, t("chatLeftSidebar.older"))} - - ) : ( -
-

- {t("chatLeftSidebar.recentConversations")} -

- -
- )} -
+
+
+
+ {conversationManagement.conversationList.length > 0 ? ( + <> + {renderConversationList(today, t("chatLeftSidebar.today"))} + {renderConversationList( + week, + t("chatLeftSidebar.last7Days") + )} + {renderConversationList(older, t("chatLeftSidebar.older"))} + + ) : ( +
+

+ {t("chatLeftSidebar.recentConversations")} +

+ +
+ )}
- ) : ( - renderCollapsedSidebar() - )} +
+ ) : ( + renderCollapsedSidebar() + )}