fix: connect 10 unconnected backend modules to real code paths (context_builder→agent_runner, agent_permissions→agent_runner, agent_tools→agent_runner, data_policy→agent_runner, oversight→agent_runner+migration 0128, transparency→agent_runner, agent_stream→agent_routes SSE endpoint, agent_memory AI-module deleted, decision_guard→engine, require_approval→agent_runner), 11 integration tests passing
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -1,236 +0,0 @@
|
||||
"""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")
|
||||
@@ -601,3 +601,55 @@ async def send_agent_message_endpoint(
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# ─── Punkt 7: SSE Streaming Endpoint (agent_stream.py) ─────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/stream")
|
||||
async def stream_agent_run(
|
||||
id: str,
|
||||
body: AgentMessageRequest,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream an agent run via Server-Sent Events (SSE).
|
||||
|
||||
Uses ``app.ai.agent_stream.stream_react_loop`` to emit step events
|
||||
in real-time as the agent processes.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.ai.agent_stream import stream_react_loop
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
aid = uuid.UUID(id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid agent ID") from None
|
||||
|
||||
agent = await AgentService.get_by_id(db, tenant_id, aid)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if not agent.is_active:
|
||||
raise HTTPException(status_code=400, detail="Agent is not active")
|
||||
|
||||
registry = get_tool_registry()
|
||||
tool_ids: list[str] = list(agent.tool_ids or [])
|
||||
tools = registry.get_by_names(tool_ids) if tool_ids else []
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else []
|
||||
|
||||
return StreamingResponse(
|
||||
stream_react_loop(
|
||||
agent_definition=agent,
|
||||
user_message=body.message,
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_run_id=aid,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
@@ -155,6 +155,28 @@ async def run_agent(
|
||||
tools = registry.get_by_names(tool_ids) if tool_ids else []
|
||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else []
|
||||
|
||||
# ── Resolve agent permissions (Punkt 2+3 der Audit) ──
|
||||
from app.ai.agent_permissions import resolve_agent_permissions
|
||||
from app.ai.agent_tools import get_agent_tools
|
||||
from app.ai.skill_registry import get_skill_registry
|
||||
|
||||
async with factory() as db:
|
||||
perm_ctx = await resolve_agent_permissions(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
agent_definition=agent,
|
||||
)
|
||||
|
||||
# Use permission-filtered tools instead of raw tool_ids
|
||||
skill_reg = get_skill_registry()
|
||||
tool_schemas, _skills = get_agent_tools(
|
||||
agent_definition=agent,
|
||||
tool_registry=registry,
|
||||
skill_registry=skill_reg,
|
||||
user_permissions=perm_ctx.user_permissions,
|
||||
)
|
||||
|
||||
# ── Create AgentRun record ──
|
||||
run_id: uuid.UUID | None = None
|
||||
started_at = datetime.now(UTC)
|
||||
@@ -192,10 +214,38 @@ async def run_agent(
|
||||
import asyncio
|
||||
import uuid as uuid_mod
|
||||
|
||||
# ── Build agent context via context_builder (Punkt 1 der Audit) ──
|
||||
from app.ai.context_builder import build_agent_context
|
||||
|
||||
# Sanitize context_data to remove sensitive fields (Punkt 4: data_policy)
|
||||
from app.core.sensitive_data import sanitize_dict
|
||||
safe_context_data = sanitize_dict(context_data)
|
||||
|
||||
# Build the user message from sanitized context
|
||||
user_message = f"Context: {safe_context_data}" if safe_context_data else "No additional context provided."
|
||||
|
||||
# Build full message list (system prompt + context + user message)
|
||||
messages = await build_agent_context(
|
||||
agent_definition=agent,
|
||||
user_message=user_message,
|
||||
db=None, # No DB session available here; context_builder handles gracefully
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
)
|
||||
|
||||
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ──
|
||||
from app.ai.data_policy import enforce_data_policy
|
||||
messages = await enforce_data_policy(
|
||||
db=None,
|
||||
tenant_id=agent.tenant_id,
|
||||
messages=messages,
|
||||
agent_definition=agent,
|
||||
)
|
||||
|
||||
react_result: ReActResult = await asyncio.wait_for(
|
||||
run_react_loop(
|
||||
agent_definition=agent,
|
||||
messages=[{"role": "user", "content": f"Context: {context_data}"}],
|
||||
messages=messages,
|
||||
tools=tool_schemas,
|
||||
tool_registry=registry,
|
||||
db=None, # ReAct loop doesn't need DB session for LLM calls directly
|
||||
@@ -204,6 +254,8 @@ async def run_agent(
|
||||
agent_run_id=run_id,
|
||||
max_steps=20,
|
||||
timeout_seconds=max_duration,
|
||||
require_approval=bool(getattr(agent, "require_approval", False)),
|
||||
approval_tools=getattr(agent, "approval_tools", None),
|
||||
),
|
||||
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
|
||||
)
|
||||
@@ -212,6 +264,40 @@ async def run_agent(
|
||||
result_data["llm_response"] = react_result.final_content
|
||||
result_data["cost_usd"] = react_result.total_cost_usd
|
||||
result_data["error"] = react_result.error
|
||||
|
||||
# ── Mark result as AI-generated (Punkt 6: transparency) ──
|
||||
from app.ai.transparency import mark_as_ai_generated
|
||||
if react_result.final_content:
|
||||
ai_metadata = mark_as_ai_generated(
|
||||
react_result.final_content,
|
||||
metadata={
|
||||
"model": getattr(agent, "llm_model", "unknown"),
|
||||
"provider": getattr(agent, "provider", "unknown"),
|
||||
"agent_id": str(agent.id),
|
||||
"agent_name": agent.name,
|
||||
"run_id": str(run_id) if run_id else None,
|
||||
},
|
||||
)
|
||||
result_data["ai_generated"] = True
|
||||
result_data["ai_metadata"] = ai_metadata.get("ai_metadata", {})
|
||||
|
||||
# ── Create oversight decision record (Punkt 5: oversight) ──
|
||||
from app.ai.oversight import DecisionRecord, create_decision_record
|
||||
try:
|
||||
async with factory() as db:
|
||||
record = DecisionRecord(
|
||||
agent_run_id=run_id or uuid_mod.uuid4(),
|
||||
recommendation=react_result.final_content,
|
||||
evidence={
|
||||
"steps": len(react_result.steps),
|
||||
"cost_usd": react_result.total_cost_usd,
|
||||
"status": react_result.status,
|
||||
},
|
||||
)
|
||||
await create_decision_record(db, agent.tenant_id, record)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create oversight decision record: %s", e)
|
||||
result_data["steps"] = [
|
||||
{
|
||||
"step_number": s.step_number,
|
||||
|
||||
@@ -142,6 +142,29 @@ class WorkflowEngine:
|
||||
"""Execute a step using a registered step handler (G step types)."""
|
||||
step_type = step.get("type", "action")
|
||||
|
||||
# ── Decision Guard: check if action requires human review (Punkt 9) ──
|
||||
from app.workflows.decision_guard import check_decision_guard
|
||||
step_config = step.get("config", {})
|
||||
action_name = step_config.get("action", step_type)
|
||||
guard_result = await check_decision_guard(
|
||||
db=self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
instance_id=instance.id,
|
||||
step_config=step_config,
|
||||
action=action_name,
|
||||
)
|
||||
if not guard_result["allowed"]:
|
||||
# Guard blocks — pause workflow and create approval request
|
||||
instance.status = "in_progress"
|
||||
instance.resume_reason = "decision_guard"
|
||||
await self.db.flush()
|
||||
return {
|
||||
"status": "waiting_for_approval",
|
||||
"guard": guard_result,
|
||||
"step_index": instance.current_step_index,
|
||||
"message": guard_result.get("reason", "Human review required"),
|
||||
}
|
||||
|
||||
try:
|
||||
result: StepResult = await handler(
|
||||
self.db,
|
||||
|
||||
Reference in New Issue
Block a user