Skip to content

Commit 39191d6

Browse files
committed
Fix high severity issues across monitoring, agents, memory, and MoE modules
1 parent 4c2bc0b commit 39191d6

49 files changed

Lines changed: 1294 additions & 406 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

multimind/agents/agent.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Base Agent class for Multimind SDK.
33
"""
44

5+
import re
56
from typing import List, Dict, Any, Optional
67
from multimind.models.base import BaseLLM
78
from multimind.agents.memory import AgentMemory
@@ -38,8 +39,11 @@ async def run(self, task: str, **kwargs) -> Dict[str, Any]:
3839
async def _process_task(self, task: str, **kwargs) -> Dict[str, Any]:
3940
"""Process a task using available tools and the model."""
4041
# Try to match a tool by name
42+
task_lower = task.lower()
4143
for tool in self.tools:
42-
if tool.name.lower() in task.lower():
44+
tool_name = tool.name.lower().strip()
45+
# Match on whole tokens only (e.g., `calc` won't match `calculate`).
46+
if re.search(rf"\b{re.escape(tool_name)}\b", task_lower):
4347
try:
4448
# Extract parameters for the tool from kwargs
4549
params = {k: v for k, v in kwargs.items() if k in tool.get_parameters().get("required", [])}

multimind/agents/agent_loader.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,22 @@ def load_agent(
3333
) -> Agent:
3434
"""Load an agent from a configuration file."""
3535
# Load config
36-
with open(config_path, 'r') as f:
37-
config = json.load(f)
36+
try:
37+
with open(config_path, "r", encoding="utf-8") as f:
38+
config = json.load(f)
39+
except FileNotFoundError as e:
40+
raise FileNotFoundError(f"Agent config file not found: {config_path}") from e
41+
except json.JSONDecodeError as e:
42+
raise ValueError(
43+
f"Invalid JSON in agent config file: {config_path}. {e}"
44+
) from e
45+
except OSError as e:
46+
raise RuntimeError(
47+
f"Failed to read agent config file: {config_path}. {e}"
48+
) from e
49+
50+
if not isinstance(config, dict):
51+
raise ValueError(f"Agent config must be a JSON object: {config_path}")
3852

3953
# Validate config
4054
required_keys = {"model", "system_prompt"}

multimind/agents/agent_registry.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,39 @@ def set_fallback(self, name: str, fallback_name: str):
2222
def get_agent(self, name: str) -> Optional[Callable]:
2323
return self.agents.get(name)
2424

25-
def run_agent(self, name: str, *args, session_id: Optional[str] = None, **kwargs):
25+
def run_agent(
26+
self,
27+
name: str,
28+
*args,
29+
session_id: Optional[str] = None,
30+
_visited: Optional[set] = None,
31+
_depth: int = 0,
32+
_max_depth: int = 10,
33+
**kwargs,
34+
):
35+
"""
36+
Run an agent by name with fallback support.
37+
38+
Cycle-protected: if fallbacks point back to an already tried agent (A->B->A),
39+
we stop to avoid infinite recursion.
40+
"""
41+
if _visited is None:
42+
_visited = set()
43+
44+
if name in _visited:
45+
self.logger.error(
46+
f"Fallback recursion detected for agent '{name}'. Aborting. Visited={_visited}"
47+
)
48+
return None
49+
50+
if _depth >= _max_depth:
51+
self.logger.error(
52+
f"Max fallback depth reached while running agent '{name}'. Aborting."
53+
)
54+
return None
55+
56+
_visited.add(name)
57+
2658
agent = self.get_agent(name)
2759
if not agent:
2860
self.logger.warning(f"Agent {name} not found.")
@@ -33,14 +65,29 @@ def run_agent(self, name: str, *args, session_id: Optional[str] = None, **kwargs
3365
result = agent(*args, state=state, **kwargs)
3466
# Optionally update state
3567
if session_id is not None:
36-
self.state_memory[session_id] = result.get("state", state) if isinstance(result, dict) else state
68+
self.state_memory[session_id] = (
69+
result.get("state", state) if isinstance(result, dict) else state
70+
)
3771
return result
3872
except Exception as e:
3973
self.logger.error(f"Agent {name} failed: {e}")
4074
fallback = self.fallbacks.get(name)
4175
if fallback:
76+
if fallback in _visited:
77+
self.logger.error(
78+
f"Fallback cycle detected: '{name}' -> '{fallback}'. Aborting."
79+
)
80+
return None
4281
self.logger.info(f"Retrying with fallback agent: {fallback}")
43-
return self.run_agent(fallback, *args, session_id=session_id, **kwargs)
82+
return self.run_agent(
83+
fallback,
84+
*args,
85+
session_id=session_id,
86+
_visited=_visited,
87+
_depth=_depth + 1,
88+
_max_depth=_max_depth,
89+
**kwargs,
90+
)
4491
return None
4592

4693
def get_state(self, session_id: str):

multimind/agents/memory.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,29 @@ class AgentMemory:
1111
def __init__(self, max_history: int = 100):
1212
self.max_history = max_history
1313
self.tasks: List[str] = []
14+
self.task_timestamps: List[datetime] = []
1415
self.responses: List[Dict[str, Any]] = []
16+
self.response_timestamps: List[datetime] = []
1517
self.state: Dict[str, Any] = {}
1618
self.created_at = datetime.now()
1719

1820
def add_task(self, task: str) -> None:
1921
"""Add a task to memory."""
2022
self.tasks.append(task)
23+
self.task_timestamps.append(datetime.now())
2124
if len(self.tasks) > self.max_history:
2225
self.tasks.pop(0)
26+
if self.task_timestamps:
27+
self.task_timestamps.pop(0)
2328

2429
def add_response(self, response: Dict[str, Any]) -> None:
2530
"""Add a response to memory."""
2631
self.responses.append(response)
32+
self.response_timestamps.append(datetime.now())
2733
if len(self.responses) > self.max_history:
2834
self.responses.pop(0)
35+
if self.response_timestamps:
36+
self.response_timestamps.pop(0)
2937

3038
def update_state(self, key: str, value: Any) -> None:
3139
"""Update agent state."""
@@ -41,16 +49,31 @@ def get_history(self, n: Optional[int] = None) -> List[Dict[str, Any]]:
4149
n = self.max_history
4250

4351
history = []
44-
for task, response in zip(self.tasks[-n:], self.responses[-n:]):
52+
# Use timestamps recorded at insertion time (task/response) instead of `datetime.now()`
53+
recent_tasks = self.tasks[-n:]
54+
recent_task_timestamps = self.task_timestamps[-n:]
55+
recent_responses = self.responses[-n:]
56+
recent_response_timestamps = self.response_timestamps[-n:]
57+
58+
for task, response, task_ts, resp_ts in zip(
59+
recent_tasks,
60+
recent_responses,
61+
recent_task_timestamps,
62+
recent_response_timestamps,
63+
):
4564
history.append({
4665
"task": task,
4766
"response": response,
48-
"timestamp": datetime.now().isoformat()
67+
# Prefer response timestamp because it reflects when the completion arrived.
68+
"timestamp": resp_ts.isoformat() if isinstance(resp_ts, datetime) else datetime.now().isoformat(),
69+
"task_timestamp": task_ts.isoformat() if isinstance(task_ts, datetime) else None,
4970
})
5071
return history
5172

5273
def clear(self) -> None:
5374
"""Clear all memory."""
5475
self.tasks.clear()
76+
self.task_timestamps.clear()
5577
self.responses.clear()
78+
self.response_timestamps.clear()
5679
self.state.clear()

multimind/api/multi_model_api.py

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,59 @@
22
FastAPI-based API interface for the MultiModelWrapper.
33
"""
44

