diff --git a/backend/alembic/versions/202607221500_add_agent_model_deleted_at.py b/backend/alembic/versions/202607221500_add_agent_model_deleted_at.py new file mode 100644 index 000000000..407c10944 --- /dev/null +++ b/backend/alembic/versions/202607221500_add_agent_model_deleted_at.py @@ -0,0 +1,51 @@ +"""Add logical deletion markers for Agent and LLM Model. + +Revision ID: add_agent_model_deleted_at +Revises: add_experience_revision_drafts +Create Date: 2026-07-22 15:00:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "add_agent_model_deleted_at" +down_revision: str | None = "add_experience_revision_drafts" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "agents", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "llm_models", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_agents_active_tenant_created_at", + "agents", + ["tenant_id", "created_at"], + unique=False, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.create_index( + "ix_llm_models_active_tenant_created_at", + "llm_models", + ["tenant_id", "created_at"], + unique=False, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_llm_models_active_tenant_created_at", table_name="llm_models") + op.drop_index("ix_agents_active_tenant_created_at", table_name="agents") + op.drop_column("llm_models", "deleted_at") + op.drop_column("agents", "deleted_at") diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 43f73521e..852e53fda 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -196,7 +196,11 @@ async def toggle_company( # When disabling: pause all running agents if not new_state: agents = await db.execute( - select(Agent).where(Agent.tenant_id == company_id, Agent.status == "running") + select(Agent).where( + Agent.tenant_id == company_id, + Agent.status == "running", + Agent.deleted_at.is_(None), + ) ) for agent in agents.scalars().all(): agent.status = "paused" diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index c5532602c..2ee90ebc5 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -1,15 +1,13 @@ """Agent (Digital Employee) API routes.""" import hashlib -import json import secrets import uuid from datetime import datetime, timedelta, timezone -from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from loguru import logger -from sqlalchemy import cast, func, select, String +from sqlalchemy import String, cast, delete, exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -18,8 +16,10 @@ from app.core.security import get_current_user from app.database import async_session, get_db from app.models.agent import Agent, AgentPermission, AgentTemplate +from app.models.agent_run import AgentRun +from app.models.agent_run_event import AgentRunEvent from app.models.org import OrgMember -from app.models.audit import ChatMessage +from app.models.audit import AuditLog, ChatMessage from app.models.chat_session import ChatSession from app.models.user import User from app.schemas.schemas import AgentCreate, AgentOut, AgentUpdate @@ -28,10 +28,13 @@ from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded from app.models.tenant import Tenant from app.models.participant import Participant +from app.models.workspace import WorkspaceEditLock from app.services.okr_agent_hook import hook_new_agent from app.services.agent_manager import agent_manager from app.models.skill import Skill from app.services.resource_discovery import import_mcp_from_smithery +from app.services.agent_runtime.persistence import enqueue_cancel +from app.services.llm.model_resolution import load_active_model router = APIRouter(prefix="/agents", tags=["agents"]) settings = get_settings() @@ -50,67 +53,21 @@ async def _get_active_admin_users(db: AsyncSession, tenant_id: uuid.UUID | None) return result.scalars().all() -def _serialize_dt(value: datetime | None) -> str | None: - return value.isoformat() if value else None - - -async def _archive_agent_task_history(db: AsyncSession, agent_id: uuid.UUID, archive_dir: Path) -> Path | None: - """Persist task and task-log history into the agent archive directory before DB cleanup.""" - from app.models.task import Task, TaskLog - - task_result = await db.execute(select(Task).where(Task.agent_id == agent_id).order_by(Task.created_at.asc())) - tasks = task_result.scalars().all() - if not tasks: - return None - - archive_dir.mkdir(parents=True, exist_ok=True) - - payload = { - "agent_id": str(agent_id), - "archived_at": datetime.now(timezone.utc).isoformat(), - "tasks": [], - } - - for task in tasks: - log_result = await db.execute( - select(TaskLog).where(TaskLog.task_id == task.id).order_by(TaskLog.created_at.asc()) - ) - logs = log_result.scalars().all() - payload["tasks"].append( - { - "id": str(task.id), - "title": task.title, - "description": task.description, - "type": task.type, - "status": task.status, - "priority": task.priority, - "assignee": task.assignee, - "created_by": str(task.created_by), - "due_date": _serialize_dt(task.due_date), - "supervision_target_user_id": ( - str(task.supervision_target_user_id) if task.supervision_target_user_id else None - ), - "supervision_target_name": task.supervision_target_name, - "supervision_channel": task.supervision_channel, - "remind_schedule": task.remind_schedule, - "created_at": _serialize_dt(task.created_at), - "updated_at": _serialize_dt(task.updated_at), - "completed_at": _serialize_dt(task.completed_at), - "logs": [ - { - "id": str(log.id), - "content": log.content, - "created_at": _serialize_dt(log.created_at), - } - for log in logs - ], - } +async def _validate_active_agent_model( + db: AsyncSession, + *, + model_id: uuid.UUID | None, + tenant_id: uuid.UUID | None, + field_name: str, +) -> None: + if model_id is None: + return + if await load_active_model(db, model_id=model_id, tenant_id=tenant_id) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_name} must reference an active model in the Agent tenant", ) - archive_path = archive_dir / "task_history.json" - archive_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - return archive_path - async def _lazy_reset_token_counters(agent: Agent, db: AsyncSession) -> bool: """Reset daily/monthly token counters if the day or month has changed. @@ -294,7 +251,12 @@ async def _background_agent_setup( # 1. Initialize agent file system from template try: async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: logger.error(f"[background_agent_setup] Agent {agent_id} not found") @@ -309,7 +271,12 @@ async def _background_agent_setup( except Exception as e: logger.exception(f"Error during agent file initialization for {agent_id}: {e}") async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent: agent.status = "error" @@ -344,7 +311,12 @@ async def _background_agent_setup( except Exception as e: logger.exception(f"Error resolving skills for agent {agent_id}: {e}") async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent: agent.status = "error" @@ -364,7 +336,12 @@ async def _background_agent_setup( except Exception as e: logger.exception(f"Error copying skills files for agent {agent_id}: {e}") async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent: agent.status = "error" @@ -397,7 +374,12 @@ async def _background_agent_setup( # 5. Start container and Hook OKR Agent try: async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: logger.error(f"[background_agent_setup] Agent {agent_id} not found before starting container") @@ -412,7 +394,12 @@ async def _background_agent_setup( except Exception as e: logger.exception(f"Error starting container for agent {agent_id}: {e}") async with async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent: agent.status = "error" @@ -465,8 +452,29 @@ async def create_agent( ): default_heartbeat_interval = tenant.min_heartbeat_interval_minutes - # If the caller didn't pick a model, fall back to the tenant's default. - effective_primary_model_id = data.primary_model_id or tenant_default_model_id + # Use a requested model only after an Active check. A stale deleted tenant + # default is ignored without rewriting the historical Tenant reference. + effective_primary_model_id = data.primary_model_id + if effective_primary_model_id is not None: + await _validate_active_agent_model( + db, + model_id=effective_primary_model_id, + tenant_id=target_tenant_id, + field_name="primary_model_id", + ) + elif tenant_default_model_id is not None: + active_default = await load_active_model( + db, + model_id=tenant_default_model_id, + tenant_id=target_tenant_id, + ) + effective_primary_model_id = active_default.id if active_default is not None else None + await _validate_active_agent_model( + db, + model_id=data.fallback_model_id, + tenant_id=target_tenant_id, + field_name="fallback_model_id", + ) expires_at = datetime.now(timezone.utc) + timedelta(hours=ttl_hours) if ttl_hours and ttl_hours > 0 else None agent = Agent( @@ -921,6 +929,15 @@ async def update_agent( update_data = data.model_dump(exclude_unset=True) + for field_name in ("primary_model_id", "fallback_model_id"): + if field_name in update_data: + await _validate_active_agent_model( + db, + model_id=update_data[field_name], + tenant_id=agent.tenant_id, + field_name=field_name, + ) + # expires_at: admin only if "expires_at" in update_data: if not is_admin: @@ -1016,8 +1033,13 @@ async def delete_agent( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Delete a digital employee (creator only).""" - agent, _access = await check_agent_access(db, current_user, agent_id) + """Logically delete an Agent while retaining its history and Workspace.""" + agent, _access = await check_agent_access( + db, + current_user, + agent_id, + include_deleted=True, + ) if not is_agent_creator(current_user, agent) and current_user.role not in ( "super_admin", "org_admin", @@ -1033,96 +1055,67 @@ async def delete_agent( detail="System agents cannot be deleted. Disable the related feature (e.g. OKR) in Company Settings instead.", ) - # Stop container and archive files (best effort) - from app.services.agent_manager import agent_manager - - archive_dir: Path | None = None - try: - await agent_manager.remove_container(agent) - except Exception: - pass - try: - archive_dir = await agent_manager.archive_agent_files(agent.id) - except Exception: - pass - if archive_dir is not None: - try: - await _archive_agent_task_history(db, agent.id, archive_dir) - except Exception: - pass - - # Delete related records that reference this agent - # Use savepoints so a failure in one table doesn't poison the whole transaction - from sqlalchemy import text - - cleanup_tables = [ - "agent_activity_logs", - "audit_logs", - "approval_requests", - "agent_schedules", - "agent_triggers", - "channel_configs", - "agent_permissions", - "agent_tools", - "agent_relationships", - "gateway_messages", - "published_pages", - "notifications", - "daily_token_usage", - ] + if agent.deleted_at is None: + agent.deleted_at = datetime.now(timezone.utc) + agent.status = "stopped" + db.add( + AuditLog( + user_id=current_user.id, + agent_id=agent.id, + action="agent_deleted", + details={ + "resource_id": str(agent.id), + "tenant_id": str(agent.tenant_id) if agent.tenant_id else None, + "name": agent.name, + }, + ) + ) + await db.commit() - # Preserve durable conversation history while removing the deleted Agent from - # every active product scope. Participant is intentionally kept as a display - # tombstone because historical Group messages reference it for sender name. - required_unlinks = [ - "UPDATE chat_messages SET agent_id = NULL WHERE agent_id = :aid", - "UPDATE chat_sessions SET agent_id = NULL, is_primary = false, deleted_at = COALESCE(deleted_at, now()) WHERE agent_id = :aid", - "UPDATE chat_sessions SET peer_agent_id = NULL, is_primary = false, deleted_at = COALESCE(deleted_at, now()) WHERE peer_agent_id = :aid", - "UPDATE group_members SET removed_at = COALESCE(removed_at, now()) WHERE participant_id IN (SELECT id FROM participants WHERE type = 'agent' AND ref_id = :aid)", - ] - for sql in required_unlinks: - await db.execute(text(sql), {"aid": agent_id}) + if agent.tenant_id is not None: + run_result = await db.execute( + select(AgentRun.id) + .where( + AgentRun.tenant_id == agent.tenant_id, + AgentRun.agent_id == agent.id, + ~exists().where( + AgentRunEvent.run_id == AgentRun.id, + AgentRunEvent.event_type.in_( + ("run_completed", "run_failed", "run_cancelled") + ), + ), + ) + .order_by(AgentRun.created_at, AgentRun.id) + ) + for run_id in run_result.scalars().all(): + await enqueue_cancel( + db, + tenant_id=agent.tenant_id, + run_id=run_id, + idempotency_key=f"agent-delete:{agent.id}:run:{run_id}", + reason="agent_deleted", + actor_user_id=current_user.id, + ) - for table in cleanup_tables: - try: - async with db.begin_nested(): - await db.execute(text(f"DELETE FROM {table} WHERE agent_id = :aid"), {"aid": agent_id}) - except Exception: - pass - - # Clean up secondary FK columns that also reference agents table - secondary_fk_cleanups = [ - "DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE agent_id = :aid)", - "DELETE FROM tasks WHERE agent_id = :aid", - "DELETE FROM gateway_messages WHERE sender_agent_id = :aid", - "UPDATE chat_messages SET sender_agent_id = NULL WHERE sender_agent_id = :aid", - ] - for sql in secondary_fk_cleanups: - try: - async with db.begin_nested(): - await db.execute(text(sql), {"aid": agent_id}) - except Exception: - pass + await db.execute( + delete(WorkspaceEditLock).where(WorkspaceEditLock.agent_id == agent.id) + ) + await db.commit() - # Also clean agent_agent_relationships (has both agent_id and target_agent_id) try: - async with db.begin_nested(): - await db.execute( - text("DELETE FROM agent_agent_relationships WHERE agent_id = :aid OR target_agent_id = :aid"), - {"aid": agent_id}, + removed = await agent_manager.remove_container(agent) + if removed: + await db.commit() + else: + logger.warning( + "Container removal requires retry for logically deleted Agent {}", + agent.id, ) except Exception: - pass - - # Also clear plaza posts by this agent - try: - async with db.begin_nested(): - await db.execute(text("DELETE FROM plaza_posts WHERE author_id = :aid"), {"aid": str(agent_id)}) - except Exception: - pass - - await db.delete(agent) - await db.commit() + logger.exception( + "Container removal failed for logically deleted Agent {}", + agent.id, + ) @router.post("/{agent_id}/start", response_model=AgentOut) diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py index 9ad28d903..3ee062a44 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -284,6 +284,7 @@ async def get_custom_directory_agent_candidates( Agent.tenant_id == agent.tenant_id, Agent.id != agent.id, Agent.access_mode != "private", + Agent.deleted_at.is_(None), ~exists().where( AgentAgentRelationship.agent_id == agent_id, AgentAgentRelationship.target_agent_id == Agent.id, @@ -329,6 +330,7 @@ async def add_custom_directory_agent( Agent.tenant_id == agent.tenant_id, Agent.id != agent.id, Agent.access_mode != "private", + Agent.deleted_at.is_(None), ) )).scalar_one_or_none() if not target: diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index 692596925..f506efdae 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -145,7 +145,10 @@ async def _resolve_llm_test_target( raise ValueError("model_id must be a valid UUID") from exc async with async_session() as session: result = await session.execute( - select(LLMModel).where(LLMModel.id == model_id) + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) ) existing = result.scalar_one_or_none() if existing is None: @@ -186,7 +189,10 @@ async def _record_llm_tool_capability( async with async_session() as session: result = await session.execute( select(LLMModel) - .where(LLMModel.id == target.model_id) + .where( + LLMModel.id == target.model_id, + LLMModel.deleted_at.is_(None), + ) .with_for_update() ) existing = result.scalar_one_or_none() @@ -339,7 +345,11 @@ async def list_llm_models( raise HTTPException(status_code=403, detail="Cannot access other tenant's models") tid = tenant_id or str(current_user.tenant_id) if current_user.tenant_id else None - query = select(LLMModel).order_by(LLMModel.created_at.desc()) + query = ( + select(LLMModel) + .where(LLMModel.deleted_at.is_(None)) + .order_by(LLMModel.created_at.desc()) + ) if tid: query = query.where(LLMModel.tenant_id == uuid.UUID(tid)) result = await db.execute(query) @@ -398,7 +408,12 @@ async def set_default_llm_model( db: AsyncSession = Depends(get_db), ): """Mark this model as the tenant's default for new agents.""" - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) + ) model = result.scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Model not found") @@ -443,44 +458,35 @@ async def set_default_llm_model( @router.delete("/llm-models/{model_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_llm_model( model_id: uuid.UUID, - force: bool = False, current_user: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): - """Remove an LLM model from the pool.""" - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + """Logically delete an LLM model while retaining every historical reference.""" + query = select(LLMModel).where(LLMModel.id == model_id) + if not _is_platform_admin_user(current_user): + query = query.where(LLMModel.tenant_id == current_user.tenant_id) + result = await db.execute(query) model = result.scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Model not found") - # Check if any agents reference this model - from sqlalchemy import or_ - ref_result = await db.execute( - select(Agent.name).where( - or_(Agent.primary_model_id == model_id, Agent.fallback_model_id == model_id) - ) - ) - agent_names = [row[0] for row in ref_result.all()] - - if agent_names and not force: - raise HTTPException( - status_code=409, - detail={ - "message": f"This model is used by {len(agent_names)} agent(s)", - "agents": agent_names, - }, - ) - - # Nullify FK references in agents before deleting - if agent_names: - await db.execute( - update(Agent).where(Agent.primary_model_id == model_id).values(primary_model_id=None) - ) - await db.execute( - update(Agent).where(Agent.fallback_model_id == model_id).values(fallback_model_id=None) + if model.deleted_at is None: + model.deleted_at = datetime.now(UTC) + model.enabled = False + db.add( + AuditLog( + user_id=current_user.id, + action="llm_model_deleted", + details={ + "resource_id": str(model.id), + "tenant_id": str(model.tenant_id) if model.tenant_id else None, + "label": model.label, + "provider": model.provider, + "model": model.model, + }, + ) ) - await db.delete(model) - await db.commit() + await db.commit() @router.put("/llm-models/{model_id}", response_model=LLMModelOut) @@ -491,7 +497,12 @@ async def update_llm_model( db: AsyncSession = Depends(get_db), ): """Update an existing LLM model in the pool (admin).""" - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) + ) model = result.scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Model not found") @@ -924,6 +935,7 @@ async def _runtime_model_settings_payload(db: AsyncSession, *, tenant_id: uuid.U .where( or_(LLMModel.tenant_id.is_(None), LLMModel.tenant_id == tenant_id), LLMModel.enabled.is_(True), + LLMModel.deleted_at.is_(None), LLMModel.supports_tool_calling.is_(True), ) .order_by(LLMModel.created_at.desc()) @@ -973,7 +985,12 @@ async def update_runtime_model_settings( resolved_tenant_id = _runtime_settings_tenant_id(current_user, tenant_id) requested_ids = {data.planning_model_id, data.compact_model_id} - result = await db.execute(select(LLMModel).where(LLMModel.id.in_(requested_ids))) + result = await db.execute( + select(LLMModel).where( + LLMModel.id.in_(requested_ids), + LLMModel.deleted_at.is_(None), + ) + ) models = {model.id: model for model in result.scalars().all()} for model_id in requested_ids: model = models.get(model_id) diff --git a/backend/app/api/experience.py b/backend/app/api/experience.py index df61aa6dd..2fc3599cc 100644 --- a/backend/app/api/experience.py +++ b/backend/app/api/experience.py @@ -22,8 +22,8 @@ from app.models.agent import Agent from app.models.experience import ExperienceEntry from app.models.experience_reference import ExperienceReference -from app.models.llm import LLMModel from app.models.user import User +from app.services.llm.model_resolution import resolve_active_agent_model router = APIRouter(prefix="/api/experience", tags=["experience"]) @@ -420,11 +420,7 @@ async def _distill_fields(db, agent, content: str) -> dict: """ fields: dict = {} try: - model_id = agent.primary_model_id or agent.fallback_model_id - model = ( - (await db.execute(select(LLMModel).where(LLMModel.id == model_id))).scalar_one_or_none() - if model_id else None - ) + model = await resolve_active_agent_model(db, agent) if model: from app.services.llm import get_model_api_key from app.services.llm.client import chat_complete @@ -468,7 +464,14 @@ async def distill_content(payload: DraftFromContent, current_user: User = Depend raise HTTPException(400, "Content cannot be empty") eff = _effective_tenant_id(current_user) async with async_session() as db: - agent = (await db.execute(select(Agent).where(Agent.id == payload.agent_id))).scalar_one_or_none() + agent = ( + await db.execute( + select(Agent).where( + Agent.id == payload.agent_id, + Agent.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() if not agent or (eff and str(agent.tenant_id) != eff): raise HTTPException(404, "Agent not found") return DistillResult(**await _distill_fields(db, agent, payload.content)) @@ -482,7 +485,14 @@ async def create_draft_from_content(payload: DraftFromContent, current_user: Use raise HTTPException(400, "Content cannot be empty") eff = _effective_tenant_id(current_user) async with async_session() as db: - agent = (await db.execute(select(Agent).where(Agent.id == payload.agent_id))).scalar_one_or_none() + agent = ( + await db.execute( + select(Agent).where( + Agent.id == payload.agent_id, + Agent.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() if not agent or (eff and str(agent.tenant_id) != eff): raise HTTPException(404, "Agent not found") f = await _distill_fields(db, agent, payload.content) diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 944e35eb7..4dd452b44 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -20,6 +20,7 @@ ) from app.services.agent_runtime.chat_intake import ChatRuntimeIntake from app.services.feishu_service import feishu_service +from app.services.llm.model_resolution import active_agent_model_candidates from app.services.storage import store_agent_upload router = APIRouter(tags=["feishu"]) @@ -320,7 +321,12 @@ async def _accept_feishu_runtime_message( from app.services.channel_session import find_or_create_channel_session async with _async_session() as db: - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent is None: raise RuntimeError(f"Feishu Agent {agent_id} not found") @@ -691,32 +697,18 @@ async def _load_agent_and_model( scalar values before closing the session to avoid detached-instance errors. """ from app.models.agent import Agent - from app.models.llm import LLMModel - - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: return None, None, None - model = None - if agent.primary_model_id: - model_result = await db.execute(select(LLMModel).where(LLMModel.id == agent.primary_model_id)) - model = model_result.scalar_one_or_none() - if model and not model.enabled: - logger.info(f"[Channel] Primary model {model.model} is disabled, skipping") - model = None - - fallback_model = None - if agent.fallback_model_id: - fb_result = await db.execute(select(LLMModel).where(LLMModel.id == agent.fallback_model_id)) - fallback_model = fb_result.scalar_one_or_none() - if fallback_model and not fallback_model.enabled: - logger.info(f"[Channel] Fallback model {fallback_model.model} is disabled, skipping") - fallback_model = None - - if not model and fallback_model: - model = fallback_model - fallback_model = None - logger.warning(f"[Channel] Primary model unavailable, using fallback: {model.model}") + candidates = await active_agent_model_candidates(db, agent) + model = candidates[0] if candidates else None + fallback_model = candidates[1] if len(candidates) > 1 else None return agent, model, fallback_model diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index ec153b521..7f1780920 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -47,6 +47,7 @@ async def _get_agent_by_key(api_key: str, db: AsyncSession) -> Agent: select(Agent).where( Agent.api_key_hash == api_key, Agent.agent_type == "openclaw", + Agent.deleted_at.is_(None), ) ) agent = result.scalar_one_or_none() @@ -58,6 +59,7 @@ async def _get_agent_by_key(api_key: str, db: AsyncSession) -> Agent: select(Agent).where( Agent.api_key_hash == key_hash, Agent.agent_type == "openclaw", + Agent.deleted_at.is_(None), ) ) agent = result.scalar_one_or_none() @@ -202,6 +204,7 @@ async def poll_messages( Agent.id != agent.id, Agent.access_mode == "company", Agent.status.in_(["running", "idle"]), + Agent.deleted_at.is_(None), ) .order_by(Agent.name.asc(), Agent.created_at.asc()) ) @@ -306,7 +309,10 @@ async def report_result( if runtime_completion is None: sender_result = await db.execute( - select(Agent).where(Agent.id == msg.sender_agent_id) + select(Agent).where( + Agent.id == msg.sender_agent_id, + Agent.deleted_at.is_(None), + ) ) sender_agent = sender_result.scalar_one_or_none() if sender_agent is not None and sender_agent.agent_type == "openclaw": @@ -392,6 +398,7 @@ async def send_message( Agent.tenant_id == agent.tenant_id, Agent.id != agent.id, Agent.access_mode == "company", + Agent.deleted_at.is_(None), ) ) company_candidate = company_result.scalars().first() diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index f642d5e3a..af0c5b213 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -79,6 +79,7 @@ class GroupMemberOut(BaseModel): role: str role_description: str | None = None title: str | None = None + is_deleted: bool = False joined_at: datetime @@ -411,6 +412,7 @@ async def _member_outputs( role=membership.role, role_description=agent.role_description if agent is not None else None, title=user.title if user is not None else None, + is_deleted=bool(agent is not None and agent.deleted_at is not None), joined_at=membership.joined_at, ) ) diff --git a/backend/app/api/notification.py b/backend/app/api/notification.py index cebfd0b8b..801240786 100644 --- a/backend/app/api/notification.py +++ b/backend/app/api/notification.py @@ -167,7 +167,10 @@ async def broadcast_notification( # Notify all agents in tenant agents_result = await db.execute( - select(Agent).where(Agent.tenant_id == tenant_id) + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.deleted_at.is_(None), + ) ) for agent in agents_result.scalars().all(): await send_notification( diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py index b0bc17efb..db7c81b59 100644 --- a/backend/app/api/okr.py +++ b/backend/app/api/okr.py @@ -1692,7 +1692,12 @@ async def trigger_member_outreach(user=Depends(get_current_user)): 404, "OKR Agent not found. Please ensure OKR is enabled and the agent has been seeded.", ) - okr_agent_result = await db.execute(select(Agent).where(Agent.id == settings.okr_agent_id)) + okr_agent_result = await db.execute( + select(Agent).where( + Agent.id == settings.okr_agent_id, + Agent.deleted_at.is_(None), + ) + ) okr_agent = okr_agent_result.scalar_one_or_none() if not okr_agent: raise HTTPException( @@ -1755,6 +1760,7 @@ async def trigger_member_outreach(user=Depends(get_current_user)): AgentAgentRelationship.agent_id == okr_agent.id, Agent.is_system == False, # noqa: E712 Agent.status.notin_(["stopped", "error"]), + Agent.deleted_at.is_(None), ) ) tracked_agents = agent_rel_result.scalars().all() diff --git a/backend/app/api/onboarding.py b/backend/app/api/onboarding.py index c580c11ec..d33bb6771 100644 --- a/backend/app/api/onboarding.py +++ b/backend/app/api/onboarding.py @@ -202,7 +202,12 @@ async def create_personal_assistant( """Create the user's private assistant and advance onboarding.""" row = await _ensure_row(db, current_user, "join") if row.personal_assistant_agent_id: - result = await db.execute(select(Agent).where(Agent.id == row.personal_assistant_agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == row.personal_assistant_agent_id, + Agent.deleted_at.is_(None), + ) + ) existing = result.scalar_one_or_none() if existing: row.current_step = "opening" diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index 75f1b88ed..950e59cc1 100644 --- a/backend/app/api/plaza.py +++ b/backend/app/api/plaza.py @@ -89,7 +89,10 @@ async def _notify_mentions(db, content: str, author_id: uuid.UUID, author_name: return # Find matching agents in the same tenant - agent_q = select(Agent).where(Agent.id != author_id) + agent_q = select(Agent).where( + Agent.id != author_id, + Agent.deleted_at.is_(None), + ) if tenant_id: agent_q = agent_q.where(Agent.tenant_id == tenant_id) agents_result = await db.execute(agent_q) diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py index e69a3f062..62e48ae5b 100644 --- a/backend/app/api/webhooks.py +++ b/backend/app/api/webhooks.py @@ -91,8 +91,15 @@ async def receive_webhook(token: str, request: Request): return JSONResponse({"ok": True}) # Per-agent rate limit check - agent_result = await db.execute(select(Agent).where(Agent.id == target.agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == target.agent_id, + Agent.deleted_at.is_(None), + ) + ) agent_obj = agent_result.scalar_one_or_none() + if agent_obj is None: + return JSONResponse({"ok": True}) agent_rate_limit = (agent_obj.webhook_rate_limit if agent_obj else None) or RATE_LIMIT # Retrieve all needed scalar fields and expunge from db session to prevent MissingGreenlet errors. diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 428bba0a3..a89be270d 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -39,6 +39,10 @@ from app.services.agent_runtime.run_state_reader import RunStateReadError, open_run_state_reader from app.services.chat_session_service import ensure_primary_platform_session from app.services.llm.utils import convert_chat_messages_to_llm_format +from app.services.llm.model_resolution import ( + active_agent_model_candidates, + load_active_model, +) from app.services.onboarding import is_onboarded, mark_onboarding_phase, resolve_onboarding_prompt from app.services.quota_guard import ( AgentExpired, @@ -320,28 +324,9 @@ async def setup(self) -> bool: async def _load_models(self, db: AsyncSession): """Loads primary and fallback models for the agent.""" - if self.agent.primary_model_id: - model_result = await db.execute(select(LLMModel).where(LLMModel.id == self.agent.primary_model_id)) - self.llm_model = model_result.scalar_one_or_none() - if self.llm_model and not self.llm_model.enabled: - logger.info(f"[WS] Primary model {self.llm_model.model} is disabled, skipping") - self.llm_model = None - else: - logger.info(f"[WS] Primary model loaded: {self.llm_model.model if self.llm_model else 'None'}") - - if self.agent.fallback_model_id: - fb_result = await db.execute(select(LLMModel).where(LLMModel.id == self.agent.fallback_model_id)) - self.fallback_llm_model = fb_result.scalar_one_or_none() - if self.fallback_llm_model and not self.fallback_llm_model.enabled: - logger.info(f"[WS] Fallback model {self.fallback_llm_model.model} is disabled, skipping") - self.fallback_llm_model = None - elif self.fallback_llm_model: - logger.info(f"[WS] Fallback model loaded: {self.fallback_llm_model.model}") - - if not self.llm_model and self.fallback_llm_model: - self.llm_model = self.fallback_llm_model - self.fallback_llm_model = None - logger.info(f"[WS] Primary model unavailable, using fallback: {self.llm_model.model}") + candidates = await active_agent_model_candidates(db, self.agent) + self.llm_model = candidates[0] if candidates else None + self.fallback_llm_model = candidates[1] if len(candidates) > 1 else None async def _resolve_chat_session(self, db: AsyncSession, user_id: uuid.UUID) -> str | None: """Resolves existing session or creates a new one.""" @@ -786,7 +771,11 @@ async def _enqueue_runtime_chat( ) agent, _ = await check_agent_access(db, user, self.agent_id) session = await db.get(ChatSession, session_id) - model = await db.get(LLMModel, model_id) + model = await load_active_model( + db, + model_id=model_id, + tenant_id=agent.tenant_id, + ) if session is None: raise ChatRuntimeIntakeError( "chat_session_not_found", @@ -1182,40 +1171,32 @@ async def _mark_onboarding_runtime_phase(self, target_phase: str) -> None: async def _resolve_effective_model(self, override_model_id: str | None) -> LLMModel | None: """Reloads model config and resolves effective model (taking overrides into account).""" async with async_session() as _mdb: - _agent_r = await _mdb.execute(select(Agent).where(Agent.id == self.agent_id)) + _agent_r = await _mdb.execute( + select(Agent).where( + Agent.id == self.agent_id, + Agent.deleted_at.is_(None), + ) + ) _agent_cur = _agent_r.scalar_one_or_none() if _agent_cur: - if _agent_cur.primary_model_id: - _m_r = await _mdb.execute(select(LLMModel).where(LLMModel.id == _agent_cur.primary_model_id)) - _m = _m_r.scalar_one_or_none() - self.llm_model = _m if (_m and _m.enabled) else None - else: - self.llm_model = None - - if _agent_cur.fallback_model_id: - _fb_r = await _mdb.execute(select(LLMModel).where(LLMModel.id == _agent_cur.fallback_model_id)) - _fb = _fb_r.scalar_one_or_none() - self.fallback_llm_model = _fb if (_fb and _fb.enabled) else None - else: - self.fallback_llm_model = None - - if not self.llm_model and self.fallback_llm_model: - self.llm_model = self.fallback_llm_model - self.fallback_llm_model = None + candidates = await active_agent_model_candidates(_mdb, _agent_cur) + self.llm_model = candidates[0] if candidates else None + self.fallback_llm_model = candidates[1] if len(candidates) > 1 else None + else: + self.llm_model = None + self.fallback_llm_model = None effective_llm_model = self.llm_model if override_model_id: try: _ovr_uuid = uuid.UUID(str(override_model_id)) async with async_session() as _mdb: - _mr = await _mdb.execute(select(LLMModel).where(LLMModel.id == _ovr_uuid)) - _ovr = _mr.scalar_one_or_none() - if ( - _ovr - and _ovr.enabled - and self.user is not None - and _ovr.tenant_id in {None, self.user.tenant_id} - ): + _ovr = await load_active_model( + _mdb, + model_id=_ovr_uuid, + tenant_id=self.user.tenant_id if self.user is not None else None, + ) + if _ovr and self.user is not None: effective_llm_model = _ovr else: logger.warning( @@ -1309,7 +1290,12 @@ async def _update_activity_and_quota(self, assistant_response: str): """Update last_active_at, conversation/agent LLM usage, and log activity.""" try: async with async_session() as _db: - _ar = await _db.execute(select(Agent).where(Agent.id == self.agent_id)) + _ar = await _db.execute( + select(Agent).where( + Agent.id == self.agent_id, + Agent.deleted_at.is_(None), + ) + ) _agent = _ar.scalar_one_or_none() if _agent: _agent.last_active_at = datetime.now(tz.utc) diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 86208c7f7..9465ce334 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -45,6 +45,8 @@ def can_use_agent_static(user: User, agent: Agent) -> bool: """Return whether a user can use an agent without DB-backed custom checks.""" if not user or not agent: return False + if getattr(agent, "deleted_at", None) is not None: + return False if not getattr(user, "is_active", True): return False if not _agent_tenant_matches_user(agent, user): @@ -66,6 +68,8 @@ async def can_use_agent(db: AsyncSession, user: User, agent: Agent) -> bool: return True if not user or not agent: return False + if getattr(agent, "deleted_at", None) is not None: + return False if not getattr(user, "is_active", True): return False if not _agent_tenant_matches_user(agent, user): @@ -88,10 +92,18 @@ async def can_use_agent(db: AsyncSession, user: User, agent: Agent) -> bool: return result.scalar_one_or_none() is not None -async def can_manage_agent(db: AsyncSession, user: User, agent: Agent) -> bool: +async def can_manage_agent( + db: AsyncSession, + user: User, + agent: Agent, + *, + include_deleted: bool = False, +) -> bool: """Return whether a human user can manage agent configuration.""" if not user or not agent: return False + if not include_deleted and getattr(agent, "deleted_at", None) is not None: + return False if not getattr(user, "is_active", True): return False if not _agent_tenant_matches_user(agent, user): @@ -118,6 +130,8 @@ async def can_manage_agent(db: AsyncSession, user: User, agent: Agent) -> bool: def _roster_agent_unavailable_reason(agent: Agent) -> str | None: + if getattr(agent, "deleted_at", None) is not None: + return "agent_deleted" status_value = getattr(agent, "status", None) if status_value in (None, "running", "idle"): pass @@ -231,6 +245,7 @@ def build_visible_agents_query( return stmt.where( Agent.tenant_id == target_tenant_id, + Agent.deleted_at.is_(None), or_(*visible_conditions), ) @@ -325,6 +340,8 @@ async def get_agent_accessible_user_ids(db: AsyncSession, agent: Agent) -> set[u def _agent_available(agent: Agent | None) -> tuple[bool, str | None]: if not agent: return False, "target_not_found" + if getattr(agent, "deleted_at", None) is not None: + return False, "agent_deleted" if getattr(agent, "status", None) in ("stopped", "error"): return False, f"target_status_{agent.status}" if is_agent_expired(agent): @@ -461,7 +478,13 @@ async def evaluate_human_relationship_status( } -async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) -> Tuple[Agent, str]: +async def check_agent_access( + db: AsyncSession, + user: User, + agent_id: uuid.UUID, + *, + include_deleted: bool = False, +) -> Tuple[Agent, str]: """Check if a user has access to a specific agent. Returns (agent, access_level) where access_level is 'manage' or 'use'. @@ -471,7 +494,10 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) 2. Company admin + non-private agent -> manage 3. User has explicit permission (company/user scope) -> from permission record """ - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + query = select(Agent).where(Agent.id == agent_id) + if not include_deleted: + query = query.where(Agent.deleted_at.is_(None)) + result = await db.execute(query) agent = result.scalar_one_or_none() if not agent: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") @@ -484,7 +510,7 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) if agent.creator_id == user.id: return agent, "manage" - if await can_manage_agent(db, user, agent): + if await can_manage_agent(db, user, agent, include_deleted=include_deleted): return agent, "manage" if await can_use_agent(db, user, agent): return agent, "use" diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 6d66fe2fc..bd4e56e92 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, func, text from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -23,6 +23,14 @@ class Agent(Base): """ __tablename__ = "agents" + __table_args__ = ( + Index( + "ix_agents_active_tenant_created_at", + "tenant_id", + "created_at", + postgresql_where=text("deleted_at IS NULL"), + ), + ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(100), nullable=False) @@ -135,6 +143,7 @@ class Agent(Base): DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Relationships creator: Mapped["User"] = relationship("User", back_populates="created_agents", foreign_keys=[creator_id]) diff --git a/backend/app/models/llm.py b/backend/app/models/llm.py index 0e53a2256..49f1fa69c 100644 --- a/backend/app/models/llm.py +++ b/backend/app/models/llm.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, func, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -41,6 +41,12 @@ class LLMModel(Base): "tool_calling_capability_source IN ('probe', 'builtin_registry')", name="ck_llm_models_tool_calling_capability_source", ), + Index( + "ix_llm_models_active_tenant_created_at", + "tenant_id", + "created_at", + postgresql_where=text("deleted_at IS NULL"), + ), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -70,3 +76,4 @@ class LLMModel(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 85ffac79d..11b354a01 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -296,6 +296,7 @@ class AgentOut(BaseModel): onboarded_for_me: bool = True created_at: datetime last_active_at: datetime | None = None + deleted_at: datetime | None = None model_config = {"from_attributes": True} @@ -440,6 +441,7 @@ class LLMModelOut(BaseModel): max_output_tokens: int | None = None request_timeout: int | None = None created_at: datetime + deleted_at: datetime | None = None model_config = {"from_attributes": True} diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index 439e8f6d8..4258e2b6c 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -1,7 +1,6 @@ """Agent lifecycle manager — Docker container management for OpenClaw Gateway instances.""" import json -import shutil import uuid from datetime import datetime, timezone from pathlib import Path @@ -16,6 +15,7 @@ from app.models.agent import Agent, AgentTemplate from app.models.llm import LLMModel from app.services.llm import get_model_api_key +from app.services.llm.model_resolution import resolve_active_agent_model from app.services.storage import get_storage_backend, normalize_storage_key settings = get_settings() @@ -252,6 +252,10 @@ async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: Returns container_id or None if Docker not available. """ + if agent.deleted_at is not None: + logger.info("Agent {} is deleted; skipping container start", agent.id) + return None + if not self.docker_client: logger.info("Docker not available, skipping container start") agent.status = "idle" @@ -261,10 +265,7 @@ async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: agent_dir = await self._materialize_agent_dir(agent.id) # Get model config - model = None - if agent.primary_model_id: - result = await db.execute(select(LLMModel).where(LLMModel.id == agent.primary_model_id)) - model = result.scalar_one_or_none() + model = await resolve_active_agent_model(db, agent) # Generate OpenClaw config config = self._generate_openclaw_config(agent, model) @@ -353,21 +354,6 @@ async def remove_container(self, agent: Agent) -> bool: logger.error(f"Failed to remove container: {e}") return False - async def archive_agent_files(self, agent_id: uuid.UUID) -> Path: - """Archive agent files to a backup location and return the archive directory.""" - agent_dir = self._agent_dir(agent_id) - local_root = settings.STORAGE_LOCAL_ROOT or settings.AGENT_DATA_DIR - archive_dir = Path(local_root) / "_archived" - archive_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - dest = archive_dir / f"{agent_id}_{timestamp}" - if agent_dir.exists(): - shutil.move(str(agent_dir), str(dest)) - logger.info(f"Archived agent files to {dest}") - else: - dest.mkdir(parents=True, exist_ok=True) - return dest - def get_container_status(self, agent: Agent) -> dict: """Get real-time container status.""" if not self.docker_client or not agent.container_id: diff --git a/backend/app/services/agent_runtime/a2a_runtime.py b/backend/app/services/agent_runtime/a2a_runtime.py index 30712ee4e..1754e492d 100644 --- a/backend/app/services/agent_runtime/a2a_runtime.py +++ b/backend/app/services/agent_runtime/a2a_runtime.py @@ -362,6 +362,7 @@ async def _resolve_target( Agent.tenant_id == source_agent.tenant_id, Agent.id != source_agent.id, Agent.id == target_agent_id, + Agent.deleted_at.is_(None), ) ) target = target_result.scalar_one_or_none() @@ -372,6 +373,7 @@ async def _resolve_target( Agent.tenant_id == source_agent.tenant_id, Agent.id != source_agent.id, Agent.name == target_name, + Agent.deleted_at.is_(None), ) ) target = exact_result.scalars().first() @@ -384,6 +386,7 @@ async def _resolve_target( Agent.tenant_id == source_agent.tenant_id, Agent.id != source_agent.id, Agent.name.ilike(f"%{safe_name}%"), + Agent.deleted_at.is_(None), ) .limit(2) ) @@ -793,6 +796,7 @@ async def execute( select(Agent).where( Agent.tenant_id == tenant_id, Agent.id == source_agent_id, + Agent.deleted_at.is_(None), ) ) source_agent = source_result.scalar_one_or_none() diff --git a/backend/app/services/agent_runtime/adapter.py b/backend/app/services/agent_runtime/adapter.py index c85c9b5fb..371e103e3 100644 --- a/backend/app/services/agent_runtime/adapter.py +++ b/backend/app/services/agent_runtime/adapter.py @@ -11,7 +11,6 @@ from app.models.agent import Agent from app.models.agent_run import AgentRun from app.models.agent_run_command import AgentRunCommand -from app.models.llm import LLMModel from app.services.agent_runtime.config import RuntimeGateDecision, RuntimeRolloutPolicy from app.services.agent_runtime.contracts import ( CancelRunCommand, @@ -31,6 +30,7 @@ enqueue_resume, register_run_with_start, ) +from app.services.llm.model_resolution import load_active_model, resolve_active_agent_model class RuntimeAdapterError(RuntimeError): @@ -97,7 +97,10 @@ async def _get_run(self, *, tenant_id: uuid.UUID, run_id: uuid.UUID) -> AgentRun raise RuntimeAdapterError("run_scope_mismatch", "loaded Run is outside the requested tenant") return run - async def _configured_model_turn_limit(self, command: StartRunCommand) -> int | None: + async def _configured_model_turn_limit( + self, + command: StartRunCommand, + ) -> tuple[int | None, Agent | None]: """Resolve the immutable Run budget without a Runtime-side fallback.""" requested = command.requested_model_turn_limit if requested is not None and ( @@ -116,7 +119,7 @@ async def _configured_model_turn_limit(self, command: StartRunCommand) -> int | "invalid_requested_model_turn_limit", "Planning Runs use their own bounded attempt policy", ) - return None + return None, None if command.agent_id is None: raise RuntimeAdapterError( @@ -127,6 +130,7 @@ async def _configured_model_turn_limit(self, command: StartRunCommand) -> int | select(Agent).where( Agent.tenant_id == command.tenant_id, Agent.id == command.agent_id, + Agent.deleted_at.is_(None), ) ) agent = result.scalar_one_or_none() @@ -145,34 +149,37 @@ async def _configured_model_turn_limit(self, command: StartRunCommand) -> int | "invalid_agent_model_turn_limit", "Agent max_tool_rounds must be a positive model turn limit", ) - return configured if requested is None else min(configured, requested) + return (configured if requested is None else min(configured, requested)), agent - async def _require_agent_runtime_model(self, command: StartRunCommand) -> None: + async def _require_agent_runtime_model( + self, + command: StartRunCommand, + agent: Agent | None, + ) -> uuid.UUID | None: """Reject new tool-driven Agent Runs before any durable Run is created.""" if command.run_kind == "orchestration": - return - if command.model_id is None: - raise RuntimeAdapterError( - "model_required", - "Agent Runtime requires a pinned model before the Run can start", - ) - result = await self._db.execute( - select(LLMModel).where(LLMModel.id == command.model_id) + return command.model_id + model = await load_active_model( + self._db, + model_id=command.model_id, + tenant_id=command.tenant_id, ) - model = result.scalar_one_or_none() - if ( - model is None - or not model.enabled - or model.tenant_id not in {None, command.tenant_id} - ): + if model is None and agent is not None: + model = await resolve_active_agent_model( + self._db, + agent, + require_tool_calling=True, + ) + if model is None: raise RuntimeAdapterError( "model_unavailable", - "Agent Runtime model is disabled or outside the command tenant", + "Agent Runtime has no active tool-capable model in the command tenant", ) try: ModelCapabilityResolver.require_native_tool_calling(model) except ModelCapabilityError as exc: raise RuntimeAdapterError(exc.code, str(exc)) from exc + return model.id @staticmethod def _start_payload(command: StartRunCommand) -> dict: @@ -277,9 +284,10 @@ async def start_run(self, command: StartRunCommand) -> RunHandle: ) model_turn_limit = existing.model_turn_limit self._require_v2(decision) + resolved_model_id = existing.model_id if existing is not None else command.model_id if existing is None: - model_turn_limit = await self._configured_model_turn_limit(command) - await self._require_agent_runtime_model(command) + model_turn_limit, agent = await self._configured_model_turn_limit(command) + resolved_model_id = await self._require_agent_runtime_model(command, agent) elif existing.run_kind == "orchestration": if model_turn_limit is not None: raise RuntimeAdapterError( @@ -315,7 +323,7 @@ async def start_run(self, command: StartRunCommand) -> RunHandle: goal=command.goal, run_kind=command.run_kind, system_role=command.system_role, - model_id=command.model_id, + model_id=resolved_model_id, model_turn_limit=model_turn_limit, runtime_thread_id=command.runtime_thread_id, runtime_type=runtime_type, @@ -342,6 +350,19 @@ async def resume_run(self, command: ResumeRunCommand) -> RunHandle: """Persist a resume for an existing LangGraph Run without committing.""" run = await self._get_run(tenant_id=command.tenant_id, run_id=command.run_id) self._require_existing_v2(run) + if run.agent_id is not None: + agent_result = await self._db.execute( + select(Agent.id).where( + Agent.id == run.agent_id, + Agent.tenant_id == command.tenant_id, + Agent.deleted_at.is_(None), + ) + ) + if agent_result.scalar_one_or_none() is None: + raise RuntimeAdapterError( + "agent_unavailable", + "Deleted Agent Run cannot be resumed", + ) enqueued = await enqueue_resume( self._db, tenant_id=command.tenant_id, diff --git a/backend/app/services/agent_runtime/group_context_builder.py b/backend/app/services/agent_runtime/group_context_builder.py index 260111a2b..404777db6 100644 --- a/backend/app/services/agent_runtime/group_context_builder.py +++ b/backend/app/services/agent_runtime/group_context_builder.py @@ -248,6 +248,7 @@ async def capture( Agent.tenant_id == tenant_id, Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), + Agent.deleted_at.is_(None), ) ) agent = agent_result.scalar_one_or_none() diff --git a/backend/app/services/agent_runtime/group_runtime_tools.py b/backend/app/services/agent_runtime/group_runtime_tools.py index cc67e071c..43682a077 100644 --- a/backend/app/services/agent_runtime/group_runtime_tools.py +++ b/backend/app/services/agent_runtime/group_runtime_tools.py @@ -377,6 +377,7 @@ async def _query_members( Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), Agent.access_mode != "private", + Agent.deleted_at.is_(None), ) ) agents = {value.id: value for value in agent_result.scalars().all()} diff --git a/backend/app/services/agent_runtime/model_capabilities.py b/backend/app/services/agent_runtime/model_capabilities.py index 8a43001e9..69bdba1e4 100644 --- a/backend/app/services/agent_runtime/model_capabilities.py +++ b/backend/app/services/agent_runtime/model_capabilities.py @@ -235,7 +235,12 @@ async def resolve_platform_model( if model_id is None: raise PlatformModelConfigurationError(setting_name, "is not configured") - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) + ) model = result.scalar_one_or_none() if model is None: raise PlatformModelConfigurationError(setting_name, f"model {model_id} does not exist") @@ -260,7 +265,12 @@ async def resolve_group_model( if model_id is None: raise PlatformModelConfigurationError(setting_name, "is not configured") - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) + ) model = result.scalar_one_or_none() if model is None: raise PlatformModelConfigurationError(setting_name, f"model {model_id} does not exist") diff --git a/backend/app/services/agent_runtime/model_step_service.py b/backend/app/services/agent_runtime/model_step_service.py index a2cebc824..57480ab34 100644 --- a/backend/app/services/agent_runtime/model_step_service.py +++ b/backend/app/services/agent_runtime/model_step_service.py @@ -66,6 +66,7 @@ parse_tool_arguments, ) from app.services.llm.single_step import LLMCompletionStep, complete_llm_once +from app.services.llm.model_resolution import active_agent_model_candidates from app.services.llm.utils import get_max_tokens @@ -947,12 +948,18 @@ async def _load( ) from exc prior_incomplete = _prior_incomplete_tool_calls(state, current_run_id=run_id) async with self._session_factory() as db: - model_result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + model_result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + ) + ) model = model_result.scalar_one_or_none() agent_result = await db.execute( select(Agent).where( Agent.id == agent_id, Agent.tenant_id == tenant_id, + Agent.deleted_at.is_(None), ) ) agent = agent_result.scalar_one_or_none() @@ -982,6 +989,17 @@ async def _load( ) ) executions.extend(prior_execution_result.scalars().all()) + if agent is not None and ( + model is None + or not model.enabled + or model.tenant_id not in {None, tenant_id} + ): + candidates = await active_agent_model_candidates( + db, + agent, + require_tool_calling=True, + ) + model = candidates[0] if candidates else None if ( model is None or not model.enabled @@ -1025,20 +1043,13 @@ async def _fallback_model( agent: Agent, primary_model: LLMModel, ) -> LLMModel | None: - fallback_id = agent.fallback_model_id - if fallback_id is None or fallback_id == primary_model.id: - return None async with self._session_factory() as db: - result = await db.execute(select(LLMModel).where(LLMModel.id == fallback_id)) - fallback = result.scalar_one_or_none() - if ( - fallback is None - or not fallback.enabled - or fallback.tenant_id not in {None, tenant_id} - or fallback.supports_tool_calling is not True - ): - return None - return fallback + candidates = await active_agent_model_candidates( + db, + agent, + require_tool_calling=True, + ) + return next((model for model in candidates if model.id != primary_model.id), None) async def compact_inputs( self, diff --git a/backend/app/services/agent_runtime/planning.py b/backend/app/services/agent_runtime/planning.py index a8f6e1961..8438d33f6 100644 --- a/backend/app/services/agent_runtime/planning.py +++ b/backend/app/services/agent_runtime/planning.py @@ -8,8 +8,6 @@ from typing import Protocol, cast import uuid -from sqlalchemy import select - from app.models.llm import LLMModel from app.services.agent_runtime.command_worker import RuntimeSessionFactory from app.services.agent_runtime.model_capabilities import ( @@ -32,6 +30,7 @@ ) from app.services.llm.client import LLMMessage from app.services.llm.single_step import LLMCompletionStep, complete_llm_once +from app.services.llm.model_resolution import load_active_model from app.services.llm.utils import get_max_tokens @@ -295,9 +294,12 @@ async def _load_model(self, context: RuntimeContext) -> LLMModel: "Planning Run has an invalid pinned model", ) from exc async with self._session_factory() as db: - result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) - model = result.scalar_one_or_none() - if model is None or not model.enabled or model.tenant_id not in {None, tenant_id}: + model = await load_active_model( + db, + model_id=model_id, + tenant_id=tenant_id, + ) + if model is None: raise PlanningContractError( "planning_model_unavailable", "Pinned Planning model is not enabled for this Group tenant", diff --git a/backend/app/services/agent_runtime/session_context_background.py b/backend/app/services/agent_runtime/session_context_background.py index 3a8ccaa83..3ca70f606 100644 --- a/backend/app/services/agent_runtime/session_context_background.py +++ b/backend/app/services/agent_runtime/session_context_background.py @@ -36,6 +36,7 @@ SessionContextService, SessionContextSnapshot, ) +from app.services.llm.model_resolution import resolve_active_agent_model from app.services.agent_runtime.state import JsonObject from app.services.llm.utils import get_max_tokens @@ -93,18 +94,6 @@ def _model_threshold(model: LLMModel, settings: Settings) -> int: ).compact_threshold -def _usable_model( - model: LLMModel | None, - *, - tenant_id: uuid.UUID, -) -> bool: - return bool( - model is not None - and model.enabled - and model.tenant_id in {None, tenant_id} - ) - - class SessionCompactPolicyResolver: """Calculate the public Group Session trigger budget.""" @@ -172,30 +161,24 @@ async def resolve( Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), Agent.access_mode != "private", - Agent.primary_model_id.is_not(None), + Agent.deleted_at.is_(None), ) ) agents = list(agent_result.scalars().all()) - model_ids = {agent.primary_model_id for agent in agents if agent.primary_model_id} - if not model_ids: + if not agents: raise SessionContextBackgroundError( "session_compact_budget_unavailable", "Group has no valid Agent models for shared compact budgeting", ) - model_result = await db.execute( - select(LLMModel).where(LLMModel.id.in_(model_ids)) - ) - models = { - model.id: model - for model in model_result.scalars().all() - if _usable_model(model, tenant_id=tenant_id) - } - missing = model_ids - models.keys() - if missing: - raise SessionContextBackgroundError( - "session_compact_budget_unavailable", - "At least one active group Agent model is not usable", - ) + models: dict[uuid.UUID, LLMModel] = {} + for agent in agents: + model = await resolve_active_agent_model(db, agent) + if model is None: + raise SessionContextBackgroundError( + "session_compact_budget_unavailable", + "At least one active group Agent has no usable model", + ) + models[model.id] = model try: thresholds = { model.id: _model_threshold(model, self._settings) diff --git a/backend/app/services/agent_runtime/session_context_compactor.py b/backend/app/services/agent_runtime/session_context_compactor.py index a6b0671e6..81b8b44db 100644 --- a/backend/app/services/agent_runtime/session_context_compactor.py +++ b/backend/app/services/agent_runtime/session_context_compactor.py @@ -31,6 +31,7 @@ ) from app.services.agent_runtime.state import JsonObject, JsonValue from app.services.llm.client import LLMMessage +from app.services.llm.model_resolution import resolve_active_agent_model from app.services.llm.single_step import LLMCompletionStep, complete_llm_once from app.services.llm.utils import get_max_tokens @@ -278,18 +279,6 @@ def __init__( self._completion = completion self._model_resolver = model_resolver or self._resolve_models - @staticmethod - def _usable_model( - model: LLMModel | None, - *, - tenant_id: uuid.UUID, - ) -> LLMModel | None: - if model is None or not model.enabled: - return None - if model.tenant_id not in {None, tenant_id}: - return None - return model - async def _resolve_models( self, request: SessionCompactRequest, @@ -328,25 +317,20 @@ async def _resolve_models( select(Agent).where( Agent.id == session.agent_id, Agent.tenant_id == request.tenant_id, + Agent.deleted_at.is_(None), ) ) agent = agent_result.scalar_one_or_none() - if agent is None or agent.primary_model_id is None: + if agent is None: raise SessionContextCompactorError( "session_compact_model_unavailable", - "Session Agent has no current primary model", + "Session Agent is unavailable", ) - primary_result = await db.execute( - select(LLMModel).where(LLMModel.id == agent.primary_model_id) - ) - primary = self._usable_model( - primary_result.scalar_one_or_none(), - tenant_id=request.tenant_id, - ) + primary = await resolve_active_agent_model(db, agent) if primary is None: raise SessionContextCompactorError( "session_compact_model_unavailable", - "Session Agent primary model is not usable", + "Session Agent has no usable model", ) return CompactModelSelection( primary=primary, diff --git a/backend/app/services/agent_runtime/tool_step_service.py b/backend/app/services/agent_runtime/tool_step_service.py index 34612fffb..69d9e5218 100644 --- a/backend/app/services/agent_runtime/tool_step_service.py +++ b/backend/app/services/agent_runtime/tool_step_service.py @@ -517,6 +517,7 @@ async def _agent( select(Agent).where( Agent.id == agent_id, Agent.tenant_id == tenant_id, + Agent.deleted_at.is_(None), ) ) agent = result.scalar_one_or_none() diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index e21712d28..7f511b1fe 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -20017,7 +20017,12 @@ async def _get_agent_owner_info(agent_id: uuid.UUID) -> tuple[str, str]: from sqlalchemy import select as _select async with async_session() as db: - result = await db.execute(_select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + _select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return "agent", str(agent_id) @@ -20418,7 +20423,12 @@ async def _get_okr(agent_id: uuid.UUID | None, arguments: dict) -> str: async with async_session() as db: # Look up the agent's tenant - agent_result = await db.execute(_select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + _select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: return "Agent not found." @@ -20585,7 +20595,12 @@ async def _get_my_okr(agent_id: uuid.UUID | None, arguments: dict) -> str: from sqlalchemy import select as _select async with async_session() as db: - agent_result = await db.execute(_select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + _select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: return "Agent not found." diff --git a/backend/app/services/channel_session.py b/backend/app/services/channel_session.py index 8557bf792..4c0f2ac77 100644 --- a/backend/app/services/channel_session.py +++ b/backend/app/services/channel_session.py @@ -55,7 +55,12 @@ async def find_or_create_channel_session( "External channel and conversation ID are required", ) - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent is None or agent.tenant_id is None: raise ChannelSessionError( diff --git a/backend/app/services/chat_session_service.py b/backend/app/services/chat_session_service.py index c19e667f3..cb28e8aae 100644 --- a/backend/app/services/chat_session_service.py +++ b/backend/app/services/chat_session_service.py @@ -329,7 +329,12 @@ async def get_primary_platform_session( user_id: uuid.UUID, ) -> ChatSession | None: """Compatibility wrapper for callers that do not yet pass tenant identity.""" - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent is None or agent.tenant_id is None: return None @@ -342,7 +347,12 @@ async def ensure_primary_platform_session( user_id: uuid.UUID, ) -> ChatSession: """Compatibility wrapper that resolves tenant and creator Participant first.""" - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent is None or agent.tenant_id is None: raise ValueError("agent must belong to a tenant") diff --git a/backend/app/services/collaboration.py b/backend/app/services/collaboration.py index 9cc46db93..aac7527ae 100644 --- a/backend/app/services/collaboration.py +++ b/backend/app/services/collaboration.py @@ -1,6 +1,5 @@ """Agent collaboration service — Agent-to-Agent communication.""" -import json import uuid from datetime import datetime, timezone @@ -30,9 +29,19 @@ async def delegate_task( from app.models.task import Task # Verify both agents exist and are running - from_result = await db.execute(select(Agent).where(Agent.id == from_agent_id)) + from_result = await db.execute( + select(Agent).where( + Agent.id == from_agent_id, + Agent.deleted_at.is_(None), + ) + ) from_agent = from_result.scalar_one_or_none() - to_result = await db.execute(select(Agent).where(Agent.id == to_agent_id)) + to_result = await db.execute( + select(Agent).where( + Agent.id == to_agent_id, + Agent.deleted_at.is_(None), + ) + ) to_agent = to_result.scalar_one_or_none() if not from_agent or not to_agent: @@ -77,7 +86,12 @@ async def list_collaborators(self, db: AsyncSession, agent_id: uuid.UUID) -> lis Returns agents from the same enterprise (same creator's org). """ - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return [] @@ -87,6 +101,7 @@ async def list_collaborators(self, db: AsyncSession, agent_id: uuid.UUID) -> lis select(Agent).where( Agent.id != agent_id, Agent.status.in_(["running", "stopped"]), + Agent.deleted_at.is_(None), ).order_by(Agent.name) ) agents = collaborators_result.scalars().all() @@ -109,15 +124,29 @@ async def send_message_between_agents( msg_type: 'notify' (fire-and-forget) or 'consult' (expects reply) """ - from_result = await db.execute(select(Agent).where(Agent.id == from_agent_id)) + from_result = await db.execute( + select(Agent).where( + Agent.id == from_agent_id, + Agent.deleted_at.is_(None), + ) + ) from_agent = from_result.scalar_one_or_none() + to_result = await db.execute( + select(Agent).where( + Agent.id == to_agent_id, + Agent.deleted_at.is_(None), + ) + ) + to_agent = to_result.scalar_one_or_none() + if from_agent is None or to_agent is None: + raise ValueError("Agent not found") timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") rel_path = f"workspace/inbox/{timestamp}_{str(from_agent_id)[:8]}.md" await store_agent_bytes( to_agent_id, rel_path, - f"# 来自 {from_agent.name if from_agent else 'Unknown'} 的消息\n" + f"# 来自 {from_agent.name} 的消息\n" f"- 类型: {msg_type}\n" f"- 时间: {datetime.now(timezone.utc).isoformat()}\n\n" f"{message}\n".encode("utf-8"), diff --git a/backend/app/services/enterprise_sync.py b/backend/app/services/enterprise_sync.py index a67523215..81cdb47b6 100644 --- a/backend/app/services/enterprise_sync.py +++ b/backend/app/services/enterprise_sync.py @@ -87,7 +87,12 @@ async def sync_to_agent(self, db: AsyncSession, agent_id: uuid.UUID, agent_role: async def sync_to_all_agents(self, db: AsyncSession) -> int: """Sync enterprise info to all running agents. Returns count.""" - result = await db.execute(select(Agent).where(Agent.status == "running")) + result = await db.execute( + select(Agent).where( + Agent.status == "running", + Agent.deleted_at.is_(None), + ) + ) agents = result.scalars().all() for agent in agents: diff --git a/backend/app/services/experience_retrieval.py b/backend/app/services/experience_retrieval.py index e935b59ef..dfc798090 100644 --- a/backend/app/services/experience_retrieval.py +++ b/backend/app/services/experience_retrieval.py @@ -24,10 +24,10 @@ from app.models.agent import Agent from app.models.experience import ExperienceEntry from app.models.experience_reference import ExperienceReference -from app.models.llm import LLMModel from app.models.org import OrgMember from app.models.system_settings import SystemSetting from app.services.agent_runtime.tool_execution import ToolExecutionOutcome +from app.services.llm.model_resolution import resolve_active_agent_model # Agents echo this marker in their final answer to cite an entry they actually used. CITATION_RE = re.compile(r"\[\[exp:([0-9a-fA-F-]{36})\]\]") @@ -78,7 +78,14 @@ def _token_needles(token: str) -> list[str]: async def _resolve_agent(db, agent_id: uuid.UUID) -> Agent | None: - return (await db.execute(select(Agent).where(Agent.id == agent_id))).scalar_one_or_none() + return ( + await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() async def _agent_department_ids(db, agent: Agent) -> set[uuid.UUID]: @@ -197,8 +204,7 @@ async def _expand_query(db, agent, keyword: str) -> list[str]: return _EXPANSION_CACHE[key] terms: list[str] = [] try: - model_id = agent.primary_model_id or agent.fallback_model_id - model = (await db.execute(select(LLMModel).where(LLMModel.id == model_id))).scalar_one_or_none() if model_id else None + model = await resolve_active_agent_model(db, agent) if model: from app.services.llm import get_model_api_key from app.services.llm.client import chat_complete diff --git a/backend/app/services/group_chat_service.py b/backend/app/services/group_chat_service.py index ca7d66d56..67157f2c4 100644 --- a/backend/app/services/group_chat_service.py +++ b/backend/app/services/group_chat_service.py @@ -132,6 +132,7 @@ async def _valid_participant( Agent.tenant_id == tenant_id, Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), + Agent.deleted_at.is_(None), ) ) @@ -618,6 +619,7 @@ async def invite_group_member( select(Agent).where( Agent.id == target.ref_id, Agent.tenant_id == tenant_id, + Agent.deleted_at.is_(None), ) ) target_agent = target_agent_result.scalar_one_or_none() diff --git a/backend/app/services/group_message_service.py b/backend/app/services/group_message_service.py index dc5400db6..fec9a90b3 100644 --- a/backend/app/services/group_message_service.py +++ b/backend/app/services/group_message_service.py @@ -17,6 +17,7 @@ from app.models.group import Group, GroupMember from app.models.llm import LLMModel from app.models.participant import Participant +from app.models.tenant import Tenant from app.models.user import User from app.services.agent_runtime.adapter import ( RuntimeAdapterError, @@ -203,6 +204,7 @@ async def _load_sender_scope( Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), Agent.access_mode != "private", + Agent.deleted_at.is_(None), ) ) if agent_result.scalar_one_or_none() is None: @@ -295,15 +297,31 @@ async def _resolve_mentions( Agent.status.in_(_ACTIVE_AGENT_STATUSES), Agent.is_expired.is_(False), Agent.access_mode != "private", + Agent.deleted_at.is_(None), ) ) agents = {agent.id: agent for agent in agent_result.scalars().all()} - model_ids = {agent.primary_model_id for agent in agents.values() if agent.primary_model_id} + default_result = await db.execute( + select(Tenant.default_model_id).where(Tenant.id == tenant_id) + ) + default_model_id = default_result.scalar_one_or_none() + model_ids = { + model_id + for agent in agents.values() + for model_id in ( + agent.primary_model_id, + agent.fallback_model_id, + default_model_id, + ) + if model_id is not None + } if model_ids: model_result = await db.execute( select(LLMModel).where( LLMModel.id.in_(model_ids), + LLMModel.deleted_at.is_(None), LLMModel.enabled.is_(True), + LLMModel.supports_tool_calling.is_(True), ) ) models = { @@ -344,7 +362,18 @@ async def _resolve_mentions( if agent is None: output.append(_invalid_mention(participant_id, reason="agent_unavailable")) continue - model = models.get(agent.primary_model_id) if agent.primary_model_id is not None else None + model = next( + ( + models[model_id] + for model_id in ( + agent.primary_model_id, + agent.fallback_model_id, + default_model_id, + ) + if model_id in models + ), + None, + ) if model is None: output.append(_invalid_mention(participant_id, reason="agent_model_unavailable")) continue diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py index 05b28df0f..a42e8cd8c 100644 --- a/backend/app/services/heartbeat.py +++ b/backend/app/services/heartbeat.py @@ -206,6 +206,7 @@ async def _heartbeat_tick(): select(Agent).where( Agent.heartbeat_enabled.is_(True), Agent.status.in_(["running", "idle"]), + Agent.deleted_at.is_(None), ) ) agents = result.scalars().all() @@ -391,7 +392,12 @@ async def run_agent_oneshot( from app.database import async_session from app.models.agent import Agent async with async_session() as db: - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: logger.warning(f"[Oneshot] Agent {agent_id} not found — aborting") diff --git a/backend/app/services/llm/caller.py b/backend/app/services/llm/caller.py index 85c075f60..f74580c39 100644 --- a/backend/app/services/llm/caller.py +++ b/backend/app/services/llm/caller.py @@ -29,6 +29,7 @@ extract_token_usage, estimate_token_usage_from_chars, ) +from app.services.llm.model_resolution import active_agent_model_candidates from .client import LLMError from .failover import classify_error, FailoverErrorType @@ -890,11 +891,15 @@ async def call_agent_llm( ) -> str: """Call the agent's LLM with automatic failover support.""" from app.models.agent import Agent - from app.models.llm import LLMModel from app.core.permissions import is_agent_expired # Load agent - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent: Agent | None = agent_result.scalar_one_or_none() if not agent: return "⚠️ 数字员工未找到" @@ -902,26 +907,12 @@ async def call_agent_llm( if is_agent_expired(agent): return "This Agent has expired and is off duty. Please contact your admin to extend its service." - # Load primary model - primary_model: LLMModel | None = None - if agent.primary_model_id: - model_result = await db.execute(select(LLMModel).where(LLMModel.id == agent.primary_model_id)) - primary_model = model_result.scalar_one_or_none() - - # Load fallback model - fallback_model: LLMModel | None = None - if agent.fallback_model_id: - fb_result = await db.execute(select(LLMModel).where(LLMModel.id == agent.fallback_model_id)) - fallback_model = fb_result.scalar_one_or_none() - - # Config-level fallback: primary missing -> use fallback - if not primary_model and fallback_model: - primary_model = fallback_model - fallback_model = None - logger.warning(f"[call_agent_llm] Primary model unavailable, using fallback: {primary_model.model}") + candidates = await active_agent_model_candidates(db, agent) + primary_model = candidates[0] if candidates else None + fallback_model = candidates[1] if len(candidates) > 1 else None if not primary_model: - return f"⚠️ {agent.name} 未配置 LLM 模型,请在管理后台设置。" + return f"⚠️ {agent.name} 没有可用的 LLM 模型,请在管理后台设置。" # Build conversation messages messages: list[dict] = [] diff --git a/backend/app/services/llm/model_resolution.py b/backend/app/services/llm/model_resolution.py new file mode 100644 index 000000000..eddcfca26 --- /dev/null +++ b/backend/app/services/llm/model_resolution.py @@ -0,0 +1,128 @@ +"""Shared Active-model resolution for Agent calls.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.agent import Agent +from app.models.llm import LLMModel +from app.models.tenant import Tenant + + +def _is_usable( + model: LLMModel, + *, + tenant_id: uuid.UUID | None, + require_tool_calling: bool, +) -> bool: + if getattr(model, "deleted_at", None) is not None or not model.enabled: + return False + if model.tenant_id not in {None, tenant_id}: + return False + if require_tool_calling and model.supports_tool_calling is not True: + return False + return True + + +async def load_active_model( + db: AsyncSession, + *, + model_id: uuid.UUID | None, + tenant_id: uuid.UUID | None, + require_tool_calling: bool = False, +) -> LLMModel | None: + """Load one enabled, non-deleted model valid for the requested tenant.""" + if model_id is None: + return None + result = await db.execute( + select(LLMModel).where( + LLMModel.id == model_id, + LLMModel.deleted_at.is_(None), + LLMModel.enabled.is_(True), + or_(LLMModel.tenant_id.is_(None), LLMModel.tenant_id == tenant_id), + ) + ) + model = result.scalar_one_or_none() + if model is None or not _is_usable( + model, + tenant_id=tenant_id, + require_tool_calling=require_tool_calling, + ): + return None + return model + + +async def active_agent_model_candidates( + db: AsyncSession, + agent: Agent, + *, + require_tool_calling: bool = False, +) -> tuple[LLMModel, ...]: + """Resolve primary, fallback, then tenant default without rewriting stored IDs.""" + if getattr(agent, "deleted_at", None) is not None: + return () + + default_model_id: uuid.UUID | None = None + if agent.tenant_id is not None: + default_result = await db.execute( + select(Tenant.default_model_id).where(Tenant.id == agent.tenant_id) + ) + default_model_id = default_result.scalar_one_or_none() + + candidate_ids = tuple( + dict.fromkeys( + model_id + for model_id in ( + agent.primary_model_id, + agent.fallback_model_id, + default_model_id, + ) + if model_id is not None + ) + ) + if not candidate_ids: + return () + + result = await db.execute( + select(LLMModel).where( + LLMModel.id.in_(candidate_ids), + LLMModel.deleted_at.is_(None), + LLMModel.enabled.is_(True), + or_(LLMModel.tenant_id.is_(None), LLMModel.tenant_id == agent.tenant_id), + ) + ) + models_by_id = {model.id: model for model in result.scalars().all()} + return tuple( + model + for model_id in candidate_ids + if (model := models_by_id.get(model_id)) is not None + and _is_usable( + model, + tenant_id=agent.tenant_id, + require_tool_calling=require_tool_calling, + ) + ) + + +async def resolve_active_agent_model( + db: AsyncSession, + agent: Agent, + *, + require_tool_calling: bool = False, +) -> LLMModel | None: + candidates = await active_agent_model_candidates( + db, + agent, + require_tool_calling=require_tool_calling, + ) + return candidates[0] if candidates else None + + +__all__ = [ + "active_agent_model_candidates", + "load_active_model", + "resolve_active_agent_model", +] diff --git a/backend/app/services/okr_agent_hook.py b/backend/app/services/okr_agent_hook.py index e17cb8f4f..ecf3c6394 100644 --- a/backend/app/services/okr_agent_hook.py +++ b/backend/app/services/okr_agent_hook.py @@ -76,7 +76,10 @@ async def hook_new_agent(db: AsyncSession, new_agent_id: uuid.UUID, tenant_id: u """When a new company-visible agent is created, bind to OKR Agent.""" agent_res = await db.execute( select(Agent) - .where(Agent.id == new_agent_id) + .where( + Agent.id == new_agent_id, + Agent.deleted_at.is_(None), + ) ) agent = agent_res.scalar_one_or_none() if not agent or getattr(agent, "is_system", False): @@ -124,7 +127,8 @@ async def _get_okr_agent(db: AsyncSession, tenant_id: uuid.UUID) -> Agent | None select(Agent).where( Agent.tenant_id == tenant_id, Agent.is_system == True, - Agent.name == "OKR Agent" + Agent.name == "OKR Agent", + Agent.deleted_at.is_(None), ).limit(1) ) return res.scalar_one_or_none() diff --git a/backend/app/services/okr_daily_collection.py b/backend/app/services/okr_daily_collection.py index 9de89d6b6..bbecb3295 100644 --- a/backend/app/services/okr_daily_collection.py +++ b/backend/app/services/okr_daily_collection.py @@ -116,7 +116,12 @@ async def trigger_daily_collection_for_tenant(tenant_id: uuid.UUID) -> dict: if not settings.okr_agent_id: raise ValueError("OKR Agent not found for this tenant") - okr_agent_result = await db.execute(select(Agent).where(Agent.id == settings.okr_agent_id)) + okr_agent_result = await db.execute( + select(Agent).where( + Agent.id == settings.okr_agent_id, + Agent.deleted_at.is_(None), + ) + ) okr_agent = okr_agent_result.scalar_one_or_none() if not okr_agent: raise ValueError("OKR Agent not found for this tenant") @@ -148,6 +153,7 @@ async def trigger_daily_collection_for_tenant(tenant_id: uuid.UUID) -> dict: AgentAgentRelationship.agent_id == okr_agent.id, Agent.is_system == False, # noqa: E712 Agent.status.notin_(["stopped", "error"]), + Agent.deleted_at.is_(None), ) ) tracked_agents = agent_rel_result.scalars().all() diff --git a/backend/app/services/okr_reporting.py b/backend/app/services/okr_reporting.py index ccaaa413b..49f4e47bd 100644 --- a/backend/app/services/okr_reporting.py +++ b/backend/app/services/okr_reporting.py @@ -28,6 +28,7 @@ from app.models.org import AgentAgentRelationship, AgentRelationship, OrgMember from app.models.user import User from app.services.llm.client import chat_complete +from app.services.llm.model_resolution import active_agent_model_candidates from app.services.llm.utils import get_model_api_key, get_max_tokens @@ -122,28 +123,20 @@ async def _resolve_report_models(tenant_id: uuid.UUID) -> ResolvedReportModels: if not settings or not settings.okr_agent_id: return ResolvedReportModels(primary=None, fallback=None, okr_agent_id=None) - agent_result = await db.execute(select(Agent).where(Agent.id == settings.okr_agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == settings.okr_agent_id, + Agent.tenant_id == tenant_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if not agent: return ResolvedReportModels(primary=None, fallback=None, okr_agent_id=settings.okr_agent_id) - primary: LLMModel | None = None - fallback: LLMModel | None = None - - if agent.primary_model_id: - primary_result = await db.execute( - select(LLMModel).where(LLMModel.id == agent.primary_model_id) - ) - primary = primary_result.scalar_one_or_none() - - if agent.fallback_model_id: - fallback_result = await db.execute( - select(LLMModel).where(LLMModel.id == agent.fallback_model_id) - ) - fallback = fallback_result.scalar_one_or_none() - - if not primary and fallback: - primary, fallback = fallback, None + candidates = await active_agent_model_candidates(db, agent) + primary = candidates[0] if candidates else None + fallback = candidates[1] if len(candidates) > 1 else None return ResolvedReportModels( primary=primary, @@ -166,6 +159,7 @@ async def list_company_members(tenant_id: uuid.UUID) -> list[CompanyMember]: Agent.tenant_id == tenant_id, Agent.is_system == False, # noqa: E712 Agent.status.notin_(["stopped", "error"]), + Agent.deleted_at.is_(None), ) ) @@ -222,6 +216,7 @@ async def list_tracked_okr_members(tenant_id: uuid.UUID) -> list[CompanyMember]: AgentAgentRelationship.agent_id == settings.okr_agent_id, Agent.is_system == False, # noqa: E712 Agent.status.notin_(["stopped", "error"]), + Agent.deleted_at.is_(None), ) ) diff --git a/backend/app/services/okr_scheduler.py b/backend/app/services/okr_scheduler.py index cee7ee222..262dc8a11 100644 --- a/backend/app/services/okr_scheduler.py +++ b/backend/app/services/okr_scheduler.py @@ -129,6 +129,7 @@ async def collect_all_focus_updates( select(Agent).where( Agent.tenant_id == tenant_id, Agent.id != okr_agent_id, + Agent.deleted_at.is_(None), ) ) agents = agents_result.scalars().all() diff --git a/backend/app/services/quota_guard.py b/backend/app/services/quota_guard.py index e84027f1f..008a8b39a 100644 --- a/backend/app/services/quota_guard.py +++ b/backend/app/services/quota_guard.py @@ -89,7 +89,12 @@ async def check_agent_expired(agent_id: uuid.UUID) -> None: from app.models.agent import Agent async with async_session() as db: - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return @@ -118,7 +123,12 @@ async def check_agent_llm_quota(agent_id: uuid.UUID) -> None: from app.models.agent import Agent async with async_session() as db: - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return @@ -144,7 +154,12 @@ async def increment_agent_llm_usage(agent_id: uuid.UUID) -> None: from app.models.agent import Agent async with async_session() as db: - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return @@ -179,6 +194,7 @@ async def check_agent_creation_quota(user_id: uuid.UUID) -> None: select(sa_func.count()).select_from(Agent).where( Agent.creator_id == user_id, Agent.is_expired == False, + Agent.deleted_at.is_(None), ) ) current_count = count_result.scalar() or 0 @@ -219,6 +235,7 @@ async def _enforce(session, floor_val): select(Agent).where( Agent.tenant_id == tenant_id, Agent.heartbeat_interval_minutes < floor_val, + Agent.deleted_at.is_(None), ) ) agents = agents_result.scalars().all() diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 8aeb8eaf2..c97754d02 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -56,7 +56,10 @@ async def _tick(): if occurrence_at is None: continue agent_result = await db.execute( - select(Agent).where(Agent.id == sched.agent_id) + select(Agent).where( + Agent.id == sched.agent_id, + Agent.deleted_at.is_(None), + ) ) agent = agent_result.scalar_one_or_none() if ( diff --git a/backend/app/services/skill_seeder.py b/backend/app/services/skill_seeder.py index e04087356..dc81b6ec3 100644 --- a/backend/app/services/skill_seeder.py +++ b/backend/app/services/skill_seeder.py @@ -1061,7 +1061,9 @@ async def push_default_skills_to_existing_agents(): return # Load all agents - agents_r = await db.execute(select(Agent)) + agents_r = await db.execute( + select(Agent).where(Agent.deleted_at.is_(None)) + ) agents = agents_r.scalars().all() pushed = 0 diff --git a/backend/app/services/task_executor.py b/backend/app/services/task_executor.py index ddaee6f64..69d792314 100644 --- a/backend/app/services/task_executor.py +++ b/backend/app/services/task_executor.py @@ -140,7 +140,12 @@ async def _try_enqueue_runtime_task( "task_not_found", "Task does not exist for the requested Agent", ) - agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent_result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = agent_result.scalar_one_or_none() if agent is None: raise TaskRuntimeIntakeError( diff --git a/backend/app/services/timezone_utils.py b/backend/app/services/timezone_utils.py index 8a2696393..8f3398987 100644 --- a/backend/app/services/timezone_utils.py +++ b/backend/app/services/timezone_utils.py @@ -1,8 +1,8 @@ """Timezone utilities for resolving agent and tenant timezones.""" import uuid +from datetime import datetime from zoneinfo import ZoneInfo -from datetime import datetime, timezone from sqlalchemy import select @@ -41,7 +41,12 @@ async def get_agent_timezone(agent_id: uuid.UUID) -> str: from app.models.tenant import Tenant async with async_session() as db: - result = await db.execute(select(Agent).where(Agent.id == agent_id)) + result = await db.execute( + select(Agent).where( + Agent.id == agent_id, + Agent.deleted_at.is_(None), + ) + ) agent = result.scalar_one_or_none() if not agent: return "UTC" diff --git a/backend/app/services/trigger_runtime/intake.py b/backend/app/services/trigger_runtime/intake.py index a1ad44815..3b06663fd 100644 --- a/backend/app/services/trigger_runtime/intake.py +++ b/backend/app/services/trigger_runtime/intake.py @@ -344,6 +344,7 @@ async def load_trigger_agent( result = await db.execute( select(Agent).where( Agent.id == trigger.agent_id, + Agent.deleted_at.is_(None), ) ) return result.scalar_one_or_none() diff --git a/backend/tests/test_agent_delete_api.py b/backend/tests/test_agent_delete_api.py index 248f8b40f..398a6c3ec 100644 --- a/backend/tests/test_agent_delete_api.py +++ b/backend/tests/test_agent_delete_api.py @@ -1,27 +1,17 @@ -import json import uuid from datetime import UTC, datetime -from types import SimpleNamespace import pytest -from sqlalchemy.exc import IntegrityError from app.api import agents as agents_api from app.models.agent import Agent +from app.models.audit import AuditLog from app.models.user import User -class _NestedTransaction: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - class DummyResult: - def __init__(self, values=None): - self._values = list(values or []) + def __init__(self, values=()): + self._values = list(values) def scalar_one_or_none(self): return self._values[0] if self._values else None @@ -34,92 +24,41 @@ def all(self): class RecordingDB: - def __init__(self, *, required_cleanup: list[str], responses=None): - self.required_cleanup = required_cleanup - self.responses = list(responses or []) - self.executed_sql: list[str] = [] + def __init__(self, responses=()): + self.responses = list(responses) + self.added: list[object] = [] + self.executed: list[object] = [] self.deleted: list[object] = [] - self.committed = False - - def begin_nested(self): - return _NestedTransaction() + self.commit_count = 0 async def execute(self, statement, params=None): - sql = getattr(statement, "text", str(statement)) - self.executed_sql.append(sql) + self.executed.append(statement) if self.responses: return self.responses.pop(0) return DummyResult() - async def delete(self, obj): - self.deleted.append(obj) - missing_cleanup = [sql for sql in self.required_cleanup if sql not in self.executed_sql] - if missing_cleanup: - raise IntegrityError( - statement="DELETE FROM agents WHERE id = :aid", - params={"aid": getattr(obj, "id", None)}, - orig=Exception(f"missing cleanup: {missing_cleanup}"), - ) + def add(self, value): + self.added.append(value) - async def commit(self): - self.committed = True - - -class TaskCleanupDB(RecordingDB): - def __init__(self): - super().__init__( - required_cleanup=[ - "UPDATE chat_messages SET agent_id = NULL WHERE agent_id = :aid", - "UPDATE chat_sessions SET agent_id = NULL, is_primary = false, deleted_at = COALESCE(deleted_at, now()) WHERE agent_id = :aid", - "UPDATE chat_sessions SET peer_agent_id = NULL, is_primary = false, deleted_at = COALESCE(deleted_at, now()) WHERE peer_agent_id = :aid", - "UPDATE group_members SET removed_at = COALESCE(removed_at, now()) WHERE participant_id IN (SELECT id FROM participants WHERE type = 'agent' AND ref_id = :aid)", - "DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE agent_id = :aid)", - "DELETE FROM tasks WHERE agent_id = :aid", - "DELETE FROM published_pages WHERE agent_id = :aid", - "DELETE FROM notifications WHERE agent_id = :aid", - ] - ) - self.task_rows_remaining = 1 - self.task_logs_remaining = 1 + async def delete(self, value): + self.deleted.append(value) + raise AssertionError("logical deletion must not call db.delete") - async def execute(self, statement, params=None): - sql = getattr(statement, "text", str(statement)) - self.executed_sql.append(sql) - - if sql == "DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE agent_id = :aid)": - self.task_logs_remaining = 0 - elif sql == "DELETE FROM tasks WHERE agent_id = :aid": - if self.task_logs_remaining: - raise IntegrityError( - statement=sql, - params=params, - orig=Exception("task_logs.task_id foreign key still blocks task deletion"), - ) - self.task_rows_remaining = 0 + async def flush(self): + return None - if self.responses: - return self.responses.pop(0) - return DummyResult() - - async def delete(self, obj): - if self.task_rows_remaining: - raise IntegrityError( - statement="DELETE FROM agents WHERE id = :aid", - params={"aid": getattr(obj, "id", None)}, - orig=Exception("tasks.agent_id foreign key still blocks agent deletion"), - ) - - await super().delete(obj) + async def commit(self): + self.commit_count += 1 -def make_user(**overrides): +def make_user(**overrides) -> User: values = { "id": uuid.uuid4(), "username": "alice", "email": "alice@example.com", "password_hash": "hashed", "display_name": "Alice", - "role": "member", + "role": "org_admin", "tenant_id": uuid.uuid4(), "is_active": True, } @@ -127,12 +66,13 @@ def make_user(**overrides): return User(**values) -def make_agent(creator_id: uuid.UUID, **overrides): +def make_agent(user: User, **overrides) -> Agent: values = { "id": uuid.uuid4(), "name": "Ops Bot", "role_description": "assistant", - "creator_id": creator_id, + "creator_id": user.id, + "tenant_id": user.tenant_id, "status": "idle", "agent_type": "native", } @@ -141,83 +81,99 @@ def make_agent(creator_id: uuid.UUID, **overrides): @pytest.mark.asyncio -async def test_delete_agent_cleans_remaining_foreign_key_rows(monkeypatch): +async def test_delete_agent_marks_deleted_and_preserves_history(monkeypatch): creator = make_user() - agent = make_agent(creator.id) - db = TaskCleanupDB() - - async def fake_check_agent_access(_db, _current_user, _agent_id): + agent = make_agent(creator) + unfinished_run_id = uuid.uuid4() + db = RecordingDB(responses=[DummyResult([unfinished_run_id]), DummyResult()]) + cancel_calls: list[dict] = [] + remove_calls: list[uuid.UUID] = [] + + async def fake_check_agent_access(_db, _user, _agent_id, *, include_deleted=False): + assert include_deleted is True return agent, "manage" + async def fake_enqueue_cancel(_db, **kwargs): + cancel_calls.append(kwargs) + class FakeAgentManager: - async def remove_container(self, _agent): - return None + async def remove_container(self, value): + remove_calls.append(value.id) + return True async def archive_agent_files(self, _agent_id): - return None + raise AssertionError("logical deletion must not archive Workspace") monkeypatch.setattr(agents_api, "check_agent_access", fake_check_agent_access) - monkeypatch.setattr(agents_api, "is_agent_creator", lambda _user, _agent: True) - monkeypatch.setattr("app.services.agent_manager.agent_manager", FakeAgentManager()) - - await agents_api.delete_agent( - agent_id=agent.id, - current_user=creator, - db=db, + monkeypatch.setattr(agents_api, "enqueue_cancel", fake_enqueue_cancel, raising=False) + monkeypatch.setattr(agents_api, "agent_manager", FakeAgentManager(), raising=False) + + await agents_api.delete_agent(agent_id=agent.id, current_user=creator, db=db) + + assert agent.deleted_at is not None + assert agent.status == "stopped" + assert db.deleted == [] + assert remove_calls == [agent.id] + assert [call["run_id"] for call in cancel_calls] == [unfinished_run_id] + assert cancel_calls[0]["reason"] == "agent_deleted" + assert any( + isinstance(value, AuditLog) and value.action == "agent_deleted" + for value in db.added ) + sql = "\n".join(str(statement) for statement in db.executed) + assert "agent_run_events" in sql + assert "workspace_edit_locks" in sql + assert "DELETE FROM audit_logs" not in sql + assert "UPDATE chat_messages SET agent_id = NULL" not in sql + assert "DELETE FROM tasks" not in sql - assert db.deleted == [agent] - assert db.committed is True - assert db.executed_sql.index("DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE agent_id = :aid)") < ( - db.executed_sql.index("DELETE FROM tasks WHERE agent_id = :aid") - ) - assert "DELETE FROM chat_messages WHERE agent_id = :aid" not in db.executed_sql - assert "DELETE FROM participants WHERE type = 'agent' AND ref_id = :aid" not in db.executed_sql + +@pytest.mark.asyncio +async def test_delete_agent_is_idempotent_and_retries_runtime_cleanup(monkeypatch): + creator = make_user() + deleted_at = datetime.now(UTC) + agent = make_agent(creator, deleted_at=deleted_at, status="stopped") + db = RecordingDB(responses=[DummyResult(), DummyResult()]) + remove_calls: list[uuid.UUID] = [] + + async def fake_check_agent_access(_db, _user, _agent_id, *, include_deleted=False): + assert include_deleted is True + return agent, "manage" + + class FakeAgentManager: + async def remove_container(self, value): + remove_calls.append(value.id) + return False + + monkeypatch.setattr(agents_api, "check_agent_access", fake_check_agent_access) + monkeypatch.setattr(agents_api, "agent_manager", FakeAgentManager(), raising=False) + + await agents_api.delete_agent(agent_id=agent.id, current_user=creator, db=db) + + assert agent.deleted_at == deleted_at + assert not any(isinstance(value, AuditLog) for value in db.added) + assert remove_calls == [agent.id] @pytest.mark.asyncio -async def test_archive_agent_task_history_writes_json_snapshot(tmp_path): - agent_id = uuid.uuid4() - task_id = uuid.uuid4() - created_at = datetime.now(UTC) - - task = SimpleNamespace( - id=task_id, - title="Review PR", - description="Check lore trailers", - type="todo", - status="done", - priority="high", - assignee="self", - created_by=uuid.uuid4(), - due_date=None, - supervision_target_user_id=None, - supervision_target_name=None, - supervision_channel=None, - remind_schedule=None, - created_at=created_at, - updated_at=created_at, - completed_at=created_at, - ) - log = SimpleNamespace( - id=uuid.uuid4(), - content="Completed review and left comments", - created_at=created_at, - ) +async def test_delete_agent_keeps_logical_delete_when_container_removal_fails(monkeypatch): + creator = make_user() + agent = make_agent(creator) + db = RecordingDB(responses=[DummyResult(), DummyResult()]) - db = RecordingDB( - required_cleanup=[], - responses=[ - DummyResult([task]), - DummyResult([log]), - ], - ) + async def fake_check_agent_access(_db, _user, _agent_id, *, include_deleted=False): + assert include_deleted is True + return agent, "manage" + + class FailingAgentManager: + async def remove_container(self, _agent): + raise RuntimeError("docker unavailable") + + monkeypatch.setattr(agents_api, "check_agent_access", fake_check_agent_access) + monkeypatch.setattr(agents_api, "agent_manager", FailingAgentManager(), raising=False) - archive_dir = tmp_path / "_archived" / f"{agent_id}_20260325_120000" - archive_path = await agents_api._archive_agent_task_history(db, agent_id, archive_dir) + await agents_api.delete_agent(agent_id=agent.id, current_user=creator, db=db) - assert archive_path == archive_dir / "task_history.json" - payload = json.loads(archive_path.read_text(encoding="utf-8")) - assert payload["agent_id"] == str(agent_id) - assert payload["tasks"][0]["id"] == str(task_id) - assert payload["tasks"][0]["logs"][0]["content"] == "Completed review and left comments" + assert agent.deleted_at is not None + assert agent.status == "stopped" + assert db.commit_count >= 1 diff --git a/backend/tests/test_agent_runtime_adapter.py b/backend/tests/test_agent_runtime_adapter.py index e00064bdc..333ce7784 100644 --- a/backend/tests/test_agent_runtime_adapter.py +++ b/backend/tests/test_agent_runtime_adapter.py @@ -425,7 +425,7 @@ async def test_resume_accepts_a_shared_thread_identity_and_cancel_is_scoped_to_r ) resume = _stored_command(run, "resume") cancel = _stored_command(run, "cancel") - db = _session(run, run) + db = _session(run, run.agent_id, run) with ( patch( @@ -458,6 +458,30 @@ async def test_resume_accepts_a_shared_thread_identity_and_cancel_is_scoped_to_r assert cancelled.run_id == run.id +@pytest.mark.asyncio +async def test_resume_rejects_run_for_deleted_agent() -> None: + tenant_id = uuid.uuid4() + run = _run(tenant_id=tenant_id, agent_id=uuid.uuid4()) + db = _session(run, None) + + with patch( + "app.services.agent_runtime.adapter.enqueue_resume", + new=AsyncMock(), + ) as enqueue: + with pytest.raises(RuntimeAdapterError) as raised: + await RuntimeCommandIntake(db, settings=_settings(enabled=True)).resume_run( + ResumeRunCommand( + tenant_id=tenant_id, + run_id=run.id, + idempotency_key="resume:deleted-agent", + payload={}, + ) + ) + + assert raised.value.code == "agent_unavailable" + enqueue.assert_not_awaited() + + def test_command_intake_has_no_query_or_stream_facade() -> None: intake = RuntimeCommandIntake(_session(), settings=_settings(enabled=True)) diff --git a/backend/tests/test_agent_runtime_group_a2a_thread_contracts.py b/backend/tests/test_agent_runtime_group_a2a_thread_contracts.py index 06c66596a..06af4440e 100644 --- a/backend/tests/test_agent_runtime_group_a2a_thread_contracts.py +++ b/backend/tests/test_agent_runtime_group_a2a_thread_contracts.py @@ -459,6 +459,7 @@ async def mark_succeeded(db, **kwargs): target_agent, pair_session, source_run, + source_agent.id, source_run, None, ) diff --git a/backend/tests/test_agent_runtime_model_step_service.py b/backend/tests/test_agent_runtime_model_step_service.py index 27221b41c..e55736f64 100644 --- a/backend/tests/test_agent_runtime_model_step_service.py +++ b/backend/tests/test_agent_runtime_model_step_service.py @@ -50,9 +50,22 @@ async def execute(self, statement): def _session_factory(model: LLMModel, agent: Agent): + calls = 0 + @asynccontextmanager async def factory(): - yield _DB(model, agent) + nonlocal calls + calls += 1 + if calls == 1: + yield _DB(model, agent) + return + + class _NoFallbackDB: + async def execute(self, statement): + del statement + return _Result() + + yield _NoFallbackDB() return factory @@ -73,9 +86,12 @@ async def factory(): return class _FallbackDB: + def __init__(self) -> None: + self.results = iter((_Result(), _Result([fallback]))) + async def execute(self, statement): del statement - return _Result([fallback]) + return next(self.results) yield _FallbackDB() diff --git a/backend/tests/test_agent_runtime_session_context_background.py b/backend/tests/test_agent_runtime_session_context_background.py index 8e6e16c04..d2d57b325 100644 --- a/backend/tests/test_agent_runtime_session_context_background.py +++ b/backend/tests/test_agent_runtime_session_context_background.py @@ -121,7 +121,10 @@ async def test_group_compact_trigger_uses_the_smallest_active_agent_budget() -> _Result([session]), _Result([group_id]), _Result(agents), - _Result([large, small]), + _Result(), + _Result([small]), + _Result(), + _Result([large]), ) policy = await background.SessionCompactPolicyResolver( diff --git a/backend/tests/test_agent_runtime_session_context_compactor.py b/backend/tests/test_agent_runtime_session_context_compactor.py index 001c4cc24..751099874 100644 --- a/backend/tests/test_agent_runtime_session_context_compactor.py +++ b/backend/tests/test_agent_runtime_session_context_compactor.py @@ -122,6 +122,12 @@ def __init__(self, value) -> None: def scalar_one_or_none(self): return self.value + def scalars(self): + return self + + def all(self): + return list(self.value) if isinstance(self.value, (list, tuple)) else [] + class _DB: def __init__(self, *values) -> None: @@ -224,7 +230,7 @@ async def resolve(db_arg, settings_arg, *, tenant_id): @pytest.mark.asyncio -async def test_direct_compact_selects_current_primary_without_querying_fallback() -> None: +async def test_direct_compact_resolves_active_model_candidates() -> None: request = _request() primary = _model(request.tenant_id, name="current-primary") agent = Agent( @@ -247,7 +253,7 @@ async def test_direct_compact_selects_current_primary_without_querying_fallback( source_channel="web", is_primary=True, ) - db = _DB(direct_session, agent, primary) + db = _DB(direct_session, agent, None, [primary]) compactor = LLMSessionContextCompactor( session_factory=_session_factory(db), # type: ignore[arg-type] ) @@ -258,7 +264,7 @@ async def test_direct_compact_selects_current_primary_without_querying_fallback( assert selection.primary is primary assert selection.usage_agent_id == agent.id - assert db.calls == 3 + assert db.calls == 4 assert not db.results diff --git a/backend/tests/test_agent_visibility.py b/backend/tests/test_agent_visibility.py index 4a1400f54..339f89123 100644 --- a/backend/tests/test_agent_visibility.py +++ b/backend/tests/test_agent_visibility.py @@ -1,4 +1,5 @@ import uuid +from datetime import UTC, datetime from types import SimpleNamespace import pytest @@ -43,6 +44,7 @@ def test_build_visible_agents_query_restricts_to_same_tenant_and_non_private_age assert "agents.creator_id" in sql assert "agents.access_mode" in sql assert "agent_permissions" in sql + assert "agents.deleted_at IS NULL" in sql def test_build_visible_agents_query_platform_admin_still_uses_visibility_filters(): @@ -192,6 +194,24 @@ def test_can_use_agent_static_keeps_private_creator_only(): assert permissions.can_use_agent_static(admin, private_agent) is False +def test_deleted_agent_is_never_usable_or_contactable(): + tenant_id = uuid.uuid4() + creator_id = uuid.uuid4() + user = make_user(id=creator_id, tenant_id=tenant_id) + source = make_agent(tenant_id=tenant_id, creator_id=creator_id) + deleted = make_agent( + tenant_id=tenant_id, + creator_id=creator_id, + deleted_at=datetime.now(UTC), + ) + + assert permissions.can_use_agent_static(user, deleted) is False + visibility = permissions.evaluate_roster_agent_visibility(source, deleted) + assert visibility.visible is True + assert visibility.can_contact is False + assert visibility.unavailable_reason == "agent_deleted" + + def test_evaluate_roster_agent_visibility_matches_phase1_rules(): tenant_id = uuid.uuid4() creator_id = uuid.uuid4() diff --git a/backend/tests/test_group_api.py b/backend/tests/test_group_api.py index 294047df6..8e1ffe295 100644 --- a/backend/tests/test_group_api.py +++ b/backend/tests/test_group_api.py @@ -13,7 +13,8 @@ from app.api import groups as groups_api from app.models.audit import AuditLog from app.models.chat_session import ChatSession -from app.models.group import Group +from app.models.group import Group, GroupMember +from app.models.agent import Agent from app.models.agent_run import AgentRun from app.models.participant import Participant from app.models.user import User @@ -119,6 +120,60 @@ def test_group_invite_write_contract_only_accepts_participant_id() -> None: assert set(groups_api.InviteGroupMemberIn.model_fields) == {"participant_id"} +@pytest.mark.asyncio +async def test_member_history_marks_deleted_agent_without_hiding_identity() -> None: + tenant_id = uuid.uuid4() + agent = Agent( + id=uuid.uuid4(), + tenant_id=tenant_id, + creator_id=uuid.uuid4(), + name="Retired Analyst", + role_description="Historical role", + status="stopped", + deleted_at=NOW, + ) + participant = Participant( + id=uuid.uuid4(), + type="agent", + ref_id=agent.id, + display_name=agent.name, + ) + membership = GroupMember( + id=uuid.uuid4(), + group_id=uuid.uuid4(), + participant_id=participant.id, + role="member", + joined_at=NOW, + session_read_state={}, + ) + + class _Result: + def __init__(self, values): + self.values = values + + def scalars(self): + return self + + def all(self): + return self.values + + class _DB: + def __init__(self): + self.results = iter((_Result([participant]), _Result([agent]))) + + async def execute(self, _statement): + return next(self.results) + + output = await groups_api._member_outputs( # type: ignore[attr-defined] + _DB(), # type: ignore[arg-type] + [membership], + ) + + assert output[0].display_name == "Retired Analyst" + assert output[0].role_description == "Historical role" + assert output[0].is_deleted is True + + @pytest.mark.asyncio async def test_active_group_runs_use_exact_checkpoint_status(monkeypatch) -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_group_message_service.py b/backend/tests/test_group_message_service.py index 6f333c3ae..e35d65c3d 100644 --- a/backend/tests/test_group_message_service.py +++ b/backend/tests/test_group_message_service.py @@ -130,6 +130,7 @@ def _records(): api_key_encrypted="secret", label="Test", enabled=True, + supports_tool_calling=True, ) agent = Agent( id=uuid.uuid4(), @@ -226,6 +227,7 @@ async def test_mention_resolution_only_exposes_active_group_members() -> None: _ScalarCollection(memberships), _ScalarCollection([user]), _ScalarCollection([mention.agent]), + _ScalarCollection(), _ScalarCollection([mention.model]), ) ) diff --git a/backend/tests/test_model_logical_delete.py b/backend/tests/test_model_logical_delete.py new file mode 100644 index 000000000..9bc696c8f --- /dev/null +++ b/backend/tests/test_model_logical_delete.py @@ -0,0 +1,126 @@ +import uuid +from datetime import UTC, datetime + +import pytest + +from app.api import enterprise as enterprise_api +from app.models.agent import Agent +from app.models.audit import AuditLog +from app.models.llm import LLMModel +from app.models.user import User + + +class DummyResult: + def __init__(self, values=()): + self._values = list(values) + + def scalar_one_or_none(self): + return self._values[0] if self._values else None + + def scalars(self): + return self + + def all(self): + return list(self._values) + + +class RecordingDB: + def __init__(self, responses=()): + self.responses = list(responses) + self.added: list[object] = [] + self.executed: list[object] = [] + self.deleted: list[object] = [] + self.commit_count = 0 + + async def execute(self, statement, params=None): + self.executed.append(statement) + if self.responses: + return self.responses.pop(0) + return DummyResult() + + def add(self, value): + self.added.append(value) + + async def delete(self, value): + self.deleted.append(value) + raise AssertionError("logical deletion must not call db.delete") + + async def commit(self): + self.commit_count += 1 + + +def make_user(**overrides) -> User: + values = { + "id": uuid.uuid4(), + "username": "admin", + "email": "admin@example.com", + "password_hash": "hashed", + "display_name": "Admin", + "role": "org_admin", + "tenant_id": uuid.uuid4(), + "is_active": True, + } + values.update(overrides) + return User(**values) + + +def make_model(user: User, **overrides) -> LLMModel: + values = { + "id": uuid.uuid4(), + "tenant_id": user.tenant_id, + "provider": "openai", + "model": "gpt-test", + "api_key_encrypted": "encrypted", + "label": "Test model", + "enabled": True, + } + values.update(overrides) + return LLMModel(**values) + + +def test_agent_and_model_define_logical_delete_columns(): + assert "deleted_at" in Agent.__table__.columns + assert "deleted_at" in LLMModel.__table__.columns + + +@pytest.mark.asyncio +async def test_delete_model_marks_unavailable_without_clearing_references(): + user = make_user() + model = make_model(user) + db = RecordingDB(responses=[DummyResult([model])]) + + await enterprise_api.remove_llm_model( + model_id=model.id, + current_user=user, + db=db, + ) + + assert model.deleted_at is not None + assert model.enabled is False + assert db.deleted == [] + assert any( + isinstance(value, AuditLog) and value.action == "llm_model_deleted" + for value in db.added + ) + sql = "\n".join(str(statement) for statement in db.executed) + assert "UPDATE agents SET primary_model_id" not in sql + assert "UPDATE agents SET fallback_model_id" not in sql + + +@pytest.mark.asyncio +async def test_delete_model_is_idempotent(): + user = make_user() + deleted_at = datetime.now(UTC) + model = make_model(user, deleted_at=deleted_at, enabled=False) + db = RecordingDB(responses=[DummyResult([model])]) + + await enterprise_api.remove_llm_model( + model_id=model.id, + current_user=user, + db=db, + ) + + assert model.deleted_at == deleted_at + assert db.deleted == [] + assert not any(isinstance(value, AuditLog) for value in db.added) + assert db.commit_count == 0 diff --git a/backend/tests/test_model_resolution.py b/backend/tests/test_model_resolution.py new file mode 100644 index 000000000..026f4b6b9 --- /dev/null +++ b/backend/tests/test_model_resolution.py @@ -0,0 +1,135 @@ +import uuid +from types import SimpleNamespace + +import pytest + + +class DummyResult: + def __init__(self, values=()): + self._values = list(values) + + def scalar_one_or_none(self): + return self._values[0] if self._values else None + + def scalars(self): + return self + + def all(self): + return list(self._values) + + +class SequenceDB: + def __init__(self, responses): + self.responses = list(responses) + self.statements = [] + + async def execute(self, statement): + self.statements.append(statement) + return self.responses.pop(0) + + +def make_model(model_id, tenant_id, **overrides): + values = { + "id": model_id, + "tenant_id": tenant_id, + "enabled": True, + "deleted_at": None, + "supports_tool_calling": True, + } + values.update(overrides) + return SimpleNamespace(**values) + + +@pytest.mark.asyncio +async def test_candidates_follow_primary_fallback_tenant_default_order(): + from app.services.llm.model_resolution import active_agent_model_candidates + + tenant_id = uuid.uuid4() + primary_id = uuid.uuid4() + fallback_id = uuid.uuid4() + default_id = uuid.uuid4() + agent = SimpleNamespace( + tenant_id=tenant_id, + primary_model_id=primary_id, + fallback_model_id=fallback_id, + ) + primary = make_model(primary_id, tenant_id) + fallback = make_model(fallback_id, tenant_id) + default = make_model(default_id, tenant_id) + db = SequenceDB([ + DummyResult([default_id]), + DummyResult([default, fallback, primary]), + ]) + + candidates = await active_agent_model_candidates(db, agent) + + assert candidates == (primary, fallback, default) + assert "llm_models.deleted_at IS NULL" in str(db.statements[1]) + + +@pytest.mark.asyncio +async def test_candidates_skip_deleted_disabled_cross_tenant_and_duplicate_models(): + from app.services.llm.model_resolution import active_agent_model_candidates + + tenant_id = uuid.uuid4() + deleted_id = uuid.uuid4() + fallback_id = uuid.uuid4() + agent = SimpleNamespace( + tenant_id=tenant_id, + primary_model_id=deleted_id, + fallback_model_id=fallback_id, + ) + deleted = make_model(deleted_id, tenant_id, deleted_at=object()) + fallback = make_model(fallback_id, tenant_id) + db = SequenceDB([ + DummyResult([fallback_id]), + DummyResult([deleted, fallback]), + ]) + + candidates = await active_agent_model_candidates(db, agent) + + assert candidates == (fallback,) + + +@pytest.mark.asyncio +async def test_tool_calling_requirement_filters_incapable_candidate(): + from app.services.llm.model_resolution import active_agent_model_candidates + + tenant_id = uuid.uuid4() + primary_id = uuid.uuid4() + fallback_id = uuid.uuid4() + agent = SimpleNamespace( + tenant_id=tenant_id, + primary_model_id=primary_id, + fallback_model_id=fallback_id, + ) + primary = make_model(primary_id, tenant_id, supports_tool_calling=False) + fallback = make_model(fallback_id, tenant_id) + db = SequenceDB([ + DummyResult(), + DummyResult([primary, fallback]), + ]) + + candidates = await active_agent_model_candidates( + db, + agent, + require_tool_calling=True, + ) + + assert candidates == (fallback,) + + +@pytest.mark.asyncio +async def test_deleted_agent_has_no_model_candidates(): + from app.services.llm.model_resolution import active_agent_model_candidates + + agent = SimpleNamespace( + tenant_id=uuid.uuid4(), + primary_model_id=uuid.uuid4(), + fallback_model_id=uuid.uuid4(), + deleted_at=object(), + ) + db = SequenceDB([]) + + assert await active_agent_model_candidates(db, agent) == () + assert db.statements == [] diff --git a/backend/tests/test_websocket_runtime_chat.py b/backend/tests/test_websocket_runtime_chat.py index 793d71642..ae38fbd4f 100644 --- a/backend/tests/test_websocket_runtime_chat.py +++ b/backend/tests/test_websocket_runtime_chat.py @@ -566,15 +566,26 @@ async def test_onboarding_trigger_uses_runtime_and_advances_after_completion() - @pytest.mark.asyncio async def test_web_intake_pins_onboarding_metadata_without_a_visible_user_message() -> None: handler = _handler(_WebSocket()) - model = SimpleNamespace(id=uuid.uuid4()) + model = SimpleNamespace( + id=uuid.uuid4(), + tenant_id=handler.user.tenant_id, + enabled=True, + supports_tool_calling=True, + ) session = SimpleNamespace(title="Session 1") - agent = SimpleNamespace(id=handler.agent_id) + agent = SimpleNamespace( + id=handler.agent_id, + tenant_id=handler.user.tenant_id, + ) intake = ChatRuntimeIntake( handle=_handle(handler.user.tenant_id), message_id=uuid.uuid4(), resumed=False, ) - db = _Session({User: handler.user, ChatSession: session, LLMModel: model}) + db = _Session( + {User: handler.user, ChatSession: session, LLMModel: model}, + model, + ) onboarding = SimpleNamespace( prompt="Trusted greeting prompt", target_phase="greeted", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0aede0dbe..d98af7b02 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -550,8 +550,8 @@ "danger": { "title": "Danger Zone", "deleteAgent": "Delete Digital Employee", - "deleteWarning": "This action is irreversible. All data for this digital employee will be permanently deleted.", - "confirmDelete": "Confirm Delete", + "deleteWarning": "The digital employee will be disabled immediately. Chats, runs, tasks, audit history, and workspace files will be retained.", + "confirmDelete": "Disable Digital Employee", "typeToConfirm": "Type the agent name to confirm" }, "expiry": { @@ -1101,7 +1101,10 @@ "vision": "Vision", "temperature": "Temperature", "temperaturePlaceholder": "e.g. 0.7 or 1.0 (Leave empty for default)", - "temperatureDesc": "Leave empty to use the provider default. o1/o3 reasoning models usually require 1.0" + "temperatureDesc": "Leave empty to use the provider default. o1/o3 reasoning models usually require 1.0", + "deleteConfirm": "Disable {{name}}? Existing agents and history keep their model references; new calls will use another available model.", + "deleteDone": "Model disabled", + "deleteFailed": "Failed to delete model" }, "tools": { "title": "Global Tool Management", @@ -2145,6 +2148,7 @@ "noMessages": "No messages yet. Send one, or @ an agent to start.", "unknownSender": "Unknown member", "agentBadge": "Agent", + "deletedBadge": "Deleted", "polling": "polling", "offline": "disconnected", "announcement": "Announcement", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 4b546d804..e41f43e17 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -562,8 +562,8 @@ "danger": { "title": "危险操作", "deleteAgent": "删除数字员工", - "deleteWarning": "此操作不可逆,将永久删除此数字员工及其所有数据。", - "confirmDelete": "确认删除", + "deleteWarning": "删除后数字员工会立即停用;聊天、运行、任务、审计记录和 Workspace 文件都会保留。", + "confirmDelete": "停用数字员工", "typeToConfirm": "输入数字员工名称以确认" }, "expiry": { @@ -1224,7 +1224,10 @@ "vision": "视觉", "temperature": "模型温度", "temperaturePlaceholder": "例如 0.7 或 1.0(留空使用默认值)", - "temperatureDesc": "留空使用提供商默认值。o1/o3 推理模型通常需要 1.0" + "temperatureDesc": "留空使用提供商默认值。o1/o3 推理模型通常需要 1.0", + "deleteConfirm": "停用 {{name}}?现有数字员工和历史记录会保留模型引用,后续调用会自动选择其他可用模型。", + "deleteDone": "模型已停用", + "deleteFailed": "删除模型失败" }, "tools": { "title": "全局工具管理", @@ -2270,6 +2273,7 @@ "noMessages": "还没有消息。发一条,或 @ 一个智能体开始协作。", "unknownSender": "未知成员", "agentBadge": "智能体", + "deletedBadge": "已删除", "polling": "轮询中", "offline": "连接断开", "announcement": "群公告", diff --git a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx index 0343c7366..2eeb12665 100644 --- a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx +++ b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx @@ -93,6 +93,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { const invalidateModelCaches = () => { qc.invalidateQueries({ queryKey: ['llm-models'] }); + qc.invalidateQueries({ queryKey: ['runtime-model-settings'] }); qc.invalidateQueries({ queryKey: ['tenant', 'me'] }); qc.invalidateQueries({ queryKey: ['tenant-default-model'] }); qc.invalidateQueries({ queryKey: ['agents'] }); @@ -188,30 +189,36 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { }, }); const deleteModel = useMutation({ - mutationFn: async ({ id }: { id: string; force?: boolean }) => { - const url = `/enterprise/llm-models/${id}`; - const res = await fetch(`/api${url}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, + mutationFn: (id: string) => fetchJson(`/enterprise/llm-models/${id}`, { + method: 'DELETE', + }), + onSuccess: () => { + invalidateModelCaches(); + toast.success(t('enterprise.llm.deleteDone', 'Model disabled')); + }, + onError: (error: any) => { + toast.error(t('enterprise.llm.deleteFailed', 'Failed to delete model'), { + details: String(error?.message || error), }); - if (res.status === 409) { - const data = await res.json(); - const agents = data.detail?.agents || []; - const msg = `该模型正在被 ${agents.length} 个数字员工使用:\n\n${agents.join(', ')}\n\n仍要删除吗?(对应的模型配置会被清空)`; - if (await dialog.confirm(msg, { title: t('common.dialog.deleteModel'), danger: true, confirmLabel: t('common.confirmActions.forceDelete') })) { - const r2 = await fetch(`/api/enterprise/llm-models/${id}?force=true`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, - }); - if (!r2.ok && r2.status !== 204) throw new Error('Delete failed'); - } - return; - } - if (!res.ok && res.status !== 204) throw new Error('Delete failed'); }, - onSuccess: () => invalidateModelCaches(), }); + const confirmDeleteModel = async (model: LLMModel) => { + const confirmed = await dialog.confirm( + t( + 'enterprise.llm.deleteConfirm', + 'Disable {{name}}? Existing agents and history keep their model references; new calls will use another available model.', + { name: model.label || model.model }, + ), + { + title: t('common.dialog.deleteModel'), + danger: true, + confirmLabel: t('common.delete'), + }, + ); + if (confirmed) deleteModel.mutate(model.id); + }; + const openCreateForm = () => { setEditingModelId(null); const defaultSpec = providerOptions[0]; @@ -613,7 +620,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { }} style={{ fontSize: '12px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}> {t('enterprise.tools.edit')} - + )} diff --git a/frontend/src/pages/groups/GroupMemoryTab.tsx b/frontend/src/pages/groups/GroupMemoryTab.tsx index dea708d82..beaa90926 100644 --- a/frontend/src/pages/groups/GroupMemoryTab.tsx +++ b/frontend/src/pages/groups/GroupMemoryTab.tsx @@ -22,7 +22,9 @@ export default function GroupMemoryTab({ members: GroupMember[]; }) { const { t } = useTranslation(); - const agents = members.filter((member) => member.participant_type === 'agent'); + const agents = members.filter( + (member) => member.participant_type === 'agent' && !member.is_deleted, + ); const [agentRefId, setAgentRefId] = useState(agents[0]?.participant_ref_id); useEffect(() => { diff --git a/frontend/src/pages/groups/GroupSettingsModal.tsx b/frontend/src/pages/groups/GroupSettingsModal.tsx index 37e41d385..12b25958d 100644 --- a/frontend/src/pages/groups/GroupSettingsModal.tsx +++ b/frontend/src/pages/groups/GroupSettingsModal.tsx @@ -84,6 +84,9 @@ export default function GroupSettingsModal({ {member.role === 'manager' && ( {t('groups.manager', '群管理')} )} + {member.is_deleted && ( + {t('groups.deletedBadge', '已删除')} + )} {(member.role_description || member.title) && (
{member.role_description || member.title}
diff --git a/frontend/src/pages/groups/GroupSidePanel.tsx b/frontend/src/pages/groups/GroupSidePanel.tsx index be6e2d50a..4ee42732a 100644 --- a/frontend/src/pages/groups/GroupSidePanel.tsx +++ b/frontend/src/pages/groups/GroupSidePanel.tsx @@ -89,6 +89,9 @@ export default function GroupSidePanel({ {member.role === 'manager' && ( {t('groups.manager', '群管理')} )} + {member.is_deleted && ( + {t('groups.deletedBadge', '已删除')} + )} {(member.role_description || member.title) && (
{member.role_description || member.title}
diff --git a/frontend/src/pages/groups/MessageStream.tsx b/frontend/src/pages/groups/MessageStream.tsx index 8369023d4..57215c1bd 100644 --- a/frontend/src/pages/groups/MessageStream.tsx +++ b/frontend/src/pages/groups/MessageStream.tsx @@ -175,6 +175,11 @@ export default function MessageStream({ {t('groups.agentBadge', '智能体')} )} + {member?.is_deleted && ( + + {t('groups.deletedBadge', '已删除')} + + )} {timeFormat.format(new Date(message.created_at))} diff --git a/frontend/src/pages/groups/groups.css b/frontend/src/pages/groups/groups.css index 69f020684..922c78de7 100644 --- a/frontend/src/pages/groups/groups.css +++ b/frontend/src/pages/groups/groups.css @@ -417,7 +417,8 @@ } .group-badge-agent, -.group-badge-manager { +.group-badge-manager, +.group-badge-deleted { padding: 1px 5px; border-radius: var(--radius-sm); background: var(--accent-subtle); @@ -432,6 +433,12 @@ color: var(--text-tertiary); } +.group-badge-deleted { + margin-left: var(--space-2); + background: color-mix(in srgb, var(--error) 12%, transparent); + color: var(--error); +} + .group-message-bubble { padding: var(--space-3) var(--space-4); border: 1px solid var(--border-subtle); diff --git a/frontend/src/types/group.ts b/frontend/src/types/group.ts index 8da7e1344..4e5da2fb2 100644 --- a/frontend/src/types/group.ts +++ b/frontend/src/types/group.ts @@ -23,6 +23,7 @@ export interface GroupMember { role: GroupRole; role_description: string | null; title: string | null; + is_deleted: boolean; joined_at: string; }