From 0bcb5ffc375a9f464c5587ab182b8af472b8d347 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 22 Jul 2026 16:32:45 +0800 Subject: [PATCH] Preserve history when models and agents are deleted Deleting an Agent or model now marks it deleted and removes it from active product and runtime paths while preserving chats, runs, tasks, audits, participants, and workspace files. Agent deletion also cancels unfinished runs, releases edit locks, and removes runtime containers on a best-effort basis; model resolution falls back to an active candidate. Constraint: Existing foreign keys remain unchanged in this iteration Rejected: Manual cleanup and hard deletion | destroys historical data and fails against existing references Confidence: high Scope-risk: broad Directive: New active Agent and model query paths must exclude deleted_at rows Tested: Backend full suite (1997 passed); focused backend suite (23 passed); frontend tests (63 passed); frontend production build; Alembic heads; staged diff check Not-tested: Applying the migration against a production database --- ...202607221500_add_agent_model_deleted_at.py | 51 +++ backend/app/api/admin.py | 6 +- backend/app/api/agents.py | 307 +++++++++--------- backend/app/api/directory.py | 2 + backend/app/api/enterprise.py | 89 +++-- backend/app/api/experience.py | 26 +- backend/app/api/feishu.py | 40 +-- backend/app/api/gateway.py | 9 +- backend/app/api/groups.py | 2 + backend/app/api/notification.py | 5 +- backend/app/api/okr.py | 8 +- backend/app/api/onboarding.py | 7 +- backend/app/api/plaza.py | 5 +- backend/app/api/webhooks.py | 9 +- backend/app/api/websocket.py | 86 ++--- backend/app/core/permissions.py | 34 +- backend/app/models/agent.py | 11 +- backend/app/models/llm.py | 9 +- backend/app/schemas/schemas.py | 2 + backend/app/services/agent_manager.py | 26 +- .../app/services/agent_runtime/a2a_runtime.py | 4 + backend/app/services/agent_runtime/adapter.py | 67 ++-- .../agent_runtime/group_context_builder.py | 1 + .../agent_runtime/group_runtime_tools.py | 1 + .../agent_runtime/model_capabilities.py | 14 +- .../agent_runtime/model_step_service.py | 39 ++- .../app/services/agent_runtime/planning.py | 12 +- .../session_context_background.py | 41 +-- .../session_context_compactor.py | 28 +- .../agent_runtime/tool_step_service.py | 1 + backend/app/services/agent_tools.py | 21 +- backend/app/services/channel_session.py | 7 +- backend/app/services/chat_session_service.py | 14 +- backend/app/services/collaboration.py | 41 ++- backend/app/services/enterprise_sync.py | 7 +- backend/app/services/experience_retrieval.py | 14 +- backend/app/services/group_chat_service.py | 2 + backend/app/services/group_message_service.py | 33 +- backend/app/services/heartbeat.py | 8 +- backend/app/services/llm/caller.py | 31 +- backend/app/services/llm/model_resolution.py | 128 ++++++++ backend/app/services/okr_agent_hook.py | 8 +- backend/app/services/okr_daily_collection.py | 8 +- backend/app/services/okr_reporting.py | 31 +- backend/app/services/okr_scheduler.py | 1 + backend/app/services/quota_guard.py | 23 +- backend/app/services/scheduler.py | 5 +- backend/app/services/skill_seeder.py | 4 +- backend/app/services/task_executor.py | 7 +- backend/app/services/timezone_utils.py | 9 +- .../app/services/trigger_runtime/intake.py | 1 + backend/tests/test_agent_delete_api.py | 250 ++++++-------- backend/tests/test_agent_runtime_adapter.py | 26 +- ...gent_runtime_group_a2a_thread_contracts.py | 1 + .../test_agent_runtime_model_step_service.py | 20 +- ...gent_runtime_session_context_background.py | 5 +- ...agent_runtime_session_context_compactor.py | 12 +- backend/tests/test_agent_visibility.py | 20 ++ backend/tests/test_group_api.py | 57 +++- backend/tests/test_group_message_service.py | 2 + backend/tests/test_model_logical_delete.py | 126 +++++++ backend/tests/test_model_resolution.py | 135 ++++++++ backend/tests/test_websocket_runtime_chat.py | 17 +- frontend/src/i18n/en.json | 10 +- frontend/src/i18n/zh.json | 10 +- .../pages/enterprise-settings/tabs/LlmTab.tsx | 49 +-- frontend/src/pages/groups/GroupMemoryTab.tsx | 4 +- .../src/pages/groups/GroupSettingsModal.tsx | 3 + frontend/src/pages/groups/GroupSidePanel.tsx | 3 + frontend/src/pages/groups/MessageStream.tsx | 5 + frontend/src/pages/groups/groups.css | 9 +- frontend/src/types/group.ts | 1 + 72 files changed, 1451 insertions(+), 659 deletions(-) create mode 100644 backend/alembic/versions/202607221500_add_agent_model_deleted_at.py create mode 100644 backend/app/services/llm/model_resolution.py create mode 100644 backend/tests/test_model_logical_delete.py create mode 100644 backend/tests/test_model_resolution.py 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; }