Diomedes Git commited on
Commit
de7bf4b
·
1 Parent(s): e4effb4

add multi-provider support for corvid trio

Browse files
dev_diary.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ ## 2025-11-26
2
+ - Ported the Raven-style multi-provider logic into Corvus, Crow, and Magpie so each character now shares the same client bootstrapping, fallback strategy, and tool definition structure while keeping their unique prompts and tool behaviors intact.
3
+
src/characters/corvus.py CHANGED
@@ -6,6 +6,7 @@ import logging
6
  from typing import Optional, List, Dict
7
  from dotenv import load_dotenv
8
  from groq import Groq
 
9
  from src.cluas_mcp.academic.academic_search_entrypoint import academic_search
10
  from src.cluas_mcp.common.paper_memory import PaperMemory
11
  from src.cluas_mcp.common.observation_memory import ObservationMemory
@@ -16,19 +17,32 @@ logger = logging.getLogger(__name__)
16
 
17
  class Corvus:
18
 
19
- def __init__(self, use_groq=True, location="Glasgow, Scotland"):
20
  self.name = "Corvus"
21
- self.use_groq = use_groq
22
  self.paper_memory = PaperMemory()
23
  self.observation_memory = ObservationMemory(location=location)
 
 
 
24
 
25
-
26
- if use_groq:
27
- api_key = os.getenv("GROQ_API_KEY")
28
- if not api_key:
29
- raise ValueError("GROQ_API_KEY not found in environment")
30
- self.client = Groq(api_key=api_key)
31
- self.model = "openai/gpt-oss-120b"
 
 
 
 
 
 
 
 
 
 
32
  else:
33
  self.model = "llama3.1:8b"
34
 
@@ -72,6 +86,83 @@ class Corvus:
72
 
73
  return corvus_base_prompt + memory_context
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  # little bit of fuzzy for the recall:
76
 
77
  def recall_paper(self, query: str) -> Optional[Dict]:
@@ -98,13 +189,12 @@ class Corvus:
98
  message: str,
99
  conversation_history: Optional[List[Dict]] = None) -> str:
100
  """Generate a response."""
101
- if self.use_groq:
102
- return await self._respond_groq(message, conversation_history) # add await
103
- else:
104
- return self._respond_ollama(message, conversation_history)
105
 
106
- async def _respond_groq(self, message: str, history: Optional[List[Dict]] = None) -> str: # make async
107
- """Use Groq API with tools"""
108
 
109
  if "paper" in message.lower() and len(message.split()) < 10: # maybe add oyther keywords? "or study"? "or article"?
110
  recalled = self.recall_paper(message)
@@ -119,48 +209,31 @@ class Corvus:
119
 
120
  messages.append({"role": "user", "content": message})
121
 
122
- tools = [{
123
- "type": "function",
124
- "function": {
125
- "name": "academic_search",
126
- "description": "Search academic papers in PubMed, ArXiv, and Semantic Scholar",
127
- "parameters": {
128
- "type": "object",
129
- "properties": {
130
- "query": {
131
- "type": "string",
132
- "description": "Search query for academic papers"
133
- }
134
- },
135
- "required": ["query"]
136
- }
137
- }
138
- }]
139
 
140
- # first LLM call
141
- response = self.client.chat.completions.create(
142
- model=self.model,
143
  messages=messages,
144
  tools=tools,
145
- tool_choice="auto",
146
  temperature=0.8,
147
  max_tokens=150
148
  )
149
 
150
- choice = response.choices[0]
151
 
152
  # check if model wants to use tool
153
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
154
  tool_call = choice.message.tool_calls[0]
155
 
156
- if tool_call.function.name == "academic_search":
 
 
157
  # Parse arguments
158
  args = json.loads(tool_call.function.arguments)
159
- query = args.get("query")
160
 
161
  # Call the search function (it's sync, so use executor)
162
  loop = asyncio.get_event_loop()
163
- search_results = await loop.run_in_executor(None, academic_search, query)
 
164
 
165
  # Format results for LLM
166
  tool_result = self._format_search_for_llm(search_results)
@@ -185,8 +258,7 @@ class Corvus:
185
  })
186
 
187
  # second LLM call with search results
