Phase 5.5-5.9: Plugin-Marketplace, Agent Memory, GraphRAG, Subagents, External Agent API
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
5.5 Plugin-Marketplace: - New plugin: marketplace/ (models, routes, services, schemas, config) - MarketplaceListing model (global, no tenant_id) - Ed25519 signature verification via PluginSignature - Endpoints: list, detail, install, verify, categories - Config: MARKETPLACE_SERVER_URL setting 5.6 Agent Memory (persistent): - New plugin: agent_memory/ (models, routes, services, schemas) - AgentMemory model with embedding vector(768) + HNSW index - store_memory() with auto-embedding - retrieve_relevant_memories() with pgvector cosine similarity - Semantic search endpoint 5.7 GraphRAG: - New plugin: graph_rag/ (models, routes, services, provider, schemas) - EntityRelationship model (source/target type+id, relationship_type, metadata) - BFS graph traversal (bidirectional, configurable depth) - GraphRAGSearchProvider registered in unified_search 5.8 Subagents / Multi-Agent: - AgentCoordinator class (create_subtask, wait_for_subtask, aggregate, cancel) - AgentSubtask model + migration 0002_agent_subtasks.sql - 6 new API endpoints for subtask management - Tools registered in AI tool registry 5.9 External Agent API: - external_api.py: POST /run, GET /status, POST /stream (SSE) - Bearer API token authentication - Rate limiting: 10 req/min per token - ExternalAgentRequest/Response schemas 3 new plugins registered in main.py and __init__.py All files py_compile clean
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
"""Agent Coordinator — Multi-Agent Orchestration for subtask delegation.
|
||||
|
||||
Provides the AgentCoordinator class that manages subtask creation, waiting,
|
||||
aggregation, and cancellation between AI agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentCoordinator:
|
||||
"""Coordinates subtask delegation between AI agents."""
|
||||
|
||||
@staticmethod
|
||||
async def create_subtask(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
parent_agent_id: uuid.UUID,
|
||||
child_agent_id: uuid.UUID,
|
||||
task_description: str,
|
||||
) -> AgentSubtask:
|
||||
"""Create a new subtask for a child agent.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
parent_agent_id: UUID of the parent agent creating the subtask
|
||||
child_agent_id: UUID of the child agent that will execute the subtask
|
||||
task_description: Description of the task to execute
|
||||
|
||||
Returns:
|
||||
The created AgentSubtask instance
|
||||
"""
|
||||
subtask = AgentSubtask(
|
||||
tenant_id=tenant_id,
|
||||
parent_agent_id=parent_agent_id,
|
||||
child_agent_id=child_agent_id,
|
||||
task_description=task_description,
|
||||
status="pending",
|
||||
result={},
|
||||
)
|
||||
db.add(subtask)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
"Subtask created: %s (parent=%s -> child=%s)",
|
||||
subtask.id, parent_agent_id, child_agent_id,
|
||||
)
|
||||
return subtask
|
||||
|
||||
@staticmethod
|
||||
async def wait_for_subtask(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
subtask_id: uuid.UUID,
|
||||
poll_interval: float = 0.5,
|
||||
timeout: float = 300.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a subtask to complete, fail, or be cancelled.
|
||||
|
||||
Polls the database until the subtask reaches a terminal state
|
||||
or the timeout is exceeded.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
subtask_id: UUID of the subtask to wait for
|
||||
poll_interval: Seconds between polls (default 0.5)
|
||||
timeout: Maximum seconds to wait (default 300)
|
||||
|
||||
Returns:
|
||||
Dict with status and result/error
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
start_time = datetime.now(UTC)
|
||||
while True:
|
||||
elapsed = (datetime.now(UTC) - start_time).total_seconds()
|
||||
if elapsed > timeout:
|
||||
# Mark as timed out
|
||||
await db.execute(
|
||||
sa_update(AgentSubtask)
|
||||
.where(AgentSubtask.id == subtask_id)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.values(
|
||||
status="failed",
|
||||
result={"error": "Timeout exceeded", "elapsed_seconds": elapsed},
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Timeout exceeded after {elapsed:.1f}s",
|
||||
"subtask_id": str(subtask_id),
|
||||
}
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
.where(AgentSubtask.id == subtask_id)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
|
||||
if subtask is None:
|
||||
return {"status": "error", "error": "Subtask not found"}
|
||||
|
||||
if subtask.status in ("completed", "failed", "cancelled"):
|
||||
return {
|
||||
"status": subtask.status,
|
||||
"result": subtask.result or {},
|
||||
"subtask_id": str(subtask.id),
|
||||
"completed_at": subtask.completed_at.isoformat() if subtask.completed_at else None,
|
||||
}
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
@staticmethod
|
||||
async def aggregate_results(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
subtask_ids: list[uuid.UUID],
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate results from multiple subtasks.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
subtask_ids: List of subtask UUIDs to aggregate
|
||||
|
||||
Returns:
|
||||
Dict with summary of all subtask results
|
||||
"""
|
||||
results = []
|
||||
for sid in subtask_ids:
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
.where(AgentSubtask.id == sid)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask:
|
||||
results.append({
|
||||
"subtask_id": str(subtask.id),
|
||||
"parent_agent_id": str(subtask.parent_agent_id),
|
||||
"child_agent_id": str(subtask.child_agent_id),
|
||||
"task_description": subtask.task_description,
|
||||
"status": subtask.status,
|
||||
"result": subtask.result or {},
|
||||
"created_at": subtask.created_at.isoformat() if subtask.created_at else None,
|
||||
"completed_at": subtask.completed_at.isoformat() if subtask.completed_at else None,
|
||||
})
|
||||
|
||||
completed = [r for r in results if r["status"] == "completed"]
|
||||
failed = [r for r in results if r["status"] == "failed"]
|
||||
pending = [r for r in results if r["status"] == "pending"]
|
||||
cancelled = [r for r in results if r["status"] == "cancelled"]
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"completed": len(completed),
|
||||
"failed": len(failed),
|
||||
"pending": len(pending),
|
||||
"cancelled": len(cancelled),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def cancel_subtask(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
subtask_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Cancel a pending or running subtask.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
subtask_id: UUID of the subtask to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found or already terminal
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
.where(AgentSubtask.id == subtask_id)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
|
||||
if subtask is None:
|
||||
return False
|
||||
|
||||
if subtask.status in ("completed", "failed", "cancelled"):
|
||||
logger.warning(
|
||||
"Cannot cancel subtask %s: already in terminal state '%s'",
|
||||
subtask_id, subtask.status,
|
||||
)
|
||||
return False
|
||||
|
||||
subtask.status = "cancelled"
|
||||
subtask.completed_at = datetime.now(UTC)
|
||||
subtask.result = {"cancelled_by": "coordinator", "previous_status": subtask.status}
|
||||
await db.flush()
|
||||
|
||||
logger.info("Subtask %s cancelled (was %s)", subtask_id, subtask.status)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def list_subtasks(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
parent_agent_id: uuid.UUID | None = None,
|
||||
child_agent_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[AgentSubtask], int]:
|
||||
"""List subtasks with optional filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
query = select(AgentSubtask).where(AgentSubtask.tenant_id == tenant_id)
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(AgentSubtask)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
if parent_agent_id is not None:
|
||||
query = query.where(AgentSubtask.parent_agent_id == parent_agent_id)
|
||||
count_query = count_query.where(AgentSubtask.parent_agent_id == parent_agent_id)
|
||||
if child_agent_id is not None:
|
||||
query = query.where(AgentSubtask.child_agent_id == child_agent_id)
|
||||
count_query = count_query.where(AgentSubtask.child_agent_id == child_agent_id)
|
||||
if status is not None:
|
||||
query = query.where(AgentSubtask.status == status)
|
||||
count_query = count_query.where(AgentSubtask.status == status)
|
||||
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
query.order_by(AgentSubtask.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
def register_agent_coordinator_tools():
|
||||
"""Register AgentCoordinator tools in the global tool registry."""
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
|
||||
async def create_subtask_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
"""Handle create_subtask tool call from an AI agent."""
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
parent_agent_id = arguments.get("parent_agent_id", "")
|
||||
child_agent_name = arguments.get("child_agent_name", "")
|
||||
task_description = arguments.get("task_description", "")
|
||||
tenant_id_str = context.get("tenant_id", "")
|
||||
|
||||
if not child_agent_name or not task_description:
|
||||
return json.dumps({"status": "error", "error": "Missing child_agent_name or task_description"})
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(tenant_id_str) if tenant_id_str else uuid.uuid4()
|
||||
except (ValueError, TypeError):
|
||||
return json.dumps({"status": "error", "error": "Invalid tenant_id"})
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
# Find child agent by name
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
result = await db.execute(
|
||||
select(AgentDefinition)
|
||||
.where(AgentDefinition.name == child_agent_name)
|
||||
.where(AgentDefinition.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
child_agent = result.scalar_one_or_none()
|
||||
|
||||
if child_agent is None:
|
||||
return json.dumps({"status": "error", "error": f"Child agent '{child_agent_name}' not found"})
|
||||
|
||||
if not child_agent.is_active:
|
||||
return json.dumps({"status": "error", "error": f"Child agent '{child_agent_name}' is inactive"})
|
||||
|
||||
try:
|
||||
parent_id = uuid.UUID(parent_agent_id) if parent_agent_id else uuid.uuid4()
|
||||
except (ValueError, TypeError):
|
||||
return json.dumps({"status": "error", "error": "Invalid parent_agent_id"})
|
||||
|
||||
subtask = await AgentCoordinator.create_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
parent_agent_id=parent_id,
|
||||
child_agent_id=child_agent.id,
|
||||
task_description=task_description,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return json.dumps({
|
||||
"status": "created",
|
||||
"subtask_id": str(subtask.id),
|
||||
"child_agent": child_agent_name,
|
||||
"child_agent_id": str(child_agent.id),
|
||||
"task_description": task_description,
|
||||
})
|
||||
|
||||
registry.register(
|
||||
name="create_subtask",
|
||||
description="Create a subtask for another agent to execute. The child agent will be activated with the task description.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parent_agent_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the parent agent creating the subtask",
|
||||
},
|
||||
"child_agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the child agent that will execute the subtask",
|
||||
},
|
||||
"task_description": {
|
||||
"type": "string",
|
||||
"description": "Description of the task to execute",
|
||||
},
|
||||
},
|
||||
"required": ["child_agent_name", "task_description"],
|
||||
},
|
||||
handler=create_subtask_handler,
|
||||
plugin_name="automation",
|
||||
required_permission="agents:execute",
|
||||
category="orchestration",
|
||||
)
|
||||
logger.info("Agent coordinator tool 'create_subtask' registered")
|
||||
|
||||
|
||||
def unregister_agent_coordinator_tools():
|
||||
"""Unregister AgentCoordinator tools."""
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
registry.unregister("create_subtask")
|
||||
logger.info("Agent coordinator tools unregistered")
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Agent Subtasks for Multi-Agent Orchestration (Phase 5.8)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_subtasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
parent_agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE,
|
||||
child_agent_id UUID NOT NULL REFERENCES automation_agent_definitions(id) ON DELETE CASCADE,
|
||||
task_description TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled
|
||||
result JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_agent_subtasks_parent ON agent_subtasks (tenant_id, parent_agent_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_agent_subtasks_child ON agent_subtasks (tenant_id, child_agent_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_agent_subtasks_status ON agent_subtasks (tenant_id, status);
|
||||
@@ -251,3 +251,40 @@ class AutomationRun(Base, TenantMixin):
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class AgentSubtask(Base, TenantMixin):
|
||||
"""A subtask delegated from one agent to another for multi-agent orchestration."""
|
||||
|
||||
__tablename__ = "agent_subtasks"
|
||||
__table_args__ = (
|
||||
Index("ix_agent_subtasks_parent", "tenant_id", "parent_agent_id"),
|
||||
Index("ix_agent_subtasks_child", "tenant_id", "child_agent_id"),
|
||||
Index("ix_agent_subtasks_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
parent_agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
child_agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("automation_agent_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
task_description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
result: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ class AutomationPlugin(BasePlugin):
|
||||
"mail.received",
|
||||
"workflow.timeout",
|
||||
],
|
||||
migrations=["0001_initial.sql"],
|
||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql"],
|
||||
permissions=[
|
||||
"automation:read",
|
||||
"automation:write",
|
||||
@@ -185,6 +185,12 @@ class AutomationPlugin(BasePlugin):
|
||||
register_agent_comm_tool()
|
||||
except Exception:
|
||||
logger.exception("Failed to register agent communication tool")
|
||||
# Register agent coordinator tools
|
||||
try:
|
||||
from app.plugins.builtins.automation.agent_coordinator import register_agent_coordinator_tools
|
||||
register_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to register agent coordinator tools")
|
||||
# Register MiniApps from manifest
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
@@ -222,6 +228,12 @@ class AutomationPlugin(BasePlugin):
|
||||
unregister_agent_comm_tool()
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister agent communication tool")
|
||||
# Unregister agent coordinator tools
|
||||
try:
|
||||
from app.plugins.builtins.automation.agent_coordinator import unregister_agent_coordinator_tools
|
||||
unregister_agent_coordinator_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister agent coordinator tools")
|
||||
# Unregister MiniApps
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
|
||||
|
||||
@@ -624,3 +624,241 @@ async def restore_automation_version(
|
||||
return _automation_to_response(automation)
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── Subtask Endpoints (Phase 5.8) ───
|
||||
|
||||
|
||||
@router.get(
|
||||
"/subtasks",
|
||||
dependencies=[Depends(require_permission("agents:read"))],
|
||||
response_model=SubtaskListResponse,
|
||||
)
|
||||
async def list_subtasks(
|
||||
parent_agent_id: str | None = Query(None),
|
||||
child_agent_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List subtasks with optional filters."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
parent_id = uuid.UUID(parent_agent_id) if parent_agent_id else None
|
||||
child_id = uuid.UUID(child_agent_id) if child_agent_id else None
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
items, total = await AgentCoordinator.list_subtasks(
|
||||
db, tenant_id,
|
||||
parent_agent_id=parent_id,
|
||||
child_agent_id=child_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return SubtaskListResponse(
|
||||
items=[_subtask_to_response(s) for s in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subtasks",
|
||||
dependencies=[Depends(require_permission("agents:execute"))],
|
||||
response_model=SubtaskRead,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_subtask(
|
||||
data: SubtaskCreate,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new subtask for multi-agent orchestration."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
parent_id = uuid.UUID(data.parent_agent_id)
|
||||
child_id = uuid.UUID(data.child_agent_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID")
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
subtask = await AgentCoordinator.create_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
parent_agent_id=parent_id,
|
||||
child_agent_id=child_id,
|
||||
task_description=data.task_description,
|
||||
)
|
||||
await db.commit()
|
||||
return _subtask_to_response(subtask)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/subtasks/{subtask_id}",
|
||||
dependencies=[Depends(require_permission("agents:read"))],
|
||||
response_model=SubtaskRead,
|
||||
)
|
||||
async def get_subtask(
|
||||
subtask_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single subtask by ID."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
.where(AgentSubtask.id == sid)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail="Subtask not found")
|
||||
return _subtask_to_response(subtask)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/subtasks/{subtask_id}",
|
||||
dependencies=[Depends(require_permission("agents:execute"))],
|
||||
response_model=SubtaskRead,
|
||||
)
|
||||
async def update_subtask(
|
||||
subtask_id: str,
|
||||
data: SubtaskUpdate,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a subtask (status, result)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentSubtask)
|
||||
.where(AgentSubtask.id == sid)
|
||||
.where(AgentSubtask.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask is None:
|
||||
raise HTTPException(status_code=404, detail="Subtask not found")
|
||||
|
||||
update_data = data.model_dump(exclude_none=True)
|
||||
if "status" in update_data:
|
||||
subtask.status = update_data["status"]
|
||||
if update_data["status"] in ("completed", "failed", "cancelled"):
|
||||
subtask.completed_at = __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
)
|
||||
if "result" in update_data:
|
||||
subtask.result = update_data["result"]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subtask)
|
||||
return _subtask_to_response(subtask)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subtasks/{subtask_id}/cancel",
|
||||
dependencies=[Depends(require_permission("agents:execute"))],
|
||||
)
|
||||
async def cancel_subtask(
|
||||
subtask_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a pending or running subtask."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
success = await AgentCoordinator.cancel_subtask(db, tenant_id, sid)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Subtask not found or already in terminal state",
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "cancelled", "subtask_id": subtask_id}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subtasks/{subtask_id}/wait",
|
||||
dependencies=[Depends(require_permission("agents:execute"))],
|
||||
)
|
||||
async def wait_for_subtask(
|
||||
subtask_id: str,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Wait for a subtask to complete (polls until terminal state)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
sid = uuid.UUID(subtask_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID")
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
result = await AgentCoordinator.wait_for_subtask(db, tenant_id, sid)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subtasks/aggregate",
|
||||
dependencies=[Depends(require_permission("agents:read"))],
|
||||
)
|
||||
async def aggregate_subtasks(
|
||||
subtask_ids: list[str],
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Aggregate results from multiple subtasks."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
ids = [uuid.UUID(sid) for sid in subtask_ids]
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid subtask ID in list")
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
|
||||
result = await AgentCoordinator.aggregate_results(db, tenant_id, ids)
|
||||
return result
|
||||
|
||||
|
||||
def _subtask_to_response(s) -> SubtaskRead:
|
||||
"""Convert AgentSubtask model to response schema."""
|
||||
return SubtaskRead(
|
||||
id=str(s.id),
|
||||
parent_agent_id=str(s.parent_agent_id),
|
||||
child_agent_id=str(s.child_agent_id),
|
||||
task_description=s.task_description,
|
||||
status=s.status,
|
||||
result=s.result or {},
|
||||
created_at=s.created_at.isoformat() if s.created_at else None,
|
||||
completed_at=s.completed_at.isoformat() if s.completed_at else None,
|
||||
updated_at=s.updated_at.isoformat() if s.updated_at else None,
|
||||
)
|
||||
|
||||
@@ -289,3 +289,42 @@ class AutomationVersionListResponse(BaseModel):
|
||||
|
||||
items: list[AutomationVersionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Subtask Schemas (Phase 5.8) ───
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""Create a new subtask for multi-agent orchestration."""
|
||||
|
||||
parent_agent_id: str = Field(..., description="UUID of the parent agent")
|
||||
child_agent_id: str = Field(..., description="UUID of the child agent that will execute")
|
||||
task_description: str = Field(..., min_length=1, description="Description of the task")
|
||||
|
||||
|
||||
class SubtaskUpdate(BaseModel):
|
||||
"""Update a subtask (status, result)."""
|
||||
|
||||
status: str | None = Field(None, pattern="^(pending|running|completed|failed|cancelled)$")
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SubtaskRead(BaseModel):
|
||||
"""Subtask response."""
|
||||
|
||||
id: str
|
||||
parent_agent_id: str
|
||||
child_agent_id: str
|
||||
task_description: str
|
||||
status: str = "pending"
|
||||
result: dict[str, Any] = {}
|
||||
created_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SubtaskListResponse(BaseModel):
|
||||
"""Paginated subtask list."""
|
||||
|
||||
items: list[SubtaskRead]
|
||||
total: int
|
||||
|
||||
Reference in New Issue
Block a user