Phase 5.5-5.9: Plugin-Marketplace, Agent Memory, GraphRAG, Subagents, External Agent API
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:
Agent Zero
2026-08-04 15:06:23 +02:00
parent 597aea1c23
commit 000c969b13
36 changed files with 3118 additions and 2 deletions
@@ -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);
+37
View File
@@ -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
)
+13 -1
View File
@@ -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
+238
View File
@@ -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