5+
import logging
56
from fastapi import FastAPI, HTTPException
67
from pydantic import BaseModel, Field
78
from typing import List, Dict, Optional, Union
89
import asyncio
10+
import json
11+
from typing import Tuple, Any
12+
from functools import lru_cache
913
from ..models.factory import ModelFactory
1014
from ..models.multi_model import MultiModelWrapper
1115

1216
app = FastAPI(title="Multi-Model API")
17+
logger = logging.getLogger(__name__)
18+
19+
# Reuse a single factory across requests to avoid re-loading env / re-allocating caches.
20+
_MODEL_FACTORY = ModelFactory()
21+
22+
# Cache MultiModelWrapper instances by request parameters.
23+
# Note: wrapper init can be expensive because it initializes provider model instances.
24+
_WRAPPER_CACHE: Dict[Tuple[str, Tuple[str, ...], str], MultiModelWrapper] = {}
25+
_WRAPPER_LOCKS: Dict[Tuple[str, Tuple[str, ...], str], asyncio.Lock] = {}
26+
27+
28+
def _weights_key(model_weights: Optional[Dict[str, float]]) -> str:
29+
# Stable string key for dict weights (used for caching).
30+
return json.dumps(model_weights or {}, sort_keys=True, default=str)
31+
32+
33+
async def _get_multi_model(
34+
*,
35+
primary_model: str,
36+
fallback_models: List[str],
37+
model_weights: Optional[Dict[str, float]],
38+
) -> MultiModelWrapper:
39+
fallback_tuple = tuple(fallback_models or [])
40+
key = (primary_model, fallback_tuple, _weights_key(model_weights))
41+
42+
if key in _WRAPPER_CACHE:
43+
return _WRAPPER_CACHE[key]
44+
45+
lock = _WRAPPER_LOCKS.setdefault(key, asyncio.Lock())
46+
async with lock:
47+
if key in _WRAPPER_CACHE:
48+
return _WRAPPER_CACHE[key]
49+
50+
wrapper = MultiModelWrapper(
51+
model_factory=_MODEL_FACTORY,
52+
primary_model=primary_model,
53+
fallback_models=list(fallback_tuple),
54+
model_weights=model_weights,
55+
)
56+
_WRAPPER_CACHE[key] = wrapper
57+
return wrapper
1358

