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.
Files changed (89) hide show
  1. open_swarm-0.1.1743070217.dist-info/METADATA +258 -0
  2. open_swarm-0.1.1743070217.dist-info/RECORD +89 -0
  3. open_swarm-0.1.1743070217.dist-info/WHEEL +5 -0
  4. open_swarm-0.1.1743070217.dist-info/entry_points.txt +3 -0
  5. open_swarm-0.1.1743070217.dist-info/licenses/LICENSE +21 -0
  6. open_swarm-0.1.1743070217.dist-info/top_level.txt +1 -0
  7. swarm/__init__.py +3 -0
  8. swarm/agent/__init__.py +7 -0
  9. swarm/agent/agent.py +49 -0
  10. swarm/apps.py +53 -0
  11. swarm/auth.py +56 -0
  12. swarm/consumers.py +141 -0
  13. swarm/core.py +326 -0
  14. swarm/extensions/__init__.py +1 -0
  15. swarm/extensions/blueprint/__init__.py +36 -0
  16. swarm/extensions/blueprint/agent_utils.py +45 -0
  17. swarm/extensions/blueprint/blueprint_base.py +562 -0
  18. swarm/extensions/blueprint/blueprint_discovery.py +112 -0
  19. swarm/extensions/blueprint/blueprint_utils.py +17 -0
  20. swarm/extensions/blueprint/common_utils.py +12 -0
  21. swarm/extensions/blueprint/django_utils.py +203 -0
  22. swarm/extensions/blueprint/interactive_mode.py +102 -0
  23. swarm/extensions/blueprint/modes/rest_mode.py +37 -0
  24. swarm/extensions/blueprint/output_utils.py +95 -0
  25. swarm/extensions/blueprint/spinner.py +91 -0
  26. swarm/extensions/cli/__init__.py +0 -0
  27. swarm/extensions/cli/blueprint_runner.py +251 -0
  28. swarm/extensions/cli/cli_args.py +88 -0
  29. swarm/extensions/cli/commands/__init__.py +0 -0
  30. swarm/extensions/cli/commands/blueprint_management.py +31 -0
  31. swarm/extensions/cli/commands/config_management.py +15 -0
  32. swarm/extensions/cli/commands/edit_config.py +77 -0
  33. swarm/extensions/cli/commands/list_blueprints.py +22 -0
  34. swarm/extensions/cli/commands/validate_env.py +57 -0
  35. swarm/extensions/cli/commands/validate_envvars.py +39 -0
  36. swarm/extensions/cli/interactive_shell.py +41 -0
  37. swarm/extensions/cli/main.py +36 -0
  38. swarm/extensions/cli/selection.py +43 -0
  39. swarm/extensions/cli/utils/discover_commands.py +32 -0
  40. swarm/extensions/cli/utils/env_setup.py +15 -0
  41. swarm/extensions/cli/utils.py +105 -0
  42. swarm/extensions/config/__init__.py +6 -0
  43. swarm/extensions/config/config_loader.py +208 -0
  44. swarm/extensions/config/config_manager.py +258 -0
  45. swarm/extensions/config/server_config.py +49 -0
  46. swarm/extensions/config/setup_wizard.py +103 -0
  47. swarm/extensions/config/utils/__init__.py +0 -0
  48. swarm/extensions/config/utils/logger.py +36 -0
  49. swarm/extensions/launchers/__init__.py +1 -0
  50. swarm/extensions/launchers/build_launchers.py +14 -0
  51. swarm/extensions/launchers/build_swarm_wrapper.py +12 -0
  52. swarm/extensions/launchers/swarm_api.py +68 -0
  53. swarm/extensions/launchers/swarm_cli.py +304 -0
  54. swarm/extensions/launchers/swarm_wrapper.py +29 -0
  55. swarm/extensions/mcp/__init__.py +1 -0
  56. swarm/extensions/mcp/cache_utils.py +36 -0
  57. swarm/extensions/mcp/mcp_client.py +341 -0
  58. swarm/extensions/mcp/mcp_constants.py +7 -0
  59. swarm/extensions/mcp/mcp_tool_provider.py +110 -0
  60. swarm/llm/chat_completion.py +195 -0
  61. swarm/messages.py +132 -0
  62. swarm/migrations/0010_initial_chat_models.py +51 -0
  63. swarm/migrations/__init__.py +0 -0
  64. swarm/models.py +45 -0
  65. swarm/repl/__init__.py +1 -0
  66. swarm/repl/repl.py +87 -0
  67. swarm/serializers.py +12 -0
  68. swarm/settings.py +189 -0
  69. swarm/tool_executor.py +239 -0
  70. swarm/types.py +126 -0
  71. swarm/urls.py +89 -0
  72. swarm/util.py +124 -0
  73. swarm/utils/color_utils.py +40 -0
  74. swarm/utils/context_utils.py +272 -0
  75. swarm/utils/general_utils.py +162 -0
  76. swarm/utils/logger.py +61 -0
  77. swarm/utils/logger_setup.py +25 -0
  78. swarm/utils/message_sequence.py +173 -0
  79. swarm/utils/message_utils.py +95 -0
  80. swarm/utils/redact.py +68 -0
  81. swarm/views/__init__.py +41 -0
  82. swarm/views/api_views.py +46 -0
  83. swarm/views/chat_views.py +76 -0
  84. swarm/views/core_views.py +118 -0
  85. swarm/views/message_views.py +40 -0
  86. swarm/views/model_views.py +135 -0
  87. swarm/views/utils.py +457 -0
  88. swarm/views/web_views.py +149 -0
  89. 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