Diomedes Git
commited on
Commit
·
de7bf4b
1
Parent(s):
e4effb4
add multi-provider support for corvid trio
Browse files- dev_diary.md +3 -0
- src/characters/corvus.py +114 -42
- src/characters/crow.py +101 -34
- src/characters/magpie.py +167 -80
- steps_taken.md +4 -0
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,
|
| 20 |
self.name = "Corvus"
|
| 21 |
-
self.
|
| 22 |
self.paper_memory = PaperMemory()
|
| 23 |
self.observation_memory = ObservationMemory(location=location)
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 102 |
-
return await self.
|
| 103 |
-
|
| 104 |
-
return self._respond_ollama(message, conversation_history)
|
| 105 |
|
| 106 |
-
async def
|
| 107 |
-
"""Use
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 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,
|
| 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
|
| 46 |
-
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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.
|
| 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,
|
| 19 |
self.name = "Magpie"
|
| 20 |
-
self.
|
| 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
|
| 28 |
-
|
| 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 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
|
| 201 |
elif tool_name == "fetch_trends":
|
| 202 |
category = args.get("category", "general")
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
| 205 |
|
| 206 |
elif tool_name == "get_quick_facts":
|
| 207 |
topic = args.get("topic")
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
+
|