""" ReAct-Orchestrator für den AI Agent. Implementiert einen synchronen ReAct-Loop (Reason + Act) mit: - max. 5 Iterationen - Tool-Calling - Streaming via Generator - Audit-Logging """ from __future__ import annotations import json import logging from typing import Generator from .providers import get_provider, LLMError from .tools import execute_tool, TOOL_SCHEMAS logger = logging.getLogger(__name__) MAX_ITERATIONS = 5 def run_agent_stream( session, user_message: str, page_context: str = "", user=None, ) -> Generator[str, None, None]: """ Führt den ReAct-Loop aus und streamt SSE-kompatible Daten-Strings. Yield-Format (Server-Sent Events): "data: {json}\n\n" JSON-Typen: {"type": "text", "content": "..."} – Textfragment {"type": "tool_start", "name": "..."} – Tool wird aufgerufen {"type": "tool_result", "name": "...", "result": "..."} {"type": "done"} {"type": "error", "message": "..."} """ from .models import AgentConfig, ChatMessage config = AgentConfig.get_config() # Systemkontext aufbauen system_content = config.system_prompt if page_context: system_content += f"\n\nAktueller Seitenkontext:\n{page_context}" # Nachrichtenhistorie laden (letzte 20 Nachrichten) history = list( session.messages.exclude(role="tool") .order_by("-created_at")[:20] ) history.reverse() messages = [{"role": "system", "content": system_content}] for msg in history: if msg.role in ("user", "assistant"): messages.append({"role": msg.role, "content": msg.content}) # Neue User-Nachricht messages.append({"role": "user", "content": user_message}) # Neue User-Message in DB speichern ChatMessage.objects.create( session=session, role="user", content=user_message, ) # Sesstionttitel setzen falls leer if not session.title and user_message: session.title = user_message[:100] session.save(update_fields=["title", "updated_at"]) tools = TOOL_SCHEMAS if not getattr(config, "allow_write", False) else TOOL_SCHEMAS try: provider = get_provider(config) except LLMError as e: yield _sse({"type": "error", "message": str(e)}) return full_assistant_text = "" iteration = 0 tools_disabled = False while iteration < MAX_ITERATIONS: iteration += 1 text_buffer = "" pending_tool_calls = [] current_tools = None if tools_disabled else tools try: for chunk in provider.chat_stream(messages=messages, tools=current_tools): chunk_type = chunk.get("type") if chunk_type == "text": text = chunk["content"] text_buffer += text full_assistant_text += text yield _sse({"type": "text", "content": text}) elif chunk_type == "tool_call": pending_tool_calls.append(chunk) elif chunk_type == "done": break elif chunk_type == "error": yield _sse({"type": "error", "message": chunk.get("message", "Unbekannter Fehler")}) return except LLMError as e: if not tools_disabled and iteration == 1: # Tool-Calling hat den Provider zum Absturz gebracht (z.B. OOM). # Fallback: ohne Tools erneut versuchen. # Warte kurz, damit Ollama nach OOM-Crash neu starten kann. import time logger.warning("LLM-Fehler mit Tools, Fallback auf Chat-only: %s", e) tools_disabled = True full_assistant_text = "" time.sleep(15) continue yield _sse({"type": "error", "message": str(e)}) return if not pending_tool_calls: # Kein Tool-Call → Antwort fertig break # Tool-Calls verarbeiten # Assistent-Nachricht mit tool_calls in History tool_calls_for_msg = [] for tc in pending_tool_calls: tool_calls_for_msg.append({ "id": tc["id"], "type": "function", "function": { "name": tc["name"], "arguments": json.dumps(tc["arguments"], ensure_ascii=False), }, }) assistant_msg: dict = {"role": "assistant", "content": text_buffer or ""} if tool_calls_for_msg: assistant_msg["tool_calls"] = tool_calls_for_msg messages.append(assistant_msg) # Jeden Tool-Call ausführen for tc in pending_tool_calls: tool_name = tc["name"] tool_args = tc["arguments"] tool_call_id = tc["id"] yield _sse({"type": "tool_start", "name": tool_name}) result = execute_tool(tool_name, tool_args, user) # Tool-Ergebnis in DB ChatMessage.objects.create( session=session, role="tool", content=result, tool_name=tool_name, tool_call_id=tool_call_id, ) yield _sse({"type": "tool_result", "name": tool_name, "result": result[:500]}) # Tool-Ergebnis in Messages für nächste LLM-Iteration messages.append({ "role": "tool", "tool_call_id": tool_call_id, "content": result, }) full_assistant_text = "" # Reset für nächste Iteration # Abschließende Assistent-Nachricht in DB speichern if full_assistant_text: ChatMessage.objects.create( session=session, role="assistant", content=full_assistant_text, ) # Session updated_at aktualisieren from django.utils import timezone session.updated_at = timezone.now() session.save(update_fields=["updated_at"]) yield _sse({"type": "done"}) def _sse(data: dict) -> str: """Formatiert ein Dict als SSE data-Zeile.""" return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"