annator-atom / backend /api /agent_routes.py
techprotrade's picture
Full stack ATOM backend + AIMONEYFLOW clients (port 7860)
68b32d7 verified
Raw
History Blame Contribute Delete
28.1 kB
import asyncio
import datetime
import logging
from typing import Any, Dict, List, Optional
import uuid
from advanced_workflow_orchestrator import AdvancedWorkflowOrchestrator
from fastapi import BackgroundTasks, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from core.agent_governance_service import AgentGovernanceService
from core.agent_world_model import AgentExperience, WorldModelService
from core.base_routes import BaseAPIRouter
from core.database import SessionLocal, get_db, get_db_session
from core.enterprise_security import AuditEvent, EventType, SecurityLevel, enterprise_security
from core.models import (
AgentFeedback,
AgentJob,
AgentRegistry,
AgentStatus,
HITLAction,
HITLActionStatus,
User,
)
from core.notification_manager import notification_manager
from core.rbac_service import Permission
from core.security_dependencies import require_permission
from core.websockets import manager as ws_manager
logger = logging.getLogger(__name__)
router = BaseAPIRouter(prefix="/api/agents", tags=["Agents"])
# --- Data Models ---
class AgentRunRequest(BaseModel):
agent_id: str
parameters: Dict[str, Any] = Field(default_factory=dict)
class AgentUpdateRequest(BaseModel):
agent_id: str
name: Optional[str] = None
description: Optional[str] = None
class AgentInfo(BaseModel):
id: str
name: str
description: str
status: str # idle, running, failed, success
last_run: Optional[str] = None
category: str
# --- Registry (Mock for MVP, real app would scan or register classes) ---
class AgentFeedbackRequest(BaseModel):
user_correction: str
input_context: Optional[str] = None
original_output: str
class HITLApprovalRequest(BaseModel):
decision: str # approved | rejected
feedback: Optional[str] = None
# --- Endpoints ---
@router.get("/", response_model=List[AgentInfo])
async def list_agents(
category: Optional[str] = None,
user: User = Depends(require_permission(Permission.AGENT_VIEW)),
db: Session = Depends(get_db)
):
"""List all available Computer Use Agents from Registry"""
governance_service = AgentGovernanceService(db)
agents_db = governance_service.list_agents(category)
# Get last run times
from sqlalchemy import func
latest_jobs = db.query(AgentJob.agent_id, func.max(AgentJob.start_time).label('last_run'))\
.group_by(AgentJob.agent_id)\
.all()
last_run_map = {job.agent_id: job.last_run.isoformat() for job in latest_jobs if job.last_run}
return [
AgentInfo(
id=a.id,
name=a.name,
description=a.description,
status=a.status,
last_run=last_run_map.get(a.id),
category=a.category
) for a in agents_db
]
# --- Endpoints ---
@router.get("/{agent_id}")
async def get_agent(
agent_id: str,
user: User = Depends(require_permission(Permission.AGENT_VIEW)),
db: Session = Depends(get_db)
):
"""Get a specific agent by ID"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
# Get last run time
from sqlalchemy import func
latest_job = db.query(func.max(AgentJob.start_time))\
.filter(AgentJob.agent_id == agent_id)\
.scalar()
return router.success_response(
data={
"id": agent.id,
"name": agent.name,
"description": agent.description,
"category": agent.category,
"status": agent.status,
"confidence_score": agent.confidence_score,
"module_path": agent.module_path,
"class_name": agent.class_name,
"configuration": agent.configuration,
"schedule_config": agent.schedule_config,
"version": agent.version,
"last_run": latest_job.isoformat() if latest_job else None
},
message="Agent retrieved successfully"
)
@router.get("/{agent_id}/status")
async def get_agent_status(
agent_id: str,
user: User = Depends(require_permission(Permission.AGENT_VIEW)),
db: Session = Depends(get_db)
):
"""Get the current status of an agent"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
# Check for running tasks
from core.agent_task_registry import agent_task_registry
try:
running_tasks = await agent_task_registry.get_active_tasks(agent_id)
except Exception:
running_tasks = []
return router.success_response(
data={
"agent_id": agent.id,
"name": agent.name,
"status": agent.status,
"confidence_score": agent.confidence_score,
"is_running": len(running_tasks) > 0,
"active_tasks": len(running_tasks)
},
message="Agent status retrieved successfully"
)
@router.delete("/{agent_id}")
async def delete_agent(
agent_id: str,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Delete an agent"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
# Check if agent has running tasks
from core.agent_task_registry import agent_task_registry
try:
running_tasks = await agent_task_registry.get_active_tasks(agent_id)
except Exception:
running_tasks = []
if running_tasks:
raise router.error_response(
error_code="AGENT_HAS_RUNNING_TASKS",
message=f"Cannot delete agent with {len(running_tasks)} running task(s)",
status_code=400
)
agent_name = agent.name
db.delete(agent)
db.commit()
return router.success_response(
data={"agent_id": agent_id},
message=f"Agent {agent_name} deleted successfully"
)
@router.post("/{agent_id}/run")
async def run_agent(
agent_id: str,
run_req: AgentRunRequest,
background_tasks: BackgroundTasks,
user: User = Depends(require_permission(Permission.AGENT_RUN)),
db: Session = Depends(get_db)
):
"""Trigger an agent execution in the background"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
# Check if agent is deprecated or paused
if agent.status in [AgentStatus.DEPRECATED.value, AgentStatus.PAUSED.value]:
raise router.error_response(
error_code="AGENT_INVALID_STATE",
message=f"Agent is {agent.status}",
status_code=400
)
if agent.status == "running":
raise router.conflict_error(
message="Agent is already running",
details={"agent_id": agent_id, "current_status": agent.status}
)
# Check if we should run synchronously (for testing)
is_sync = run_req.parameters.get("sync", False)
if is_sync:
# Run immediately and return result
# Note: calling execute_agent_task directly might have session issues if it creates its own session
# but execute_agent_task creates a SessionLocal(), so it is fine.
# We need to capture the return value from execute_agent_task (which currently returns nothing/void, just logs/notifies).
# We need to refactor execute_agent_task to return result if needed.
# Let's import it or call the logic directly.
# Actually, let's just instantiate GenericAgent here if it's a generic agent to get the Result object?
# Or better, refactor execute_agent_task to return the result.
# Refactoring execute_agent_task is best.
result = await execute_agent_task(agent_id, run_req.parameters)
return router.success_response(
data={"agent_id": agent_id, "result": result},
message="Agent execution completed"
)
# Run in background
# We pass agent_id only, task will re-fetch to ensure fresh state/object access
background_tasks.add_task(execute_agent_task, agent_id, run_req.parameters)
return router.success_response(
data={"agent_id": agent_id},
message="Agent execution started"
)
@router.patch("/{agent_id}")
async def update_agent(
agent_id: str,
update_data: AgentUpdateRequest,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Update agent details"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
if update_data.name:
agent.name = update_data.name
if update_data.description is not None:
agent.description = update_data.description
db.commit()
db.refresh(agent)
return router.success_response(
data={
"id": agent.id,
"name": agent.name,
"description": agent.description
},
message="Agent updated successfully"
)
@router.post("/{agent_id}/feedback")
async def submit_agent_feedback(
agent_id: str,
feedback: AgentFeedbackRequest,
user: User = Depends(require_permission(Permission.AGENT_RUN)), # Members can submit feedback
db: Session = Depends(get_db)
):
"""Submit feedback/corrections for an agent"""
service = AgentGovernanceService(db)
result = await service.submit_feedback(
agent_id=agent_id,
user_id=user.id,
original_output=feedback.original_output,
user_correction=feedback.user_correction,
input_context=feedback.input_context
)
return router.success_response(
data={
"feedback_id": result.id,
"adjudication": result.status,
"reasoning": result.ai_reasoning
},
message="Feedback submitted successfully"
)
@router.post("/{agent_id}/promote")
async def promote_agent(
agent_id: str,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Promote agent to Autonomous mode"""
service = AgentGovernanceService(db)
agent = service.promote_to_autonomous(agent_id, user)
return router.success_response(
data={"agent_status": agent.status},
message=f"Agent {agent_id} promoted to autonomous successfully"
)
@router.get("/approvals/pending", response_model=List[Dict[str, Any]])
async def list_pending_approvals(
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""List all actions waiting for human approval"""
actions = db.query(HITLAction).filter(HITLAction.status == HITLActionStatus.PENDING.value).all()
return [{
"id": a.id,
"agent_id": a.agent_id,
"action_type": a.action_type,
"params": a.params,
"reason": a.reason,
"created_at": a.created_at.isoformat() if a.created_at else None
} for a in actions]
@router.post("/approvals/{action_id}")
async def decide_hitl_action(
action_id: str,
req: HITLApprovalRequest,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Approve or Reject a paused agent action"""
action = db.query(HITLAction).filter(HITLAction.id == action_id).first()
if not action:
raise router.not_found_error("HITLAction", action_id)
if req.decision.lower() == "approved":
action.status = HITLActionStatus.APPROVED.value
else:
action.status = HITLActionStatus.REJECTED.value
action.user_feedback = req.feedback
action.reviewed_at = datetime.datetime.now()
action.reviewed_by = user.id
db.commit()
# Broadcast update to UI via WebSocket
await ws_manager.broadcast("workspace:default", {
"type": "hitl_decision",
"action_id": action_id,
"decision": action.status
})
return router.success_response(
data={"decision": action.status, "action_id": action_id},
message=f"Action {action_id} {action.status} successfully"
)
async def execute_agent_task(agent_id: str, params: Dict[str, Any]):
"""Background task to run the agent logic"""
# Use context manager for background task
with get_db_session() as db:
result = None
try:
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
logger.error(f"Agent {agent_id} not found in background task")
return
logger.info(f"Starting agent {agent.name} (ID: {agent_id})...")
# 1. World Model Retrieval
wm_service = WorldModelService()
# Build a context string from params to query memory
task_context = f"Execute {agent.name} with params: {str(params)}"
relevant_memories = await wm_service.recall_experiences(agent, task_context)
if isinstance(relevant_memories, dict):
# Extract the actual experiences list from the dictionary response
experiences = relevant_memories.get("experiences", [])
if experiences:
logger.info(f"Agents {agent.name} found {len(experiences)} relevant past experiences.")
for mem in experiences:
# Defensive check if mem is object or dict (mock vs real)
if hasattr(mem, "input_summary"):
logger.info(f" [Memory] {mem.input_summary} -> {mem.learnings} ({mem.outcome})")
else:
logger.info(f" [Memory] {str(mem)}")
elif isinstance(relevant_memories, list):
# Legacy/Fallback support if it returns a list directly
logger.info(f"Agents {agent.name} found {len(relevant_memories)} relevant past experiences.")
for mem in relevant_memories:
if hasattr(mem, "input_summary"):
logger.info(f" [Memory] {mem.input_summary} -> {mem.learnings} ({mem.outcome})")
else:
logger.info(f" [Memory] {str(mem)}")
# Dynamic Import
# Unified Execution Logic using GenericAgent ReAct Loop
from core.generic_agent import GenericAgent
result = None
try:
# 1. Determine Tools based on Agent ID/Type (Migration compatibility)
# If the agent is legacy and doesn't have tools configured, we inject them here.
override_config = {}
if agent.id == "competitive_intel":
override_config["tools"] = ["track_competitor_pricing"]
override_config["system_prompt"] = "You are a Competitive Intelligence Agent. Use the 'track_competitor_pricing' tool to gather market data."
elif agent.id == "inventory_reconcile":
override_config["tools"] = ["reconcile_inventory"]
override_config["system_prompt"] = "You are an Inventory Manager. Use 'reconcile_inventory' to check for variance."
elif agent.id == "payroll_guardian":
override_config["tools"] = ["reconcile_payroll"]
override_config["system_prompt"] = "You are a Payroll Guardian. Use 'reconcile_payroll' to verify accuracy."
# 2. Instantiate Runtime
if override_config:
if not agent.configuration:
agent.configuration = {}
# Merge defaults if not present
for k, v in override_config.items():
if k not in agent.configuration:
agent.configuration[k] = v
runner = GenericAgent(agent)
# 3. Determine Input
# ReAct loop needs a natural language instruction.
task_input = params.get("task_input") or params.get("request")
# If input is missing but we have params, we construct a prompt
if not task_input:
if agent.id == "competitive_intel":
task_input = f"Track pricing for {params.get('product', 'configured products')} against {params.get('competitors', 'competitors')}."
elif agent.id == "inventory_reconcile":
task_input = f"Reconcile inventory for {params.get('skus', 'all SKUs')}."
elif agent.id == "payroll_guardian":
task_input = f"Reconcile payroll for period {params.get('period', 'current')}."
else:
task_input = f"Execute task with params: {params}"
# 4. Execute ReAct Loop with step streaming
logger.info(f"Executing Agent {agent.name} with ReAct Loop. Input: {task_input}")
async def streaming_callback(step_record):
await ws_manager.broadcast("workspace:default", {
"type": "agent_step_update",
"agent_id": agent_id,
"step": step_record
})
result_obj = await runner.execute(task_input, context=params, step_callback=streaming_callback)
# 5. Process Result
result = result_obj
# Success Notification
await ws_manager.broadcast("workspace:default", {
"type": "agent_status_change",
"agent_id": agent_id,
"status": "success",
"result": result
})
# --- [NEW] External Bridge Response Routing ---
source_platform = params.get("source_platform")
recipient_id = params.get("recipient_id") or params.get("channel_id")
if source_platform and recipient_id:
try:
from core.agent_integration_gateway import (
ActionType,
agent_integration_gateway,
)
final_output = result.get("final_output") if isinstance(result, dict) else str(result)
if final_output:
logger.info(f"Routing async agent result back to {source_platform}")
routing_params = {
"recipient_id": recipient_id,
"channel": params.get("channel_id") or recipient_id,
"content": f"✅ *{agent.name}* finished task:\n{final_output}",
"thread_ts": params.get("thread_ts")
}
# Phase 105: Include original sender for Agent-to-Agent loopback
if source_platform == "agent":
routing_params["sender_agent_id"] = params.get("agent_id") or params.get("sender_id")
await agent_integration_gateway.execute_action(
ActionType.SEND_MESSAGE,
source_platform,
routing_params
)
except Exception as route_err:
logger.error(f"Failed to route async agent result back to {source_platform}: {route_err}")
# 6. Record Experience happens inside GenericAgent.execute() now.
except Exception as e:
logger.error(f"Agent {agent_id} logic failed: {e}")
# Record Failure
await wm_service.record_experience(AgentExperience(
id=str(uuid.uuid4()),
agent_id=agent.id,
task_type=agent.class_name,
input_summary=str(params),
outcome="Failure",
learnings=f"Failed with error: {str(e)}",
agent_role=agent.category,
specialty=None,
timestamp=datetime.datetime.utcnow()
))
raise e
except Exception as e:
import sys
import traceback
error_msg = f"Agent execution FAILED: {str(e)}\n{traceback.format_exc()}"
logger.critical(f"!!! CRITICAL AGENT ERROR !!!\n{error_msg}")
logger.error(f"Agent {agent_id} execution wrapper failed: {e}")
# Urgent Notification (Phase 34 requirement)
await notification_manager.send_urgent_notification(
message=f"Agent execution FAILED: {str(e)}",
workspace_id="default_workspace",
channel="slack"
)
# Notify UI Status
await ws_manager.broadcast("workspace:default", {
"type": "agent_status_change",
"agent_id": agent_id,
"status": "failed",
"error": str(e),
"traceback": traceback.format_exc()
})
return result
# ==================== ATOM META-AGENT ENDPOINTS ====================
class AtomExecuteRequest(BaseModel):
request: str
context: Optional[Dict[str, Any]] = None
class AtomSpawnRequest(BaseModel):
template: str # e.g., "finance_analyst", "sales_assistant", "custom"
custom_params: Optional[Dict[str, Any]] = None
persist: bool = False
class AtomTriggerRequest(BaseModel):
event_type: str
data: Dict[str, Any]
@router.post("/atom/execute")
async def execute_atom(
req: AtomExecuteRequest,
user: User = Depends(require_permission(Permission.AGENT_RUN)),
):
"""
Execute the Atom Meta-Agent with a natural language request.
Atom will analyze the request and spawn specialty agents as needed.
"""
from core.atom_meta_agent import handle_manual_trigger
# Determine workspace from user context
workspace_id = "default"
result = await handle_manual_trigger(
request=req.request,
user=user,
workspace_id=workspace_id
)
return router.success_response(
data=result,
message="Atom meta-agent executed successfully"
)
@router.post("/spawn")
async def spawn_agent(
req: AtomSpawnRequest,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
):
"""
Spawn a specialty agent on-demand from a template.
"""
from core.atom_meta_agent import get_atom_agent
atom = get_atom_agent()
agent = await atom.spawn_agent(
template_name=req.template,
custom_params=req.custom_params,
persist=req.persist
)
return router.success_response(
data={
"agent_id": agent.id,
"agent_name": agent.name,
"category": agent.category,
"persisted": req.persist
},
message=f"Agent {agent.name} spawned successfully"
)
@router.post("/atom/trigger")
async def trigger_atom_with_data(
req: AtomTriggerRequest,
# This endpoint may not require user auth if called by webhooks/internal systems
# For now, require basic auth
user: User = Depends(require_permission(Permission.AGENT_RUN)),
):
"""
Trigger Atom with new data (event-driven execution).
Used for webhooks, ingestion events, integration callbacks.
"""
from core.atom_meta_agent import handle_data_event_trigger
result = await handle_data_event_trigger(
event_type=req.event_type,
data=req.data,
workspace_id="default"
)
return router.success_response(
data=result,
message="Atom triggered with data event successfully"
)
class CustomAgentRequest(BaseModel):
name: str
description: Optional[str] = "Custom Agent"
category: str = "custom"
configuration: Dict[str, Any]
schedule_config: Optional[Dict[str, Any]] = None
@router.post("/custom")
async def create_custom_agent(
req: CustomAgentRequest,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Create a fully custom agent with configuration and schedule"""
# 1. Create Agent
registry_entry = AgentRegistry(
name=req.name,
description=req.description,
category=req.category,
configuration=req.configuration,
schedule_config=req.schedule_config,
module_path="core.generic_agent",
class_name="GenericAgent",
status=AgentStatus.STUDENT.value
)
db.add(registry_entry)
db.commit()
db.refresh(registry_entry)
# 2. Schedule if needed
if req.schedule_config and req.schedule_config.get("active"):
from core.scheduler import AgentScheduler
scheduler = AgentScheduler.get_instance()
scheduler.schedule_agent(registry_entry.id, req.schedule_config)
return router.success_response(
data={"agent_id": registry_entry.id},
message=f"Custom agent {req.name} created successfully"
)
@router.put("/{agent_id}")
async def update_agent(
agent_id: str,
req: CustomAgentRequest,
user: User = Depends(require_permission(Permission.AGENT_MANAGE)),
db: Session = Depends(get_db)
):
"""Update an agent's config or schedule"""
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
# Update fields
agent.name = req.name
agent.description = req.description
agent.category = req.category
agent.configuration = req.configuration
agent.schedule_config = req.schedule_config
db.commit()
# Update Scheduler
from core.scheduler import AgentScheduler
scheduler = AgentScheduler.get_instance()
# Ideally remove old job but for MVP we overwrite with new ID or let scheduler handle
# A robust implementation would cancel the old job_id if we stored it
if req.schedule_config and req.schedule_config.get("active"):
scheduler.schedule_agent(agent.id, req.schedule_config)
return router.success_response(
data={"agent_id": agent.id},
message=f"Agent {agent.name} updated successfully"
)
@router.post("/{agent_id}/stop")
async def stop_agent(
agent_id: str,
user: User = Depends(require_permission(Permission.AGENT_RUN)),
db: Session = Depends(get_db)
):
"""
Stop a running agent by cancelling its active tasks.
Uses the AgentTaskRegistry to cancel all running tasks for the agent.
"""
from core.agent_task_registry import agent_task_registry
logger.info(f"Stop request received for agent {agent_id} by user {user.id}")
# Try to cancel tasks via registry
cancelled_count = await agent_task_registry.cancel_agent_tasks(agent_id)
if cancelled_count > 0:
# Successfully cancelled tasks
return router.success_response(
data={
"agent_id": agent_id,
"cancelled_tasks": cancelled_count
},
message=f"Successfully stopped {cancelled_count} running task(s)"
)
else:
# No tasks in registry - agent might not be running or already stopped
# Check if agent exists
agent = db.query(AgentRegistry).filter(AgentRegistry.id == agent_id).first()
if not agent:
raise router.not_found_error("Agent", agent_id)
return router.success_response(
data={"agent_id": agent_id, "cancelled_tasks": 0},
message="No running tasks found for this agent"
)