188
- final_response = self.client.chat.completions.create(
189
- model=self.model,
190
  messages=messages,
191
  temperature=0.8,
192
  max_tokens=200 # More tokens for synthesis
 
6
  from typing import Optional, List, Dict
7
  from dotenv import load_dotenv
8
  from groq import Groq
9
+ from openai import OpenAI
10
  from src.cluas_mcp.academic.academic_search_entrypoint import academic_search
11
  from src.cluas_mcp.common.paper_memory import PaperMemory
12
  from src.cluas_mcp.common.observation_memory import ObservationMemory
 
17
 
18
  class Corvus:
19
 
20
+ def __init__(self, provider_config: Optional[Dict] = None, location: str = "Glasgow, Scotland"):
21
  self.name = "Corvus"
22
+ self.location = location
23
  self.paper_memory = PaperMemory()
24
  self.observation_memory = ObservationMemory(location=location)
25
+ self.tool_functions = {
26
+ "academic_search": academic_search
27
+ }
28
 
29
+ if provider_config is None:
30
+ provider_config = {
31
+ "primary": "groq",
32
+ "fallback": ["nebius"],
33
+ "models": {
34
+ "groq": "llama-3.1-70b-versatile",
35
+ "nebius": "meta-llama/Meta-Llama-3.1-70B-Instruct"
36
+ },
37
+ "timeout": 30,
38
+ "use_cloud": True
39
+ }
40
+
41
+ self.provider_config = provider_config
42
+ self.use_cloud = provider_config.get("use_cloud", True)
43
+
44
+ if self.use_cloud:
45
+ self._init_clients()
46
  else:
47
  self.model = "llama3.1:8b"
48
 
 
86
 
87
  return corvus_base_prompt + memory_context
88
 
89
+ def _init_clients(self) -> None:
90
+ """Initialize remote provider clients."""
91
+ self.clients = {}
92
+
93
+ api_timeout = self.provider_config.get("timeout", 30)
94
+
95
+ if os.getenv("GROQ_API_KEY"):
96
+ self.clients["groq"] = Groq(
97
+ api_key=os.getenv("GROQ_API_KEY"),
98
+ timeout=api_timeout
99
+ )
100
+
101
+ if os.getenv("NEBIUS_API_KEY"):
102
+ self.clients["nebius"] = OpenAI(
103
+ api_key=os.getenv("NEBIUS_API_KEY"),
104
+ base_url="https://api.tokenfactory.nebius.com/v1",
105
+ timeout=api_timeout
106
+ )
107
+
108
+ if not self.clients:
109
+ raise ValueError(f"{self.name}: No LLM provider API keys found in environment")
110
+
111
+ logger.info("%s initialized with providers: %s", self.name, list(self.clients.keys()))
112
+
113
+ def _get_tool_definitions(self) -> List[Dict]:
114
+ return [{
115
+ "type": "function",
116
+ "function": {
117
+ "name": "academic_search",
118
+ "description": "Search academic papers in PubMed, ArXiv, and Semantic Scholar",
119
+ "parameters": {
120
+ "type": "object",
121
+ "properties": {
122
+ "query": {
123
+ "type": "string",
124
+ "description": "Search query for academic papers"
125
+ }
126
+ },
127
+ "required": ["query"]
128
+ }
129
+ }
130
+ }]
131
+
132
+ def _call_llm(self,
133
+ messages: List[Dict],
134
+ tools: Optional[List[Dict]] = None,
135
+ temperature: float = 0.8,
136
+ max_tokens: int = 150):
137
+ """Call configured LLM providers with fallback order."""
138
+ providers = [self.provider_config["primary"]] + self.provider_config.get("fallback", [])
139
+ last_error = None
140
+
141
+ for provider in providers:
142
+ client = self.clients.get(provider)
143
+ if not client:
144
+ logger.debug("%s: skipping provider %s (not configured)", self.name, provider)
145
+ continue
146
+
147
+ try:
148
+ model = self.provider_config["models"][provider]
149
+ response = client.chat.completions.create(
150
+ model=model,
151
+ messages=messages,
152
+ tools=tools,
153
+ tool_choice="auto" if tools else None,
154
+ temperature=temperature,
155
+ max_tokens=max_tokens
156
+ )
157
+ logger.info("%s successfully used %s", self.name, provider)
158
+ return response, provider
159
+ except Exception as exc:
160
+ last_error = exc
161
+ logger.warning("%s: %s failed (%s)", self.name, provider, str(exc)[:100])
162
+ continue
163
+
164
+ raise RuntimeError(f"All LLM providers failed for {self.name}. Last error: {last_error}")
165
+
166
  # little bit of fuzzy for the recall:
167
 
168
  def recall_paper(self, query: str) -> Optional[Dict]:
 
189
  message: str,
190
  conversation_history: Optional[List[Dict]] = None) -> str:
