open-swarm 0.1.1743070217__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- open_swarm-0.1.1743070217.dist-info/METADATA +258 -0
- open_swarm-0.1.1743070217.dist-info/RECORD +89 -0
- open_swarm-0.1.1743070217.dist-info/WHEEL +5 -0
- open_swarm-0.1.1743070217.dist-info/entry_points.txt +3 -0
- open_swarm-0.1.1743070217.dist-info/licenses/LICENSE +21 -0
- open_swarm-0.1.1743070217.dist-info/top_level.txt +1 -0
- swarm/__init__.py +3 -0
- swarm/agent/__init__.py +7 -0
- swarm/agent/agent.py +49 -0
- swarm/apps.py +53 -0
- swarm/auth.py +56 -0
- swarm/consumers.py +141 -0
- swarm/core.py +326 -0
- swarm/extensions/__init__.py +1 -0
- swarm/extensions/blueprint/__init__.py +36 -0
- swarm/extensions/blueprint/agent_utils.py +45 -0
- swarm/extensions/blueprint/blueprint_base.py +562 -0
- swarm/extensions/blueprint/blueprint_discovery.py +112 -0
- swarm/extensions/blueprint/blueprint_utils.py +17 -0
- swarm/extensions/blueprint/common_utils.py +12 -0
- swarm/extensions/blueprint/django_utils.py +203 -0
- swarm/extensions/blueprint/interactive_mode.py +102 -0
- swarm/extensions/blueprint/modes/rest_mode.py +37 -0
- swarm/extensions/blueprint/output_utils.py +95 -0
- swarm/extensions/blueprint/spinner.py +91 -0
- swarm/extensions/cli/__init__.py +0 -0
- swarm/extensions/cli/blueprint_runner.py +251 -0
- swarm/extensions/cli/cli_args.py +88 -0
- swarm/extensions/cli/commands/__init__.py +0 -0
- swarm/extensions/cli/commands/blueprint_management.py +31 -0
- swarm/extensions/cli/commands/config_management.py +15 -0
- swarm/extensions/cli/commands/edit_config.py +77 -0
- swarm/extensions/cli/commands/list_blueprints.py +22 -0
- swarm/extensions/cli/commands/validate_env.py +57 -0
- swarm/extensions/cli/commands/validate_envvars.py +39 -0
- swarm/extensions/cli/interactive_shell.py +41 -0
- swarm/extensions/cli/main.py +36 -0
- swarm/extensions/cli/selection.py +43 -0
- swarm/extensions/cli/utils/discover_commands.py +32 -0
- swarm/extensions/cli/utils/env_setup.py +15 -0
- swarm/extensions/cli/utils.py +105 -0
- swarm/extensions/config/__init__.py +6 -0
- swarm/extensions/config/config_loader.py +208 -0
- swarm/extensions/config/config_manager.py +258 -0
- swarm/extensions/config/server_config.py +49 -0
- swarm/extensions/config/setup_wizard.py +103 -0
- swarm/extensions/config/utils/__init__.py +0 -0
- swarm/extensions/config/utils/logger.py +36 -0
- swarm/extensions/launchers/__init__.py +1 -0
- swarm/extensions/launchers/build_launchers.py +14 -0
- swarm/extensions/launchers/build_swarm_wrapper.py +12 -0
- swarm/extensions/launchers/swarm_api.py +68 -0
- swarm/extensions/launchers/swarm_cli.py +304 -0
- swarm/extensions/launchers/swarm_wrapper.py +29 -0
- swarm/extensions/mcp/__init__.py +1 -0
- swarm/extensions/mcp/cache_utils.py +36 -0
- swarm/extensions/mcp/mcp_client.py +341 -0
- swarm/extensions/mcp/mcp_constants.py +7 -0
- swarm/extensions/mcp/mcp_tool_provider.py +110 -0
- swarm/llm/chat_completion.py +195 -0
- swarm/messages.py +132 -0
- swarm/migrations/0010_initial_chat_models.py +51 -0
- swarm/migrations/__init__.py +0 -0
- swarm/models.py +45 -0
- swarm/repl/__init__.py +1 -0
- swarm/repl/repl.py +87 -0
- swarm/serializers.py +12 -0
- swarm/settings.py +189 -0
- swarm/tool_executor.py +239 -0
- swarm/types.py +126 -0
- swarm/urls.py +89 -0
- swarm/util.py +124 -0
- swarm/utils/color_utils.py +40 -0
- swarm/utils/context_utils.py +272 -0
- swarm/utils/general_utils.py +162 -0
- swarm/utils/logger.py +61 -0
- swarm/utils/logger_setup.py +25 -0
- swarm/utils/message_sequence.py +173 -0
- swarm/utils/message_utils.py +95 -0
- swarm/utils/redact.py +68 -0
- swarm/views/__init__.py +41 -0
- swarm/views/api_views.py +46 -0
- swarm/views/chat_views.py +76 -0
- swarm/views/core_views.py +118 -0
- swarm/views/message_views.py +40 -0
- swarm/views/model_views.py +135 -0
- swarm/views/utils.py +457 -0
- swarm/views/web_views.py +149 -0
- swarm/wsgi.py +16 -0
@@ -0,0 +1,135 @@
|
|
1
|
+
"""
|
2
|
+
Model listing views for Open Swarm MCP Core.
|
3
|
+
Dynamically discovers blueprints and lists them alongside configured LLMs.
|
4
|
+
"""
|
5
|
+
import os
|
6
|
+
from django.http import JsonResponse
|
7
|
+
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
8
|
+
from rest_framework.permissions import AllowAny # Import AllowAny
|
9
|
+
from drf_spectacular.utils import extend_schema
|
10
|
+
|
11
|
+
from swarm.utils.logger_setup import setup_logger
|
12
|
+
# Import the function to discover blueprints, not the metadata variable
|
13
|
+
from swarm.extensions.blueprint.blueprint_discovery import discover_blueprints
|
14
|
+
# Import utility to filter blueprints if needed
|
15
|
+
from swarm.extensions.blueprint.blueprint_utils import filter_blueprints
|
16
|
+
# Import config loader or access config globally if set up
|
17
|
+
# Using utils seems less direct, let's assume config needs loading or is globally available
|
18
|
+
# from swarm.views.utils import config # This import might be problematic, load directly if needed
|
19
|
+
from swarm.extensions.config.config_loader import load_server_config
|
20
|
+
from swarm.settings import BLUEPRINTS_DIR # Import the directory setting
|
21
|
+
|
22
|
+
logger = setup_logger(__name__)
|
23
|
+
|
24
|
+
@extend_schema(
|
25
|
+
responses={
|
26
|
+
200: {
|
27
|
+
"type": "object",
|
28
|
+
"properties": {
|
29
|
+
"object": {"type": "string"},
|
30
|
+
"data": {
|
31
|
+
"type": "array",
|
32
|
+
"items": {
|
33
|
+
"type": "object",
|
34
|
+
"properties": {
|
35
|
+
"id": {"type": "string"},
|
36
|
+
"object": {"type": "string"},
|
37
|
+
"title": {"type": "string"},
|
38
|
+
"description": {"type": "string"}
|
39
|
+
}
|
40
|
+
}
|
41
|
+
}
|
42
|
+
}
|
43
|
+
}
|
44
|
+
},
|
45
|
+
summary="Lists LLMs, config-defined blueprints, and discovered blueprints as models."
|
46
|
+
)
|
47
|
+
@api_view(["GET"])
|
48
|
+
@permission_classes([AllowAny]) # Use AllowAny directly
|
49
|
+
@authentication_classes([]) # No authentication required for listing models
|
50
|
+
def list_models(request):
|
51
|
+
"""List available LLMs, config-defined blueprints, and discovered blueprints."""
|
52
|
+
if request.method != "GET":
|
53
|
+
return JsonResponse({"error": "Method not allowed. Use GET."}, status=405)
|
54
|
+
|
55
|
+
try:
|
56
|
+
# Load configuration each time or ensure it's loaded globally/cached
|
57
|
+
config = load_server_config() # Load config to get LLMs and config blueprints
|
58
|
+
|
59
|
+
# 1. LLMs from config (marked as passthrough)
|
60
|
+
llm_config = config.get("llm", {})
|
61
|
+
llm_data = [
|
62
|
+
{
|
63
|
+
"id": key,
|
64
|
+
"object": "llm", # Mark as llm type
|
65
|
+
"title": conf.get("model", key), # Use model name or key as title
|
66
|
+
"description": f"Provider: {conf.get('provider', 'N/A')}, Model: {conf.get('model', 'N/A')}"
|
67
|
+
}
|
68
|
+
for key, conf in llm_config.items() if conf.get("passthrough")
|
69
|
+
]
|
70
|
+
|
71
|
+
# 2. Blueprints defined directly in swarm_config.json
|
72
|
+
config_blueprints = config.get("blueprints", {})
|
73
|
+
config_bp_data = [
|
74
|
+
{
|
75
|
+
"id": key,
|
76
|
+
"object": "blueprint", # Mark as blueprint type
|
77
|
+
"title": bp.get("title", key),
|
78
|
+
"description": bp.get("description", f"Blueprint '{key}' from configuration file.")
|
79
|
+
}
|
80
|
+
for key, bp in config_blueprints.items()
|
81
|
+
]
|
82
|
+
|
83
|
+
# 3. Dynamically discovered blueprints from the blueprints directory
|
84
|
+
# Ensure BLUEPRINTS_DIR is correctly pointing to your blueprints location relative to project root
|
85
|
+
try:
|
86
|
+
# Call discover_blueprints function to get the metadata dictionary
|
87
|
+
discovered_blueprints_metadata = discover_blueprints(directories=[BLUEPRINTS_DIR])
|
88
|
+
except FileNotFoundError:
|
89
|
+
logger.warning(f"Blueprints directory '{BLUEPRINTS_DIR}' not found. No blueprints discovered dynamically.")
|
90
|
+
discovered_blueprints_metadata = {}
|
91
|
+
except Exception as discover_err:
|
92
|
+
logger.error(f"Error discovering blueprints: {discover_err}", exc_info=True)
|
93
|
+
discovered_blueprints_metadata = {}
|
94
|
+
|
95
|
+
|
96
|
+
# Filter discovered blueprints based on environment variable if set
|
97
|
+
allowed_blueprints_str = os.getenv("SWARM_BLUEPRINTS")
|
98
|
+
if allowed_blueprints_str and allowed_blueprints_str.strip():
|
99
|
+
# Use the imported filter_blueprints utility
|
100
|
+
final_discovered_metadata = filter_blueprints(discovered_blueprints_metadata, allowed_blueprints_str)
|
101
|
+
logger.info(f"Filtering discovered blueprints based on SWARM_BLUEPRINTS env var. Kept: {list(final_discovered_metadata.keys())}")
|
102
|
+
else:
|
103
|
+
final_discovered_metadata = discovered_blueprints_metadata # Use all discovered if no filter
|
104
|
+
|
105
|
+
# Format discovered blueprint data
|
106
|
+
discovered_bp_data = [
|
107
|
+
{
|
108
|
+
"id": key,
|
109
|
+
"object": "blueprint", # Mark as blueprint type
|
110
|
+
"title": meta.get("title", key),
|
111
|
+
"description": meta.get("description", f"Discovered blueprint '{key}'.")
|
112
|
+
}
|
113
|
+
for key, meta in final_discovered_metadata.items()
|
114
|
+
]
|
115
|
+
|
116
|
+
# 4. Merge all data sources
|
117
|
+
# Start with LLMs and config blueprints
|
118
|
+
merged_data = llm_data + config_bp_data
|
119
|
+
# Keep track of IDs already added
|
120
|
+
seen_ids = {item["id"] for item in merged_data}
|
121
|
+
# Add discovered blueprints only if their ID hasn't been used by config/LLMs
|
122
|
+
for bp_item in discovered_bp_data:
|
123
|
+
if bp_item["id"] not in seen_ids:
|
124
|
+
merged_data.append(bp_item)
|
125
|
+
seen_ids.add(bp_item["id"]) # Mark ID as seen
|
126
|
+
|
127
|
+
logger.debug(f"Returning {len(merged_data)} models (LLMs + Blueprints).")
|
128
|
+
# Return the merged list in the expected OpenAI-like format
|
129
|
+
return JsonResponse({"object": "list", "data": merged_data}, status=200)
|
130
|
+
|
131
|
+
except Exception as e:
|
132
|
+
# Catch-all for unexpected errors during the process
|
133
|
+
logger.error(f"Error listing models: {e}", exc_info=True)
|
134
|
+
return JsonResponse({"error": "Internal Server Error while listing models."}, status=500)
|
135
|
+
|
swarm/views/utils.py
ADDED
@@ -0,0 +1,457 @@
|
|
1
|
+
"""
|
2
|
+
Utility functions for Swarm views.
|
3
|
+
"""
|
4
|
+
import json
|
5
|
+
from swarm.types import ChatMessage
|
6
|
+
import uuid
|
7
|
+
import time
|
8
|
+
import os
|
9
|
+
import redis
|
10
|
+
import logging
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
12
|
+
from pathlib import Path
|
13
|
+
|
14
|
+
from django.conf import settings
|
15
|
+
from rest_framework.response import Response
|
16
|
+
from rest_framework import status
|
17
|
+
|
18
|
+
# Project-specific imports
|
19
|
+
from swarm.models import ChatConversation, ChatMessage
|
20
|
+
from swarm.extensions.blueprint import discover_blueprints
|
21
|
+
from swarm.extensions.blueprint.blueprint_base import BlueprintBase
|
22
|
+
from swarm.extensions.config.config_loader import load_server_config, load_llm_config
|
23
|
+
from swarm.utils.logger_setup import setup_logger
|
24
|
+
from swarm.utils.redact import redact_sensitive_data
|
25
|
+
from swarm.utils.general_utils import extract_chat_id
|
26
|
+
from swarm.extensions.blueprint.blueprint_utils import filter_blueprints
|
27
|
+
from swarm.settings import BASE_DIR, BLUEPRINTS_DIR # Import BLUEPRINTS_DIR
|
28
|
+
|
29
|
+
logger = setup_logger(__name__)
|
30
|
+
|
31
|
+
# --- Configuration Loading ---
|
32
|
+
CONFIG_PATH = Path(settings.BASE_DIR) / 'swarm_config.json'
|
33
|
+
config = {}
|
34
|
+
llm_config = {}
|
35
|
+
llm_model = "default"
|
36
|
+
llm_provider = "unknown"
|
37
|
+
|
38
|
+
def load_configs():
|
39
|
+
"""Load main and LLM configurations."""
|
40
|
+
global config, llm_config, llm_model, llm_provider
|
41
|
+
try:
|
42
|
+
config = load_server_config(str(CONFIG_PATH))
|
43
|
+
logger.info(f"Server config loaded from {CONFIG_PATH}")
|
44
|
+
except FileNotFoundError:
|
45
|
+
logger.warning(f"Configuration file not found at {CONFIG_PATH}. Using defaults.")
|
46
|
+
config = {} # Use empty dict or default structure if needed
|
47
|
+
except ValueError as e:
|
48
|
+
logger.error(f"Error loading server config: {e}. Using defaults.")
|
49
|
+
config = {}
|
50
|
+
except Exception as e:
|
51
|
+
logger.critical(f"Unexpected error loading server config: {e}", exc_info=True)
|
52
|
+
config = {} # Critical error, use empty config
|
53
|
+
|
54
|
+
try:
|
55
|
+
llm_config = load_llm_config(config) # Load default LLM config
|
56
|
+
llm_model = llm_config.get("model", "default")
|
57
|
+
llm_provider = llm_config.get("provider", "unknown")
|
58
|
+
logger.info(f"Default LLM config loaded: Provider={llm_provider}, Model={llm_model}")
|
59
|
+
except ValueError as e:
|
60
|
+
logger.error(f"Failed to load default LLM configuration: {e}. LLM features may fail.")
|
61
|
+
llm_config = {} # Ensure llm_config is a dict even on error
|
62
|
+
llm_model = "error"
|
63
|
+
llm_provider = "error"
|
64
|
+
except Exception as e:
|
65
|
+
logger.critical(f"Unexpected error loading LLM config: {e}", exc_info=True)
|
66
|
+
llm_config = {}
|
67
|
+
llm_model = "error"
|
68
|
+
llm_provider = "error"
|
69
|
+
|
70
|
+
load_configs() # Load configs when module is imported
|
71
|
+
|
72
|
+
# --- Blueprint Discovery ---
|
73
|
+
blueprints_metadata = {}
|
74
|
+
def discover_and_load_blueprints():
|
75
|
+
"""Discover blueprints from the configured directory."""
|
76
|
+
global blueprints_metadata
|
77
|
+
try:
|
78
|
+
# Ensure BLUEPRINTS_DIR is a Path object
|
79
|
+
bp_dir_path = Path(BLUEPRINTS_DIR) if isinstance(BLUEPRINTS_DIR, str) else BLUEPRINTS_DIR
|
80
|
+
if not bp_dir_path.is_absolute():
|
81
|
+
bp_dir_path = Path(settings.BASE_DIR).parent / bp_dir_path # Assuming relative to project root
|
82
|
+
logger.info(f"Discovering blueprints in: {bp_dir_path}")
|
83
|
+
discovered = discover_blueprints(directories=[str(bp_dir_path)])
|
84
|
+
blueprints_metadata = discovered
|
85
|
+
loaded_names = list(blueprints_metadata.keys())
|
86
|
+
logger.info(f"Discovered blueprints: {loaded_names if loaded_names else 'None'}")
|
87
|
+
except FileNotFoundError:
|
88
|
+
logger.warning(f"Blueprints directory '{BLUEPRINTS_DIR}' not found. No blueprints discovered dynamically.")
|
89
|
+
blueprints_metadata = {}
|
90
|
+
except Exception as e:
|
91
|
+
logger.error(f"Failed during blueprint discovery: {e}", exc_info=True)
|
92
|
+
blueprints_metadata = {}
|
93
|
+
|
94
|
+
discover_and_load_blueprints() # Discover blueprints when module is imported
|
95
|
+
|
96
|
+
# --- Redis Client Initialization ---
|
97
|
+
REDIS_AVAILABLE = bool(os.getenv("STATEFUL_CHAT_ID_PATH")) and hasattr(settings, 'REDIS_HOST') and hasattr(settings, 'REDIS_PORT')
|
98
|
+
redis_client = None
|
99
|
+
if REDIS_AVAILABLE:
|
100
|
+
try:
|
101
|
+
redis_client = redis.Redis(
|
102
|
+
host=settings.REDIS_HOST,
|
103
|
+
port=settings.REDIS_PORT,
|
104
|
+
decode_responses=True
|
105
|
+
)
|
106
|
+
redis_client.ping()
|
107
|
+
logger.info(f"Redis connection successful ({settings.REDIS_HOST}:{settings.REDIS_PORT}).")
|
108
|
+
except Exception as e:
|
109
|
+
logger.warning(f"Redis connection failed: {e}. Stateful chat history via Redis is disabled.")
|
110
|
+
REDIS_AVAILABLE = False
|
111
|
+
else:
|
112
|
+
logger.info("Redis configuration not found or STATEFUL_CHAT_ID_PATH not set. Stateful chat history via Redis is disabled.")
|
113
|
+
|
114
|
+
# --- Helper Functions ---
|
115
|
+
|
116
|
+
def serialize_swarm_response(response: Any, model_name: str, context_variables: Dict[str, Any]) -> Dict[str, Any]:
|
117
|
+
"""Serialize a blueprint response into an OpenAI-compatible chat completion format."""
|
118
|
+
logger.debug(f"Serializing Swarm response, type: {type(response)}, model: {model_name}")
|
119
|
+
|
120
|
+
# Determine messages from response
|
121
|
+
if hasattr(response, 'messages') and isinstance(response.messages, list):
|
122
|
+
messages = response.messages
|
123
|
+
elif isinstance(response, dict) and isinstance(response.get("messages"), list):
|
124
|
+
messages = response.get("messages", [])
|
125
|
+
elif isinstance(response, str):
|
126
|
+
logger.warning(f"Received raw string response, wrapping as assistant message: {response[:100]}...")
|
127
|
+
messages = [{"role": "assistant", "content": response}]
|
128
|
+
else:
|
129
|
+
logger.error(f"Unexpected response type for serialization: {type(response)}. Returning empty response.")
|
130
|
+
messages = []
|
131
|
+
|
132
|
+
# Create choices array based on assistant messages with content
|
133
|
+
choices = []
|
134
|
+
for i, msg in enumerate(messages):
|
135
|
+
if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("content") is not None:
|
136
|
+
choice = {
|
137
|
+
"index": len(choices),
|
138
|
+
"message": {
|
139
|
+
"role": "assistant",
|
140
|
+
"content": msg["content"]
|
141
|
+
},
|
142
|
+
"finish_reason": "stop" # Assume stop for non-streaming
|
143
|
+
}
|
144
|
+
# Include tool_calls if present in the original message
|
145
|
+
if msg.get("tool_calls") is not None:
|
146
|
+
choice["message"]["tool_calls"] = msg["tool_calls"]
|
147
|
+
choice["finish_reason"] = "tool_calls" # Adjust finish reason if tools are called
|
148
|
+
|
149
|
+
choices.append(choice)
|
150
|
+
logger.debug(f"Added choice {len(choices)-1}: role={choice['message']['role']}, finish={choice['finish_reason']}")
|
151
|
+
|
152
|
+
if not choices and messages:
|
153
|
+
# Fallback if no assistant message with content, maybe use last message?
|
154
|
+
logger.warning("No assistant messages with content found for 'choices'. Using last message if possible.")
|
155
|
+
# This part might need refinement based on expected behavior for tool-only responses
|
156
|
+
|
157
|
+
# Estimate token usage (basic approximation)
|
158
|
+
prompt_tokens = 0
|
159
|
+
completion_tokens = 0
|
160
|
+
total_tokens = 0
|
161
|
+
# Need access to the *input* messages for prompt_tokens, which aren't passed here.
|
162
|
+
# This usage calculation will be inaccurate without the original prompt.
|
163
|
+
for msg in messages: # Calculating based on response messages only
|
164
|
+
if isinstance(msg, dict):
|
165
|
+
content_tokens = len(str(msg.get("content", "")).split())
|
166
|
+
if msg.get("role") == "assistant":
|
167
|
+
completion_tokens += content_tokens
|
168
|
+
total_tokens += content_tokens
|
169
|
+
if msg.get("tool_calls"): # Add rough estimate for tool call overhead
|
170
|
+
total_tokens += len(json.dumps(msg["tool_calls"])) // 4
|
171
|
+
|
172
|
+
logger.warning("Token usage calculation is approximate and based only on response messages.")
|
173
|
+
|
174
|
+
# Basic serialization structure
|
175
|
+
serialized_response = {
|
176
|
+
"id": f"swarm-chat-{uuid.uuid4()}",
|
177
|
+
"object": "chat.completion",
|
178
|
+
"created": int(time.time()),
|
179
|
+
"model": model_name,
|
180
|
+
"choices": choices,
|
181
|
+
"usage": {
|
182
|
+
"prompt_tokens": prompt_tokens, # Inaccurate without input messages
|
183
|
+
"completion_tokens": completion_tokens,
|
184
|
+
"total_tokens": total_tokens # Inaccurate
|
185
|
+
},
|
186
|
+
# Optionally include context and full response for debugging/state
|
187
|
+
# "context_variables": context_variables,
|
188
|
+
# "full_response": response # Might contain non-serializable objects
|
189
|
+
}
|
190
|
+
|
191
|
+
logger.debug(f"Serialized response: id={serialized_response['id']}, choices={len(choices)}")
|
192
|
+
return serialized_response
|
193
|
+
|
194
|
+
def parse_chat_request(request: Any) -> Any:
|
195
|
+
"""Parse incoming chat completion request body into components."""
|
196
|
+
try:
|
197
|
+
body = json.loads(request.body)
|
198
|
+
model = body.get("model", "default") # Default to 'default' if model not specified
|
199
|
+
messages = body.get("messages", [])
|
200
|
+
|
201
|
+
# Basic validation
|
202
|
+
if not isinstance(messages, list) or not messages:
|
203
|
+
# Try extracting single message if 'messages' is invalid/empty
|
204
|
+
if "message" in body:
|
205
|
+
single_msg = body["message"]
|
206
|
+
if isinstance(single_msg, str): messages = [{"role": "user", "content": single_msg}]
|
207
|
+
elif isinstance(single_msg, dict) and "content" in single_msg:
|
208
|
+
if "role" not in single_msg: single_msg["role"] = "user"
|
209
|
+
messages = [single_msg]
|
210
|
+
else:
|
211
|
+
return Response({"error": "'message' field is invalid."}, status=status.HTTP_400_BAD_REQUEST)
|
212
|
+
else:
|
213
|
+
return Response({"error": "'messages' field is required and must be a non-empty list."}, status=status.HTTP_400_BAD_REQUEST)
|
214
|
+
|
215
|
+
# Ensure all messages have a role (default to user if missing)
|
216
|
+
for msg in messages:
|
217
|
+
if not isinstance(msg, dict) or "content" not in msg:
|
218
|
+
# Allow tool calls without content
|
219
|
+
if not (isinstance(msg, dict) and msg.get("role") == "tool" and msg.get("tool_call_id")):
|
220
|
+
logger.error(f"Invalid message format found: {msg}")
|
221
|
+
return Response({"error": f"Invalid message format: {msg}"}, status=status.HTTP_400_BAD_REQUEST)
|
222
|
+
if "role" not in msg:
|
223
|
+
msg["role"] = "user"
|
224
|
+
|
225
|
+
context_variables = body.get("context_variables", {})
|
226
|
+
if not isinstance(context_variables, dict):
|
227
|
+
logger.warning("Invalid 'context_variables' format, using empty dict.")
|
228
|
+
context_variables = {}
|
229
|
+
|
230
|
+
conversation_id = extract_chat_id(body) or str(uuid.uuid4()) # Generate if not found/extracted
|
231
|
+
|
232
|
+
# Extract last tool_call_id for potential context filtering (optional)
|
233
|
+
tool_call_id = None
|
234
|
+
if messages and isinstance(messages[-1], dict) and messages[-1].get("role") == "tool":
|
235
|
+
tool_call_id = messages[-1].get("tool_call_id")
|
236
|
+
|
237
|
+
logger.debug(f"Parsed request: model={model}, messages_count={len(messages)}, context_keys={list(context_variables.keys())}, conv_id={conversation_id}, tool_id={tool_call_id}")
|
238
|
+
return (body, model, messages, context_variables, conversation_id, tool_call_id)
|
239
|
+
|
240
|
+
except json.JSONDecodeError:
|
241
|
+
logger.error("Invalid JSON payload received.")
|
242
|
+
return Response({"error": "Invalid JSON payload."}, status=status.HTTP_400_BAD_REQUEST)
|
243
|
+
except Exception as e:
|
244
|
+
logger.error(f"Error parsing request: {e}", exc_info=True)
|
245
|
+
return Response({"error": "Failed to parse request."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
246
|
+
|
247
|
+
|
248
|
+
def get_blueprint_instance(model: str, context_vars: dict) -> Any:
|
249
|
+
"""Instantiate a blueprint instance based on the requested model."""
|
250
|
+
logger.debug(f"Attempting to get blueprint instance for model: {model}")
|
251
|
+
# Reload configs and discover blueprints on each request? Or rely on initial load?
|
252
|
+
# Let's rely on initial load for now, assuming blueprints don't change often.
|
253
|
+
# discover_and_load_blueprints() # Uncomment if dynamic reload is needed
|
254
|
+
|
255
|
+
blueprint_meta = blueprints_metadata.get(model)
|
256
|
+
if not blueprint_meta:
|
257
|
+
# Check if it's an LLM passthrough defined in config
|
258
|
+
llm_profile = config.get("llm", {}).get(model)
|
259
|
+
if llm_profile and llm_profile.get("passthrough"):
|
260
|
+
logger.warning(f"Model '{model}' is an LLM passthrough, not a blueprint. Returning None.")
|
261
|
+
# This scenario should ideally be handled before calling get_blueprint_instance
|
262
|
+
# Returning None might cause issues downstream. Consider raising an error
|
263
|
+
# or having the caller handle LLM passthrough separately.
|
264
|
+
# For now, returning None as a signal.
|
265
|
+
return None # Signal it's not a blueprint
|
266
|
+
else:
|
267
|
+
logger.error(f"Model '{model}' not found in discovered blueprints or LLM config.")
|
268
|
+
return Response({"error": f"Model '{model}' not found."}, status=status.HTTP_404_NOT_FOUND)
|
269
|
+
|
270
|
+
blueprint_class = blueprint_meta.get("blueprint_class")
|
271
|
+
if not blueprint_class or not issubclass(blueprint_class, BlueprintBase):
|
272
|
+
logger.error(f"Blueprint class for model '{model}' is invalid or not found.")
|
273
|
+
return Response({"error": f"Blueprint class for model '{model}' is invalid."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
274
|
+
|
275
|
+
try:
|
276
|
+
# Pass the initially loaded global 'config' to the blueprint instance
|
277
|
+
blueprint_instance = blueprint_class(config=config, debug=settings.DEBUG)
|
278
|
+
logger.info(f"Successfully instantiated blueprint: {model}")
|
279
|
+
# Optionally set active agent based on context, if blueprint supports it
|
280
|
+
# active_agent_name = context_vars.get("active_agent_name")
|
281
|
+
# if active_agent_name and hasattr(blueprint_instance, 'set_active_agent'):
|
282
|
+
# try:
|
283
|
+
# blueprint_instance.set_active_agent(active_agent_name)
|
284
|
+
# except ValueError as e:
|
285
|
+
# logger.warning(f"Could not set active agent '{active_agent_name}': {e}")
|
286
|
+
return blueprint_instance
|
287
|
+
except Exception as e:
|
288
|
+
logger.error(f"Error initializing blueprint '{model}': {e}", exc_info=True)
|
289
|
+
return Response({"error": f"Error initializing blueprint: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
290
|
+
|
291
|
+
def load_conversation_history(conversation_id: Optional[str], current_messages: List[dict], tool_call_id: Optional[str] = None) -> List[dict]:
|
292
|
+
"""Load past messages for a conversation from Redis or database, combined with current."""
|
293
|
+
if not conversation_id:
|
294
|
+
logger.debug("No conversation_id provided, returning only current messages.")
|
295
|
+
return current_messages # Return only the messages from the current request
|
296
|
+
|
297
|
+
past_messages = []
|
298
|
+
# Try Redis first if available
|
299
|
+
if REDIS_AVAILABLE and redis_client:
|
300
|
+
try:
|
301
|
+
history_raw = redis_client.lrange(conversation_id, 0, -1) # Get all items in list
|
302
|
+
if history_raw:
|
303
|
+
# Redis stores list items as strings, parse each JSON string
|
304
|
+
past_messages = [json.loads(msg_str) for msg_str in history_raw]
|
305
|
+
logger.debug(f"Retrieved {len(past_messages)} messages from Redis list for conversation: {conversation_id}")
|
306
|
+
except redis.exceptions.RedisError as e:
|
307
|
+
logger.error(f"Redis error retrieving history for {conversation_id}: {e}", exc_info=True)
|
308
|
+
except json.JSONDecodeError as e:
|
309
|
+
logger.error(f"Error decoding JSON from Redis for {conversation_id}: {e}. History may be corrupted.")
|
310
|
+
# Potentially clear corrupted Redis key?
|
311
|
+
# redis_client.delete(conversation_id)
|
312
|
+
except Exception as e:
|
313
|
+
logger.error(f"Unexpected error retrieving from Redis for {conversation_id}: {e}", exc_info=True)
|
314
|
+
|
315
|
+
# Fallback to Database if Redis fails or history is empty
|
316
|
+
if not past_messages:
|
317
|
+
try:
|
318
|
+
conversation = ChatConversation.objects.filter(conversation_id=conversation_id).first()
|
319
|
+
if conversation:
|
320
|
+
# Query messages related to the conversation
|
321
|
+
query = conversation.messages.all().order_by("timestamp")
|
322
|
+
# Convert DB messages to the expected dict format
|
323
|
+
past_messages = [
|
324
|
+
{"role": msg.sender, "content": msg.content, "tool_calls": json.loads(msg.tool_calls) if msg.tool_calls else None}
|
325
|
+
for msg in query
|
326
|
+
]
|
327
|
+
logger.debug(f"Retrieved {len(past_messages)} messages from DB for conversation: {conversation_id}")
|
328
|
+
else:
|
329
|
+
logger.debug(f"No existing conversation found in DB for ID: {conversation_id}")
|
330
|
+
past_messages = [] # Ensure it's an empty list if no conversation found
|
331
|
+
except Exception as e:
|
332
|
+
logger.error(f"Error retrieving conversation history from DB for {conversation_id}: {e}", exc_info=True)
|
333
|
+
past_messages = [] # Ensure empty list on error
|
334
|
+
|
335
|
+
# Combine history with current request messages
|
336
|
+
# Ensure roles are correct ('user' for human, 'assistant'/'tool' for AI/tools)
|
337
|
+
# Filter out potential duplicates if necessary (e.g., if client resends last user message)
|
338
|
+
combined_messages = past_messages + current_messages
|
339
|
+
logger.debug(f"Combined history: {len(past_messages)} past + {len(current_messages)} current = {len(combined_messages)} total messages for {conversation_id}")
|
340
|
+
return combined_messages
|
341
|
+
|
342
|
+
|
343
|
+
def store_conversation_history(conversation_id: str, full_history: List[dict], response_obj: Optional[Any] = None):
|
344
|
+
"""Store conversation history (including latest response) in DB and/or Redis."""
|
345
|
+
if not conversation_id:
|
346
|
+
logger.error("Cannot store history: conversation_id is missing.")
|
347
|
+
return False
|
348
|
+
|
349
|
+
# Ensure response messages are included in the history to be stored
|
350
|
+
history_to_store = list(full_history) # Make a copy
|
351
|
+
if response_obj:
|
352
|
+
response_messages = []
|
353
|
+
if hasattr(response_obj, 'messages') and isinstance(response_obj.messages, list):
|
354
|
+
response_messages = response_obj.messages
|
355
|
+
elif isinstance(response_obj, dict) and isinstance(response_obj.get('messages'), list):
|
356
|
+
response_messages = response_obj.get('messages', [])
|
357
|
+
|
358
|
+
# Add only messages not already in full_history (prevent duplicates if run_conversation includes input)
|
359
|
+
last_stored_content = json.dumps(history_to_store[-1]) if history_to_store else None
|
360
|
+
for msg in response_messages:
|
361
|
+
msg_dict = msg.model_dump(exclude_none=True) if isinstance(msg, ChatMessage) else msg
|
362
|
+
if json.dumps(msg_dict) != last_stored_content:
|
363
|
+
history_to_store.append(msg)
|
364
|
+
|
365
|
+
|
366
|
+
# --- Store in Database ---
|
367
|
+
try:
|
368
|
+
# Use update_or_create for conversation to handle creation/retrieval atomically
|
369
|
+
conversation, created = ChatConversation.objects.update_or_create(
|
370
|
+
conversation_id=conversation_id,
|
371
|
+
defaults={'user': None} # Add user association if available (e.g., from request.user)
|
372
|
+
)
|
373
|
+
if created:
|
374
|
+
logger.debug(f"Created new ChatConversation in DB: {conversation_id}")
|
375
|
+
|
376
|
+
# Efficiently store only the messages *added* in this turn (response messages)
|
377
|
+
# Assume `full_history` contains the prompt messages, and `response_messages` contains the response
|
378
|
+
messages_to_add_to_db = []
|
379
|
+
if response_obj:
|
380
|
+
response_msgs_from_obj = getattr(response_obj, 'messages', []) if hasattr(response_obj, 'messages') else response_obj.get('messages', []) if isinstance(response_obj, dict) else []
|
381
|
+
for msg in response_msgs_from_obj:
|
382
|
+
if isinstance(msg, dict):
|
383
|
+
# Basic validation
|
384
|
+
role = msg.get("role")
|
385
|
+
content = msg.get("content")
|
386
|
+
tool_calls = msg.get("tool_calls")
|
387
|
+
if role and (content is not None or tool_calls is not None):
|
388
|
+
messages_to_add_to_db.append(ChatMessage(
|
389
|
+
conversation=conversation,
|
390
|
+
sender=role,
|
391
|
+
content=content,
|
392
|
+
# Store tool_calls as JSON string
|
393
|
+
tool_calls=json.dumps(tool_calls) if tool_calls else None
|
394
|
+
))
|
395
|
+
|
396
|
+
if messages_to_add_to_db:
|
397
|
+
ChatMessage.objects.bulk_create(messages_to_add_to_db)
|
398
|
+
logger.debug(f"Stored {len(messages_to_add_to_db)} new messages in DB for conversation {conversation_id}")
|
399
|
+
else:
|
400
|
+
logger.debug(f"No new response messages to store in DB for conversation {conversation_id}")
|
401
|
+
|
402
|
+
except Exception as e:
|
403
|
+
logger.error(f"Error storing conversation history to DB for {conversation_id}: {e}", exc_info=True)
|
404
|
+
# Continue to Redis even if DB fails? Or return False? Let's continue for now.
|
405
|
+
|
406
|
+
# --- Store full history in Redis List ---
|
407
|
+
if REDIS_AVAILABLE and redis_client:
|
408
|
+
try:
|
409
|
+
# Use a Redis list (LPUSH/LTRIM or RPUSH/LTRIM for potentially capped history)
|
410
|
+
# For simplicity, let's replace the entire history for now.
|
411
|
+
# Delete existing list and push all new items. Use pipeline for atomicity.
|
412
|
+
pipe = redis_client.pipeline()
|
413
|
+
pipe.delete(conversation_id)
|
414
|
+
# Push each message as a separate JSON string onto the list
|
415
|
+
for msg_dict in history_to_store:
|
416
|
+
pipe.rpush(conversation_id, json.dumps(msg_dict))
|
417
|
+
# Optionally cap the list size
|
418
|
+
# max_redis_history = 100 # Example cap
|
419
|
+
# pipe.ltrim(conversation_id, -max_redis_history, -1)
|
420
|
+
pipe.execute()
|
421
|
+
logger.debug(f"Stored {len(history_to_store)} messages in Redis list for conversation {conversation_id}")
|
422
|
+
except redis.exceptions.RedisError as e:
|
423
|
+
logger.error(f"Redis error storing history list for {conversation_id}: {e}", exc_info=True)
|
424
|
+
return False # Indicate failure if Redis write fails
|
425
|
+
except Exception as e:
|
426
|
+
logger.error(f"Unexpected error storing to Redis for {conversation_id}: {e}", exc_info=True)
|
427
|
+
return False
|
428
|
+
|
429
|
+
return True
|
430
|
+
|
431
|
+
|
432
|
+
async def run_conversation(blueprint_instance: Any, messages_extended: List[dict], context_vars: dict) -> Tuple[Any, dict]:
|
433
|
+
"""Run a conversation turn with a blueprint instance asynchronously."""
|
434
|
+
if not isinstance(blueprint_instance, BlueprintBase):
|
435
|
+
# Handle LLM passthrough case if needed, or raise error
|
436
|
+
# For now, assume it must be a BlueprintBase instance
|
437
|
+
logger.error("run_conversation called with non-blueprint instance.")
|
438
|
+
raise TypeError("run_conversation expects a BlueprintBase instance.")
|
439
|
+
|
440
|
+
try:
|
441
|
+
# Directly await the async method
|
442
|
+
result_dict = await blueprint_instance.run_with_context_async(messages_extended, context_vars)
|
443
|
+
|
444
|
+
response_obj = result_dict.get("response")
|
445
|
+
updated_context = result_dict.get("context_variables", context_vars) # Fallback to original context
|
446
|
+
|
447
|
+
if response_obj is None:
|
448
|
+
logger.error("Blueprint run returned None in response object.")
|
449
|
+
# Create a default error response
|
450
|
+
response_obj = {"messages": [{"role": "assistant", "content": "Error: Blueprint failed to return a response."}]}
|
451
|
+
|
452
|
+
return response_obj, updated_context
|
453
|
+
except Exception as e:
|
454
|
+
logger.error(f"Exception during blueprint run: {e}", exc_info=True)
|
455
|
+
# Return an error structure
|
456
|
+
error_response = {"messages": [{"role": "assistant", "content": f"Error processing request: {e}"}]}
|
457
|
+
return error_response, context_vars # Return original context on error
|