"""Chat service module with Agentic Loop pattern. This module provides the core chat service that orchestrates: - StreamContext: Manages streaming state transitions - MessageBuilder: Constructs message lists - AgenticLoop: Executes the agentic loop (LLM + tools iteration) - ChatService: Core chat service facade """ import json import logging import traceback import httpx from typing import List, Dict, Any, AsyncGenerator from luxx.tools.executor import ToolExecutor from luxx.tools.core import registry from luxx.services.llm_client import LLMClient from luxx.services.stream_context import StreamContext from luxx.services.agentic_loop import AgenticLoop from luxx.config import config logger = logging.getLogger(__name__) # ============== MessageBuilder ============== class MessageBuilder: """Builds message lists for LLM requests.""" def __init__(self): self.messages = [] def add_system(self, content: str) -> 'MessageBuilder': """Add system message.""" self.messages.append({"role": "system", "content": content}) return self def add_user(self, content: str, attachments: list = None) -> 'MessageBuilder': """Add user message in JSON format.""" msg_content = json.dumps({ "text": content, "attachments": attachments or [] }, ensure_ascii=False) self.messages.append({"role": "user", "content": msg_content}) return self def add_assistant(self, content: str, tool_calls: list = None) -> 'MessageBuilder': """Add assistant message.""" msg = {"role": "assistant", "content": content} if tool_calls: msg["tool_calls"] = tool_calls self.messages.append(msg) return self def add_tool_result(self, tool_call_id: str, content: str) -> 'MessageBuilder': """Add tool result message.""" self.messages.append({ "role": "tool", "tool_call_id": tool_call_id, "content": content }) return self def build(self) -> List[Dict]: """Build and return message list.""" return self.messages.copy() @staticmethod def extract_text(content: str) -> str: """Extract text from message content (supports JSON format).""" if not content: return "" try: parsed = json.loads(content) if isinstance(parsed, dict): return parsed.get("text", content) except (json.JSONDecodeError, TypeError): pass return content # ============== Factory Function ============== def get_llm_client(conversation=None) -> tuple: """Get LLM client based on conversation provider. Returns (client, max_tokens)""" from luxx.models import LLMProvider from luxx.database import SessionLocal max_tokens = None if conversation and conversation.provider_id: db = SessionLocal() try: provider = db.query(LLMProvider).filter( LLMProvider.id == conversation.provider_id ).first() if provider: max_tokens = provider.max_tokens client = LLMClient( api_key=provider.api_key, api_url=provider.base_url, model=provider.default_model ) return client, max_tokens finally: db.close() return LLMClient(), max_tokens # ============== ChatService ============== class ChatService: """Core chat service with Agentic Loop support.""" def __init__(self): self.tool_executor = ToolExecutor() self.agentic_loop = AgenticLoop(self.tool_executor) def build_messages(self, conversation, include_system: bool = True) -> List[Dict]: """Build message list from conversation history.""" from luxx.database import SessionLocal from luxx.models import Message messages = [] if include_system and conversation.system_prompt: messages.append({"role": "system", "content": conversation.system_prompt}) db = SessionLocal() try: db_messages = db.query(Message).filter( Message.conversation_id == conversation.id ).order_by(Message.created_at).all() for msg in db_messages: content = MessageBuilder.extract_text(msg.content) messages.append({"role": msg.role, "content": content}) finally: db.close() return messages def _get_tools(self, enabled_tools: list) -> list: """Filter tools based on enabled_tools list.""" if not enabled_tools: return [] return [t for t in registry.list_all() if t.get("function", {}).get("name") in enabled_tools] async def stream_response( self, conversation, user_message: str, thinking_enabled: bool = False, enabled_tools: list = None, user_id: int = None, username: str = None, workspace: str = None, user_permission_level: int = 1 ) -> AsyncGenerator[str, None]: """Streaming response with Agentic Loop.""" try: # Build initial messages messages = self.build_messages(conversation) messages.append({ "role": "user", "content": json.dumps({"text": user_message, "attachments": []}) }) # Get tools and LLM client tools = self._get_tools(enabled_tools) llm, provider_max_tokens = get_llm_client(conversation) model = conversation.model or llm.default_model or "gpt-4" max_tokens = provider_max_tokens or 8192 # Tool execution context tool_context = { "workspace": workspace, "user_id": user_id, "username": username, "user_permission_level": user_permission_level } # Stream context ctx = StreamContext() # Execute agentic loop async for event in self.agentic_loop.execute( llm=llm, model=model, messages=messages, tools=tools, temperature=conversation.temperature, max_tokens=max_tokens, thinking_enabled=thinking_enabled or conversation.thinking_enabled, context=ctx, tool_context=tool_context ): yield event # Save message after successful completion (only if we have content) if ctx._last_message_id and (ctx.full_content or ctx.all_tool_calls): self._save_message( conversation.id, ctx._last_message_id, ctx.full_content, ctx.all_tool_calls, ctx.all_tool_results, ctx.all_steps, ctx._last_token_count, ctx._last_usage ) except Exception as e: logger.error(f"Stream error: {e}\n{traceback.format_exc()}") yield _sse_event("error", {"content": str(e)}) async def non_stream_response( self, conversation, user_message: str, tools_enabled: bool = True, thinking_enabled: bool = False ) -> Dict[str, Any]: """Non-streaming response for simple requests.""" try: messages = self.build_messages(conversation) messages.append({ "role": "user", "content": json.dumps({"text": user_message, "attachments": []}) }) tools = [] if not tools_enabled else None llm, max_tokens = get_llm_client(conversation) model = conversation.model or llm.default_model or "gpt-4" response = await llm.sync_call( model=model, messages=messages, tools=tools, temperature=conversation.temperature, max_tokens=max_tokens or 8192, thinking_enabled=thinking_enabled or conversation.thinking_enabled ) return { "success": True, "content": response.content, "tool_calls": response.tool_calls, "usage": response.usage } except httpx.HTTPStatusError as e: error_msg = f"HTTP {e.response.status_code}: {e.response.text[:200] if e.response else 'No response body'}" logger.error(f"Non-stream HTTP error: {error_msg}") return {"success": False, "error": error_msg} except httpx.TimeoutException as e: logger.error(f"Non-stream timeout: {e}") return {"success": False, "error": "Request timeout"} except Exception as e: logger.error(f"Non-stream error: {type(e).__name__}: {e}\n{traceback.format_exc()}") return {"success": False, "error": f"{type(e).__name__}: {str(e)}"} def _save_message(self, conversation_id: str, msg_id: str, full_content: str, all_tool_calls: list, all_tool_results: list, all_steps: list, token_count: int = 0, usage: dict = None): """Save assistant message to database.""" from luxx.database import SessionLocal from luxx.models import Message content_json = {"text": full_content, "steps": all_steps} if all_tool_calls: content_json["tool_calls"] = all_tool_calls db = SessionLocal() try: msg = Message( id=msg_id, conversation_id=conversation_id, role="assistant", content=json.dumps(content_json, ensure_ascii=False), token_count=token_count, usage=json.dumps(usage) if usage else None ) db.add(msg) db.commit() except Exception: db.rollback() raise finally: db.close() def _sse_event(event: str, data: dict) -> str: """Format a Server-Sent Event string.""" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" # ============== Global Singleton ============== chat_service = ChatService()