191
  """Generate a response."""
192
+ if self.use_cloud:
193
+ return await self._respond_cloud(message, conversation_history)
194
+ return self._respond_ollama(message, conversation_history)
 
195
 
196
+ async def _respond_cloud(self, message: str, history: Optional[List[Dict]] = None) -> str:
197
+ """Use configured cloud providers with tools."""
198
 
199
  if "paper" in message.lower() and len(message.split()) < 10: # maybe add oyther keywords? "or study"? "or article"?
200
  recalled = self.recall_paper(message)
 
209
 
210
  messages.append({"role": "user", "content": message})
211
 
212
+ tools = self._get_tool_definitions()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
+ first_response, _ = self._call_llm(
 
 
215
  messages=messages,
216
  tools=tools,
 
217
  temperature=0.8,
218
  max_tokens=150
219
  )
220
 
221
+ choice = first_response.choices[0]
222
 
223
  # check if model wants to use tool
224
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
225
  tool_call = choice.message.tool_calls[0]
226
 
227
+ tool_name = tool_call.function.name
228
+
229
+ if tool_name in self.tool_functions:
230
  # Parse arguments
231
  args = json.loads(tool_call.function.arguments)
 
232
 
233
  # Call the search function (it's sync, so use executor)
234
  loop = asyncio.get_event_loop()
235
+ tool_func = self.tool_functions[tool_name]
236
+ search_results = await loop.run_in_executor(None, lambda: tool_func(**args))
237
 
238
  # Format results for LLM
239
  tool_result = self._format_search_for_llm(search_results)
 
258
  })
259
 
260
  # second LLM call with search results