1459
class GenerateRequest(BaseModel):
1560
prompt: str
@@ -37,12 +82,10 @@ class EmbeddingsRequest(BaseModel):
3782
async def generate(request: GenerateRequest):
3883
"""Generate text using the multi-model wrapper."""
3984
try:
40-
factory = ModelFactory()
41-
multi_model = MultiModelWrapper(
42-
model_factory=factory,
85+
multi_model = await _get_multi_model(
4386
primary_model=request.primary_model,
4487
fallback_models=request.fallback_models,
45-
model_weights=request.model_weights
88+
model_weights=request.model_weights,
4689
)
4790

4891
response = await multi_model.generate(
@@ -52,18 +95,17 @@ async def generate(request: GenerateRequest):
5295
)
5396
return {"response": response}
5497
except Exception as e:
55-
raise HTTPException(status_code=500, detail=str(e))
98+
logger.exception("Unhandled error in /generate")
99+
raise HTTPException(status_code=500, detail="Internal server error")
56100

57101
@app.post("/chat")
58102
async def chat(request: ChatRequest):
59103
"""Generate chat completion using the multi-model wrapper."""
60104
try:
61-
factory = ModelFactory()
62-
multi_model = MultiModelWrapper(
63-
model_factory=factory,
105+
multi_model = await _get_multi_model(
64106
primary_model=request.primary_model,
65107
fallback_models=request.fallback_models,
66-
model_weights=request.model_weights
108+
model_weights=request.model_weights,
67109
)
68110

69111
response = await multi_model.chat(
@@ -73,24 +115,24 @@ async def chat(request: ChatRequest):
73115
)
74116
return {"response": response}
75117
except Exception as e:
76-
raise HTTPException(status_code=500, detail=str(e))
118+
logger.exception("Unhandled error in /chat")
119+
raise HTTPException(status_code=500, detail="Internal server error")
77120

78121
@app.post("/embeddings")
79122
async def embeddings(request: EmbeddingsRequest):
80123
"""Generate embeddings using the multi-model wrapper."""
81124
try:
82-
factory = ModelFactory()
83-
multi_model = MultiModelWrapper(
84-
model_factory=factory,
125+
multi_model = await _get_multi_model(
85126
primary_model=request.primary_model,
86127
fallback_models=request.fallback_models,
87-
model_weights=request.model_weights
128+
model_weights=request.model_weights,
88129
)
89130

90131
embeddings = await multi_model.embeddings(request.text)
91132
return {"embeddings": embeddings}
92133
except Exception as e:
93-
raise HTTPException(status_code=500, detail=str(e))
134+
logger.exception("Unhandled error in /embeddings")
135+
raise HTTPException(status_code=500, detail="Internal server error")
94136

95137
@app.get("/health")
96138
async def health_check():

multimind/api/unified_api.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import BaseModel, Field
77
from typing import Dict, List, Any, Optional, Union
88
import asyncio
9-
import traceback
9+
import logging
1010
import os
1111
import base64
1212
import io
@@ -15,8 +15,13 @@
1515
from ..models.moe import Expert
1616
from ..types import UnifiedRequest, UnifiedResponse, ModalityInput
1717

18+
logger = logging.getLogger(__name__)
19+
1820
app = FastAPI(title="Unified Multi-Modal API")
1921

22+
# Reuse a single factory across requests to avoid re-creating model caches.
23+
_MODEL_FACTORY = ModelFactory()
24+
2025

2126
class _TextExpertAdapter(Expert):
2227
"""Expert wrapper around a model instance for text."""
@@ -113,7 +118,6 @@ async def process(self, input_data: Any) -> Any:
113118
def _build_experts(modalities: List[str], router: Any) -> Dict[str, Expert]:
114119
"""Build available experts for modality MoE."""
115120
experts: Dict[str, Expert] = {}
116-
factory = ModelFactory()
117121

118122
for modality in modalities:
119123
model = None
@@ -122,9 +126,9 @@ def _build_experts(modalities: List[str], router: Any) -> Dict[str, Expert]:
122126
model = next(iter(model_map.values()), None)
123127

124128
if model is None and modality == "text":
125-
for provider in factory.available_models():
129+
for provider in _MODEL_FACTORY.available_models():
126130
try:
127-
model = factory.get_model(provider)
131+
model = _MODEL_FACTORY.get_model(provider)
128132
break
129133
except Exception:
130134
continue
@@ -280,10 +284,8 @@ async def process_request(request: UnifiedRequest):
280284
# Preserve intended HTTP status codes (e.g., 400 for invalid input).
281285
raise
282286
except Exception as e:
283-
raise HTTPException(
284-
status_code=500,
285-
detail=f"Error processing request: {str(e)}\nTraceback:\n{traceback.format_exc()}"
286-
)
287+
logger.exception("Error processing request")
288+
raise HTTPException(status_code=500, detail="Internal server error")
287289

288290
@app.get("/v1/models")
289291
async def list_models():

0 commit comments

Comments
 (0)