diff --git a/app/ai/agent_memory.py b/app/ai/agent_memory.py new file mode 100644 index 0000000..dd2227f --- /dev/null +++ b/app/ai/agent_memory.py @@ -0,0 +1,236 @@ +"""Agent memory facade — unified API for persistent agent memory. + +Delegates to the ``agent_memory`` plugin (pgvector semantic search) and +provides the canonical function signatures used by the agent framework +(``store_agent_memory``, ``retrieve_agent_memory``, ``search_agent_memory``). + +Used by: +- ``app/ai/agent_loop.py`` — memory retrieval during ReAct loops +- ``app/plugins/builtins/automation`` — agent memory tools +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +# Memory types supported by the agent memory system. +MEMORY_TYPES = ("observation", "preference", "fact", "context") + + +def _memory_type(value: str | None) -> str: + """Normalize a memory type to a supported value.""" + if value in MEMORY_TYPES: + return value + return "fact" + + +async def store_agent_memory( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + memory_type: str, + content: str, + metadata: dict[str, Any] | None = None, +) -> uuid.UUID: + """Store a new agent memory with semantic embedding. + + Args: + db: Database session. + tenant_id: Tenant UUID. + agent_id: Agent UUID. + memory_type: One of ``observation``, ``preference``, ``fact``, ``context``. + content: Memory content text. + metadata: Optional metadata dict (stored as JSONB on the memory row). + + Returns: + The UUID of the created memory. + """ + from app.plugins.builtins.agent_memory.services import store_memory + + result = await store_memory( + db=db, + tenant_id=tenant_id, + agent_id=agent_id, + content=content, + memory_type=_memory_type(memory_type), + ) + memory_id = uuid.UUID(result["id"]) + + # Persist optional metadata on the memory row. + if metadata: + from sqlalchemy import update + + from app.plugins.builtins.agent_memory.models import AgentMemory + + await db.execute( + update(AgentMemory) + .where(AgentMemory.id == memory_id) + .values(metadata_=metadata) + ) + await db.flush() + + return memory_id + + +async def retrieve_agent_memory( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + memory_type: str | None = None, + limit: int = 10, +) -> list[dict[str, Any]]: + """Retrieve recent memories for an agent, optionally filtered by type. + + Args: + db: Database session. + tenant_id: Tenant UUID. + agent_id: Agent UUID. + memory_type: Optional memory type filter. + limit: Maximum number of results (default 10). + + Returns: + List of memory dicts, newest first. + """ + from sqlalchemy import select + + from app.plugins.builtins.agent_memory.models import AgentMemory + + stmt = ( + select(AgentMemory) + .where( + AgentMemory.tenant_id == tenant_id, + AgentMemory.agent_id == agent_id, + AgentMemory.deleted_at.is_(None), + ) + .order_by(AgentMemory.created_at.desc()) + .limit(limit) + ) + if memory_type: + stmt = stmt.where(AgentMemory.memory_type == _memory_type(memory_type)) + + result = await db.execute(stmt) + memories = result.scalars().all() + return [ + { + "id": str(m.id), + "agent_id": str(m.agent_id), + "memory_type": m.memory_type, + "content": m.content, + "metadata": getattr(m, "metadata_", None) or {}, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + for m in memories + ] + + +async def search_agent_memory( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + query: str, + limit: int = 5, +) -> list[dict[str, Any]]: + """Semantic search over agent memories using pgvector embeddings. + + Falls back to recent-memory retrieval when embedding generation is + unavailable (e.g. no embedding model configured). + + Args: + db: Database session. + tenant_id: Tenant UUID. + agent_id: Agent UUID. + query: Natural language query. + limit: Maximum number of results (default 5). + + Returns: + List of memory dicts with similarity scores, sorted by relevance. + """ + from app.plugins.builtins.agent_memory.services import retrieve_relevant_memories + + return await retrieve_relevant_memories( + db=db, + tenant_id=tenant_id, + agent_id=agent_id, + query=query, + limit=limit, + min_score=0.0, + ) + + +def register_agent_memory_tools(registry) -> None: + """Register agent memory tools in the global ToolRegistry. + + Registers ``search_agent_memory`` so AI agents can query their own + persistent memory during ReAct loops. + """ + import json + + async def search_agent_memory_handler( + arguments: dict[str, Any], context: dict[str, Any] + ) -> str: + """Handle search_agent_memory tool call from an AI agent.""" + from app.core.db import get_session_factory + + query = arguments.get("query", "") + limit = arguments.get("limit", 5) + agent_id_str = context.get("agent_id", "") + tenant_id_str = context.get("tenant_id", "") + + if not query: + return json.dumps({"error": "Missing query"}) + try: + tenant_id = uuid.UUID(tenant_id_str) if tenant_id_str else uuid.uuid4() + agent_id = uuid.UUID(agent_id_str) if agent_id_str else uuid.uuid4() + except (ValueError, TypeError): + return json.dumps({"error": "Invalid tenant_id or agent_id"}) + + factory = get_session_factory() + async with factory() as db: + results = await search_agent_memory( + db=db, + tenant_id=tenant_id, + agent_id=agent_id, + query=query, + limit=limit, + ) + return json.dumps({"memories": results, "count": len(results)}, default=str) + + registry.register( + name="search_agent_memory", + description=( + "Semantische Suche über das persistente Gedächtnis eines Agents. " + "Findet relevante frühere Beobachtungen, Fakten und Präferenzen." + ), + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natürlichsprachliche Suchanfrage", + }, + "limit": { + "type": "integer", + "default": 5, + "description": "Maximale Anzahl Ergebnisse", + }, + }, + "required": ["query"], + }, + handler=search_agent_memory_handler, + plugin_name="agent_memory", + required_permission="agent_memory:read", + category="memory", + ) + logger.info("Agent memory tool 'search_agent_memory' registered") + + +def unregister_agent_memory_tools(registry) -> None: + """Unregister agent memory tools from the global ToolRegistry.""" + registry.unregister("search_agent_memory") + logger.info("Agent memory tool 'search_agent_memory' unregistered") diff --git a/app/plugins/builtins/agent_memory/migrations/0002_metadata.sql b/app/plugins/builtins/agent_memory/migrations/0002_metadata.sql new file mode 100644 index 0000000..e76a72d --- /dev/null +++ b/app/plugins/builtins/agent_memory/migrations/0002_metadata.sql @@ -0,0 +1,3 @@ +-- Agent Memory: add metadata JSONB column for structured memory metadata + +ALTER TABLE agent_memories ADD COLUMN IF NOT EXISTS metadata JSONB; diff --git a/app/plugins/builtins/agent_memory/models.py b/app/plugins/builtins/agent_memory/models.py index 33ca6d4..7864f12 100644 --- a/app/plugins/builtins/agent_memory/models.py +++ b/app/plugins/builtins/agent_memory/models.py @@ -3,8 +3,10 @@ from __future__ import annotations import uuid +from typing import Any from sqlalchemy import Index, String, Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column @@ -35,5 +37,8 @@ class AgentMemory(Base, TenantMixin, OwnedMixin): String(50), nullable=False, default="fact" ) content: Mapped[str] = mapped_column(Text, nullable=False) + metadata_: Mapped[dict[str, Any] | None] = mapped_column( + "metadata", JSONB, nullable=True + ) # embedding column is managed via raw SQL (pgvector extension) # embedding vector(768) — see migration 0001_initial.sql