261
+ final_response, _ = self._call_llm(
 
262
  messages=messages,
263
  temperature=0.8,
264
  max_tokens=200 # More tokens for synthesis
src/characters/crow.py CHANGED
@@ -7,6 +7,7 @@ from datetime import datetime, UTC
7
  from typing import Optional, List, Dict, Any
8
  from dotenv import load_dotenv
9
  from groq import Groq
 
10
  from src.cluas_mcp.observation.observation_entrypoint import (
11
  get_bird_sightings,
12
  get_weather_patterns,
@@ -25,9 +26,8 @@ logger = logging.getLogger(__name__)
25
 
26
  class Crow:
27
 
28
- def __init__(self, use_groq=True, location="Tokyo, Japan"):
29
  self.name = "Crow"
30
- self.use_groq = use_groq
31
  self.location = location # crow's home location
32
  self.observation_memory = ObservationMemory()
33
  self.paper_memory = PaperMemory()
@@ -41,13 +41,24 @@ class Crow:
41
  "get_sun_times": get_sun_times,
42
  "analyze_temporal_patterns": analyze_temporal_patterns
43
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- if use_groq:
46
- api_key = os.getenv("GROQ_API_KEY")
47
- if not api_key:
48
- raise ValueError("GROQ_API_KEY not found in environment")
49
- self.client = Groq(api_key=api_key)
50
- self.model = "openai/gpt-oss-120b"
51
  else:
52
  self.model = "llama3.1:8b"
53
 
@@ -104,26 +115,31 @@ When discussing weather, birds, air quality, or natural patterns, use your tools
104
 
105
  return "\n".join(summary_lines) + "\n"
106
 
107
- async def respond(self,
108
- message: str,
109
- conversation_history: Optional[List[Dict]] = None) -> str:
110
- """Generate a response."""
111
- if self.use_groq:
112
- return await self._respond_groq(message, conversation_history)
113
- else:
114
- return self._respond_ollama(message, conversation_history)
115
-
116
- async def _respond_groq(self, message: str, history: Optional[List[Dict]] = None) -> str:
117
- """Use Groq API with tools."""
118
-
119
- messages = [{"role": "system", "content": self.get_system_prompt()}]
120
-
121
- if history:
122
- messages.extend(history[-5:])
123
-
124
- messages.append({"role": "user", "content": message})
125
-
126
- tools = [
 
 
 
 
 
127
  {
128
  "type": "function",
129
  "function": {
@@ -222,18 +238,70 @@ When discussing weather, birds, air quality, or natural patterns, use your tools
222
  }
223
  }
224
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  # first LLM call
227
- response = self.client.chat.completions.create(
228
- model=self.model,
229
  messages=messages,
230
  tools=tools,
231
- tool_choice="auto",
232
  temperature=0.7, # Slightly lower for Crow's measured personality
233
  max_tokens=150
234
  )
235
 
236
- choice = response.choices[0]
237
 
238
  # check if model wants to use a tool
239
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
@@ -275,8 +343,7 @@ When discussing weather, birds, air quality, or natural patterns, use your tools
275
  })
276
 
277
  # second LLM call with observation results
278
- final_response = self.client.chat.completions.create(
279
- model=self.model,
280
  messages=messages,
281
  temperature=0.7,
282
  max_tokens=200
 
7
  from typing import Optional, List, Dict, Any
8
  from dotenv import load_dotenv
9
  from groq import Groq
10
+ from openai import OpenAI
11
  from src.cluas_mcp.observation.observation_entrypoint import (
12
  get_bird_sightings,
13
  get_weather_patterns,
 
26
 
27
  class Crow:
28
 
29
+ def __init__(self, provider_config: Optional[Dict] = None, location: str = "Tokyo, Japan"):
30
  self.name = "Crow"
 
31
  self.location = location # crow's home location
32
  self.observation_memory = ObservationMemory()
33
  self.paper_memory = PaperMemory()
 
41
  "get_sun_times": get_sun_times,
42
  "analyze_temporal_patterns": analyze_temporal_patterns
43
  }
44
+
45
+ if provider_config is None:
46
+ provider_config = {
47
+ "primary": "groq",
48
+ "fallback": ["nebius"],
49
+ "models": {
50
+ "groq": "llama-3.1-70b-versatile",
51
+ "nebius": "meta-llama/Meta-Llama-3.1-70B-Instruct"
52
+ },
53
+ "timeout": 30,
54
+ "use_cloud": True
55
+ }
56
+
57
+ self.provider_config = provider_config
58
+ self.use_cloud = provider_config.get("use_cloud", True)
59
 
60
+ if self.use_cloud:
61
+ self._init_clients()
 
 
 
 
62
  else:
63
  self.model = "llama3.1:8b"
64
 
 
115
 
116
  return "\n".join(summary_lines) + "\n"
117
 
118
+ def _init_clients(self) -> None:
119
+ """Initialize remote provider clients."""
120
+ self.clients = {}
121
+ api_timeout = self.provider_config.get("timeout", 30)
122
+
123
+ if os.getenv("GROQ_API_KEY"):
124
+ self.clients["groq"] = Groq(
125
+ api_key=os.getenv("GROQ_API_KEY"),
126
+ timeout=api_timeout
127
+ )
128
+
129
+ if os.getenv("NEBIUS_API_KEY"):
130
+ self.clients["nebius"] = OpenAI(
131
+ api_key=os.getenv("NEBIUS_API_KEY"),
132
+ base_url="https://api.tokenfactory.nebius.com/v1",
133
+ timeout=api_timeout
134
+ )
135
+
136
+ if not self.clients:
137
+ raise ValueError(f"{self.name}: No LLM provider API keys found in environment")
138
+
139
+ logger.info("%s initialized with providers: %s", self.name, list(self.clients.keys()))
140
+
141
+ def _get_tool_definitions(self) -> List[Dict]:
142
+ return [
143
  {
144
  "type": "function",
145
  "function": {
 
238
  }
239
  }
240
  ]
241
+
242
+ def _call_llm(self,
243
+ messages: List[Dict],
244
+ tools: Optional[List[Dict]] = None,
245
+ temperature: float = 0.7,
246
+ max_tokens: int = 150):
247
+ """Call configured LLM providers with fallback order."""
248
+ providers = [self.provider_config["primary"]] + self.provider_config.get("fallback", [])
249
+ last_error = None
250
+
251
+ for provider in providers:
252
+ client = self.clients.get(provider)
253
+ if not client:
254
+ logger.debug("%s: skipping provider %s (not configured)", self.name, provider)
255
+ continue
256
+
257
+ try:
258
+ model = self.provider_config["models"][provider]
259
+ response = client.chat.completions.create(
260
+ model=model,
261
+ messages=messages,
262
+ tools=tools,
263
+ tool_choice="auto" if tools else None,
264
+ temperature=temperature,
265
+ max_tokens=max_tokens
266
+ )
267
+ logger.info("%s successfully used %s", self.name, provider)
268
+ return response, provider
269
+ except Exception as exc:
270
+ last_error = exc
271
+ logger.warning("%s: %s failed (%s)", self.name, provider, str(exc)[:100])
272
+ continue
273
+
274
+ raise RuntimeError(f"All LLM providers failed for {self.name}. Last error: {last_error}")
275
+
276
+ async def respond(self,
277
+ message: str,
278
+ conversation_history: Optional[List[Dict]] = None) -> str:
279
+ """Generate a response."""
280
+ if self.use_cloud:
281
+ return await self._respond_cloud(message, conversation_history)
282
+ return self._respond_ollama(message, conversation_history)
283
+
284
+ async def _respond_cloud(self, message: str, history: Optional[List[Dict]] = None) -> str:
285
+ """Use configured cloud providers with tools."""
286
+
287
+ messages = [{"role": "system", "content": self.get_system_prompt()}]
288
+
289
+ if history:
290
+ messages.extend(history[-5:])
291
+
292
+ messages.append({"role": "user", "content": message})
293
+
294
+ tools = self._get_tool_definitions()
295
 
296
  # first LLM call
297
+ first_response, _ = self._call_llm(
 
298
  messages=messages,
299
  tools=tools,
 
300
  temperature=0.7, # Slightly lower for Crow's measured personality
301
  max_tokens=150
302
  )
303
 
304
+ choice = first_response.choices[0]
305
 
306
  # check if model wants to use a tool
307
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
 
343
  })
344
 
345
  # second LLM call with observation results
346
+ final_response, _ = self._call_llm(
 
347
  messages=messages,
348
  temperature=0.7,
349
  max_tokens=200
src/characters/magpie.py CHANGED
@@ -1,9 +1,12 @@
1
  import os
2
  import json
3
  import asyncio
 
 
4
  from typing import Optional, List, Dict
5
  from dotenv import load_dotenv
6
  from groq import Groq
 
7
  from src.cluas_mcp.web.web_search import search_web
8
  from src.cluas_mcp.web.trending import fetch_trends
9
 
@@ -11,25 +14,47 @@ from src.cluas_mcp.common.paper_memory import PaperMemory
11
  from src.cluas_mcp.common.observation_memory import ObservationMemory
12
  from src.cluas_mcp.common.trend_memory import TrendMemory
13
 
 
 
 
 
 
14
 
15
  load_dotenv()
 
16
 
17
  class Magpie:
18
- def __init__(self, use_groq=True, location="Brooklyn, NY"):
19
  self.name = "Magpie"
20
- self.use_groq = use_groq
21
- self.tools = ["search_web", "fetch_trends"]
22
  self.trend_memory = TrendMemory()
23
  self.paper_memory = PaperMemory()
24
  self.observation_memory = ObservationMemory(location=location)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
 
 
26
 
27
- if use_groq:
28
- api_key = os.getenv("GROQ_API_KEY")
29
- if not api_key:
30
- raise ValueError("GROQ_API_KEY not found in environment")
31
- self.client = Groq(api_key=api_key)
32
- self.model = "openai/gpt-oss-120b"
33
  else:
34
  self.model = "llama3.1:8b"
35
 
@@ -57,65 +82,31 @@ TOOLS AVAILABLE:
57
 
58
  When you need current information or want to share something interesting, use your tools!"""
59
 
60
- async def respond(self,
61
- message: str,
62
- conversation_history: Optional[List[Dict]] = None) -> str:
63
- """Generate a response."""
64
- if self.use_groq:
65
- return await self._respond_groq(message, conversation_history)
66
- else:
67
- return self._respond_ollama(message, conversation_history)
68
-
69
- def _respond_ollama(self, message: str, history: Optional[List[Dict]] = None) -> str:
70
- """Use Ollama."""
71
- prompt = self._build_prompt(message, history)
72
-
73
- response = requests.post('http://localhost:11434/api/generate', json={
74
- "model": self.model,
75
- "prompt": prompt,
76
- "system": self.get_system_prompt(),
77
- "stream": False,
78
- "options": {
79
- "temperature": 0.8,
80
- "num_predict": 200,
81
- }
82
- })
83
-
84
- if response.status_code != 200:
85
- return f"[Magpie is having technical difficulties: {response.status_code}]"
86
-
87
- result = response.json()
88
- return result.get('response', '').strip()
89
-
90
- def _build_prompt(self, message: str, history: Optional[List[Dict]] = None) -> str:
91
- """Build prompt for Ollama."""
92
- if not history:
93
- return f"User: {message}\n\nMagpie:"
94
-
95
- prompt_parts = []
96
- for msg in history[-5:]:
97
- role = msg.get('role', 'user')
98
- content = msg.get('content', '')
99
- if role == 'user':
100
- prompt_parts.append(f"User: {content}")
101
- elif role == 'assistant':
102
- prompt_parts.append(f"Magpie: {content}")
103
-
104
- prompt_parts.append(f"User: {message}")
105
- prompt_parts.append("Magpie:")
106
-
107
- return "\n\n".join(prompt_parts)
108
-
109
- async def _respond_groq(self, message: str, history: Optional[List[Dict]] = None) -> str:
110
- """Use Groq API with tools"""
111
- messages = [{"role": "system", "content": self.get_system_prompt()}]
112
-
113
- if history:
114
- messages.extend(history[-5:])
115
-
116
- messages.append({"role": "user", "content": message})
117
-
118
- tools = [
119
  {
120
  "type": "function",
121
  "function": {
@@ -169,18 +160,109 @@ When you need current information or want to share something interesting, use yo
169
  }
170
  }
171
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  # First LLM call
174
- response = self.client.chat.completions.create(
175
- model=self.model,
176
  messages=messages,
177
  tools=tools,
178
- tool_choice="auto",
179
  temperature=0.8,
180
  max_tokens=150
181
  )
182
 
183
- choice = response.choices[0]
184
 
185
  # Check if model wants to use tool
186
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
@@ -195,18 +277,24 @@ When you need current information or want to share something interesting, use yo
195
  # Call the appropriate tool
196
  if tool_name == "search_web":
197
  query = args.get("query")
198
- search_results = await loop.run_in_executor(None, search_web, query)
199
- tool_result = self._format_web_search_for_llm(search_results)
 
 
200
 
201
  elif tool_name == "fetch_trends":
202
  category = args.get("category", "general")
203
- trending_results = await loop.run_in_executor(None, fetch_trends, category)
204
- tool_result = self._format_trending_topics_for_llm(trending_results)
 
 
205
 
206
  elif tool_name == "get_quick_facts":
207
  topic = args.get("topic")
208
- facts_results = await loop.run_in_executor(None, get_quick_facts, topic)
209
- tool_result = self._format_quick_facts_for_llm(facts_results)
 
 
210
 
211
  if tool_result:
212
  # Add tool call and result to conversation
@@ -229,8 +317,7 @@ When you need current information or want to share something interesting, use yo
229
  })
230
 
231
  # Second LLM call with tool results
232
- final_response = self.client.chat.completions.create(
233
- model=self.model,
234
  messages=messages,
235
  temperature=0.8,
236
  max_tokens=200 # More tokens for synthesis
 
1
  import os
2
  import json
3
  import asyncio
4
+ import logging
5
+ import requests
6
  from typing import Optional, List, Dict
7
  from dotenv import load_dotenv
8
  from groq import Groq
9
+ from openai import OpenAI
10
  from src.cluas_mcp.web.web_search import search_web
11
  from src.cluas_mcp.web.trending import fetch_trends
12
 
 
14
  from src.cluas_mcp.common.observation_memory import ObservationMemory
15
  from src.cluas_mcp.common.trend_memory import TrendMemory
16
 
17
+ try:
18
+ from src.cluas_mcp.web.quick_facts import get_quick_facts
19
+ except ImportError: # pragma: no cover - optional dependency
20
+ get_quick_facts = None
21
+
22
 
23
  load_dotenv()
24
+ logger = logging.getLogger(__name__)
25
 
26
  class Magpie:
27
+ def __init__(self, provider_config: Optional[Dict] = None, location: str = "Brooklyn, NY"):
28
  self.name = "Magpie"
29
+ self.location = location
30
+ self.tools = ["search_web", "fetch_trends", "get_quick_facts"]
31
  self.trend_memory = TrendMemory()
32
  self.paper_memory = PaperMemory()
33
  self.observation_memory = ObservationMemory(location=location)
34
+ self.tool_functions = {
35
+ "search_web": search_web,
36
+ "fetch_trends": fetch_trends
37
+ }
38
+ if get_quick_facts:
39
+ self.tool_functions["get_quick_facts"] = get_quick_facts
40
+
41
+ if provider_config is None:
42
+ provider_config = {
43
+ "primary": "groq",
44
+ "fallback": ["nebius"],
45
+ "models": {
46
+ "groq": "llama-3.1-70b-versatile",
47
+ "nebius": "meta-llama/Meta-Llama-3.1-70B-Instruct"
48
+ },
49
+ "timeout": 30,
50
+ "use_cloud": True
51
+ }
52
 
53
+ self.provider_config = provider_config
54
+ self.use_cloud = provider_config.get("use_cloud", True)
55
 
56
+ if self.use_cloud:
57
+ self._init_clients()
 
 
 
 
58
  else:
59
  self.model = "llama3.1:8b"
60
 
 
82
 
83
  When you need current information or want to share something interesting, use your tools!"""
84
 
85
+ def _init_clients(self) -> None:
86
+ """Initialize remote provider clients."""
87
+ self.clients = {}
88
+ api_timeout = self.provider_config.get("timeout", 30)
89
+
90
+ if os.getenv("GROQ_API_KEY"):
91
+ self.clients["groq"] = Groq(
92
+ api_key=os.getenv("GROQ_API_KEY"),
93
+ timeout=api_timeout
94
+ )
95
+
96
+ if os.getenv("NEBIUS_API_KEY"):
97
+ self.clients["nebius"] = OpenAI(
98
+ api_key=os.getenv("NEBIUS_API_KEY"),
99
+ base_url="https://api.tokenfactory.nebius.com/v1",
100
+ timeout=api_timeout
101
+ )
102
+
103
+ if not self.clients:
104
+ raise ValueError(f"{self.name}: No LLM provider API keys found in environment")
105
+
106
+ logger.info("%s initialized with providers: %s", self.name, list(self.clients.keys()))
107
+
108
+ def _get_tool_definitions(self) -> List[Dict]:
109
+ return [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  {
111
  "type": "function",
112
  "function": {
 
160
  }
161
  }
162
  ]
163
+
164
+ def _call_llm(self,
165
+ messages: List[Dict],
166
+ tools: Optional[List[Dict]] = None,
167
+ temperature: float = 0.8,
168
+ max_tokens: int = 150):
169
+ """Call configured LLM providers with fallback order."""
170
+ providers = [self.provider_config["primary"]] + self.provider_config.get("fallback", [])
171
+ last_error = None
172
+
173
+ for provider in providers:
174
+ client = self.clients.get(provider)
175
+ if not client:
176
+ logger.debug("%s: skipping provider %s (not configured)", self.name, provider)
177
+ continue
178
+
179
+ try:
180
+ model = self.provider_config["models"][provider]
181
+ response = client.chat.completions.create(
182
+ model=model,
183
+ messages=messages,
184
+ tools=tools,
185
+ tool_choice="auto" if tools else None,
186
+ temperature=temperature,
187
+ max_tokens=max_tokens
188
+ )
189
+ logger.info("%s successfully used %s", self.name, provider)
190
+ return response, provider
191
+ except Exception as exc:
192
+ last_error = exc
193
+ logger.warning("%s: %s failed (%s)", self.name, provider, str(exc)[:100])
194
+ continue
195
+
196
+ raise RuntimeError(f"All LLM providers failed for {self.name}. Last error: {last_error}")
197
+
198
+ async def respond(self,
199
+ message: str,
200
+ conversation_history: Optional[List[Dict]] = None) -> str:
201
+ """Generate a response."""
202
+ if self.use_cloud:
203
+ return await self._respond_cloud(message, conversation_history)
204
+ return self._respond_ollama(message, conversation_history)
205
+
206
+ def _respond_ollama(self, message: str, history: Optional[List[Dict]] = None) -> str:
207
+ """Use Ollama."""
208
+ prompt = self._build_prompt(message, history)
209
+
210
+ response = requests.post('http://localhost:11434/api/generate', json={
211
+ "model": self.model,
212
+ "prompt": prompt,
213
+ "system": self.get_system_prompt(),
214
+ "stream": False,
215
+ "options": {
216
+ "temperature": 0.8,
217
+ "num_predict": 200,
218
+ }
219
+ })
220
+
221
+ if response.status_code != 200:
222
+ return f"[Magpie is having technical difficulties: {response.status_code}]"
223
+
224
+ result = response.json()
225
+ return result.get('response', '').strip()
226
+
227
+ def _build_prompt(self, message: str, history: Optional[List[Dict]] = None) -> str:
228
+ """Build prompt for Ollama."""
229
+ if not history:
230
+ return f"User: {message}\n\nMagpie:"
231
+
232
+ prompt_parts = []
233
+ for msg in history[-5:]:
234
+ role = msg.get('role', 'user')
235
+ content = msg.get('content', '')
236
+ if role == 'user':
237
+ prompt_parts.append(f"User: {content}")
238
+ elif role == 'assistant':
239
+ prompt_parts.append(f"Magpie: {content}")
240
+
241
+ prompt_parts.append(f"User: {message}")
242
+ prompt_parts.append("Magpie:")
243
+
244
+ return "\n\n".join(prompt_parts)
245
+
246
+ async def _respond_cloud(self, message: str, history: Optional[List[Dict]] = None) -> str:
247
+ """Use configured cloud providers with tools."""
248
+ messages = [{"role": "system", "content": self.get_system_prompt()}]
249
+
250
+ if history:
251
+ messages.extend(history[-5:])
252
+
253
+ messages.append({"role": "user", "content": message})
254
+
255
+ tools = self._get_tool_definitions()
256
 
257
  # First LLM call
258
+ first_response, _ = self._call_llm(
 
259
  messages=messages,
260
  tools=tools,
 
261
  temperature=0.8,
262
  max_tokens=150
263
  )
264
 
265
+ choice = first_response.choices[0]
266
 
267
  # Check if model wants to use tool
268
  if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
 
277
  # Call the appropriate tool
278
  if tool_name == "search_web":
279
  query = args.get("query")
280
+ tool_func = self.tool_functions.get(tool_name)
281
+ if tool_func and query:
282
+ search_results = await loop.run_in_executor(None, lambda: tool_func(query))
283
+ tool_result = self._format_web_search_for_llm(search_results)
284
 
285
  elif tool_name == "fetch_trends":
286
  category = args.get("category", "general")
287
+ tool_func = self.tool_functions.get(tool_name)
288
+ if tool_func:
289
+ trending_results = await loop.run_in_executor(None, lambda: tool_func(category))
290
+ tool_result = self._format_trending_topics_for_llm(trending_results)
291
 
292
  elif tool_name == "get_quick_facts":
293
  topic = args.get("topic")
294
+ tool_func = self.tool_functions.get(tool_name)
295
+ if tool_func and topic:
296
+ facts_results = await loop.run_in_executor(None, lambda: tool_func(topic))
297
+ tool_result = self._format_quick_facts_for_llm(facts_results)
298
 
299
  if tool_result:
300
  # Add tool call and result to conversation
 
317
  })
318
 
319
  # Second LLM call with tool results
320
+ final_response, _ = self._call_llm(
 
321
  messages=messages,
322
  temperature=0.8,
323
  max_tokens=200 # More tokens for synthesis
steps_taken.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ## 2025-11-26
2
+ - Reviewed Raven's multi-provider workflow and outlined the changes for other characters.
3
+ - Updated Corvus, Crow, and Magpie to use the provider_config-driven cloud/ollama pathway.
4
+