tiny-agent-os 0.0.1__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.
- tiny_agent_os-0.0.1.dist-info/METADATA +377 -0
- tiny_agent_os-0.0.1.dist-info/RECORD +64 -0
- tiny_agent_os-0.0.1.dist-info/WHEEL +5 -0
- tiny_agent_os-0.0.1.dist-info/entry_points.txt +2 -0
- tiny_agent_os-0.0.1.dist-info/licenses/LICENSE +53 -0
- tiny_agent_os-0.0.1.dist-info/top_level.txt +1 -0
- tinyagent/__init__.py +75 -0
- tinyagent/_version.py +21 -0
- tinyagent/agent.py +957 -0
- tinyagent/chat/__init__.py +12 -0
- tinyagent/chat/chat_mode.py +291 -0
- tinyagent/cli/__init__.py +16 -0
- tinyagent/cli/colors.py +104 -0
- tinyagent/cli/main.py +664 -0
- tinyagent/cli/spinner.py +94 -0
- tinyagent/cli.py +47 -0
- tinyagent/config/__init__.py +14 -0
- tinyagent/config/config.py +258 -0
- tinyagent/decorators.py +187 -0
- tinyagent/exceptions.py +85 -0
- tinyagent/factory/__init__.py +18 -0
- tinyagent/factory/agent_factory.py +439 -0
- tinyagent/factory/dynamic_agent_factory.py +561 -0
- tinyagent/factory/orchestrator.py +1514 -0
- tinyagent/factory/tiny_chain.py +552 -0
- tinyagent/logging.py +97 -0
- tinyagent/mcp/__init__.py +14 -0
- tinyagent/mcp/manager.py +321 -0
- tinyagent/prompts/README.md +133 -0
- tinyagent/prompts/default.md +14 -0
- tinyagent/prompts/prompt_manager.py +206 -0
- tinyagent/prompts/system/agent.md +50 -0
- tinyagent/prompts/system/retry.md +55 -0
- tinyagent/prompts/system/strict_json.md +54 -0
- tinyagent/prompts/system.md +10 -0
- tinyagent/prompts/tools/calculator.md +13 -0
- tinyagent/prompts/tools/weather.md +7 -0
- tinyagent/prompts/workflows/riv_reflect.md +62 -0
- tinyagent/prompts/workflows/riv_verify.md +47 -0
- tinyagent/prompts/workflows/triage.md +129 -0
- tinyagent/tool.py +185 -0
- tinyagent/tools/README.md +391 -0
- tinyagent/tools/__init__.py +39 -0
- tinyagent/tools/aider.py +122 -0
- tinyagent/tools/anon_coder.py +296 -0
- tinyagent/tools/boilerplate_tool.py +147 -0
- tinyagent/tools/brave_search.py +104 -0
- tinyagent/tools/codeagent_tool.py +217 -0
- tinyagent/tools/content_processor.py +285 -0
- tinyagent/tools/custom_text_browser.py +965 -0
- tinyagent/tools/duckduckgo_search.py +153 -0
- tinyagent/tools/external.py +303 -0
- tinyagent/tools/file_manipulator.py +274 -0
- tinyagent/tools/final_extractor_tool.py +249 -0
- tinyagent/tools/llm_serializer.py +124 -0
- tinyagent/tools/markdown_gen.py +300 -0
- tinyagent/tools/ripgrep.py +136 -0
- tinyagent/utils/__init__.py +13 -0
- tinyagent/utils/json_parser.py +231 -0
- tinyagent/utils/logging_utils.py +78 -0
- tinyagent/utils/openrouter_request.py +123 -0
- tinyagent/utils/serialization.py +185 -0
- tinyagent/utils/structured_outputs.py +131 -0
- tinyagent/utils/type_converter.py +134 -0
tinyagent/agent.py
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent implementation for the tinyAgent framework.
|
|
3
|
+
|
|
4
|
+
This module provides the Agent class, which is the central component of the
|
|
5
|
+
tinyAgent framework. The Agent uses a language model to select and execute
|
|
6
|
+
tools based on user queries.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import requests
|
|
11
|
+
import re
|
|
12
|
+
from tinyagent.utils.openrouter_request import build_openrouter_payload, make_openrouter_request
|
|
13
|
+
from tinyagent.utils.structured_outputs import parse_strict_response
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Dict, Any, Optional, Union, Callable, TypeVar, cast, Tuple, Type
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
from openai import OpenAI
|
|
20
|
+
from .exceptions import AgentRetryExceeded, ConfigurationError, ParsingError
|
|
21
|
+
from .tool import Tool
|
|
22
|
+
from .utils.type_converter import convert_to_expected_type
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_choices(completion):
|
|
26
|
+
if isinstance(completion, dict):
|
|
27
|
+
return completion.get("choices", [])
|
|
28
|
+
else:
|
|
29
|
+
return getattr(completion, "choices", [])
|
|
30
|
+
from .logging import get_logger
|
|
31
|
+
from .prompts.prompt_manager import PromptManager
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#We can move this to utils probbaly
|
|
37
|
+
# Set up logger
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
# Type definitions
|
|
41
|
+
class ToolCallResult(Dict[str, Any]):
|
|
42
|
+
"""Type definition for a tool call result entry in history."""
|
|
43
|
+
tool: str
|
|
44
|
+
args: Dict[str, Any]
|
|
45
|
+
result: Any
|
|
46
|
+
success: bool
|
|
47
|
+
timestamp: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ToolCallError(Dict[str, Any]):
|
|
51
|
+
"""Type definition for a tool call error entry in history."""
|
|
52
|
+
tool: str
|
|
53
|
+
args: Dict[str, Any]
|
|
54
|
+
error: str
|
|
55
|
+
success: bool
|
|
56
|
+
timestamp: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CacheEntry(Dict[str, Any]):
|
|
60
|
+
"""Type definition for a cache entry."""
|
|
61
|
+
result: Any
|
|
62
|
+
timestamp: float
|
|
63
|
+
tool: str
|
|
64
|
+
args: Dict[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RetryManager:
|
|
68
|
+
"""Manages the retry strategy with temperature warming and model escalation."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, config: Dict[str, Any]):
|
|
71
|
+
self.config = config
|
|
72
|
+
self.current_attempt = 0
|
|
73
|
+
self.temperature = self._get_config_value('retries.temperature.initial', 0.2)
|
|
74
|
+
self.max_temperature = self._get_config_value('retries.temperature.max', 0.8)
|
|
75
|
+
self.temp_increment = self._get_config_value('retries.temperature.increment', 0.2)
|
|
76
|
+
self.model_sequence = self._get_config_value('retries.model_escalation.sequence', [
|
|
77
|
+
"deepseek/deepseek-chat",
|
|
78
|
+
"anthropic/claude-3.5-sonnet",
|
|
79
|
+
"anthropic/claude-3.7-sonnet"
|
|
80
|
+
])
|
|
81
|
+
self.current_model_idx = 0
|
|
82
|
+
|
|
83
|
+
def _get_config_value(self, path: str, default: Any) -> Any:
|
|
84
|
+
"""Get a value from nested config using dot notation."""
|
|
85
|
+
parts = path.split('.')
|
|
86
|
+
current = self.config
|
|
87
|
+
for part in parts:
|
|
88
|
+
if isinstance(current, dict) and part in current:
|
|
89
|
+
current = current[part]
|
|
90
|
+
else:
|
|
91
|
+
return default
|
|
92
|
+
return current
|
|
93
|
+
|
|
94
|
+
def next_attempt(self) -> Tuple[float, str]:
|
|
95
|
+
"""Get parameters for next retry attempt."""
|
|
96
|
+
self.current_attempt += 1
|
|
97
|
+
|
|
98
|
+
# Every other attempt, increase temperature
|
|
99
|
+
if self.current_attempt % 2 == 0:
|
|
100
|
+
self.temperature = min(self.temperature + self.temp_increment, self.max_temperature)
|
|
101
|
+
|
|
102
|
+
# Every third attempt, escalate model
|
|
103
|
+
if self.current_attempt % 3 == 0:
|
|
104
|
+
self.current_model_idx = min(self.current_model_idx + 1, len(self.model_sequence) - 1)
|
|
105
|
+
# Reset temperature when escalating model
|
|
106
|
+
self.temperature = self._get_config_value('retries.temperature.initial', 0.2)
|
|
107
|
+
|
|
108
|
+
return self.temperature, self.model_sequence[self.current_model_idx]
|
|
109
|
+
|
|
110
|
+
def should_retry(self) -> bool:
|
|
111
|
+
"""Determine if another retry attempt should be made."""
|
|
112
|
+
max_attempts = self._get_config_value('retries.max_attempts', 3)
|
|
113
|
+
return self.current_attempt < max_attempts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Agent:
|
|
117
|
+
"""
|
|
118
|
+
An agent that uses LLMs to select and execute tools based on user queries.
|
|
119
|
+
|
|
120
|
+
This class is the central component of the tinyAgent framework. It connects
|
|
121
|
+
to a language model, formats available tools as a prompt, and uses the model's
|
|
122
|
+
response to decide which tool to execute.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
tools: Dictionary of available tools, indexed by name
|
|
126
|
+
model: Name of the language model to use
|
|
127
|
+
max_retries: Maximum number of retries for LLM calls
|
|
128
|
+
api_key: API key for the language model provider
|
|
129
|
+
config: Optional configuration dictionary
|
|
130
|
+
parser: Optional response parser
|
|
131
|
+
history: List of tool call results and errors
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# Constants
|
|
135
|
+
ENV_API_KEY = "OPENROUTER_API_KEY"
|
|
136
|
+
CACHE_TTL = 3600 # 1 hour in seconds
|
|
137
|
+
MAX_CACHE_SIZE = 1000
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
model: Optional[str] = None,
|
|
142
|
+
max_retries: Optional[int] = None,
|
|
143
|
+
config: Optional[Dict[str, Any]] = None
|
|
144
|
+
):
|
|
145
|
+
"""
|
|
146
|
+
Initialize an Agent with tools, model, and configuration.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
model: Language model identifier (e.g., "deepseek/deepseek-chat")
|
|
150
|
+
max_retries: Maximum number of retries for failed LLM calls
|
|
151
|
+
config: Optional configuration dictionary
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ConfigurationError: If required configuration is missing
|
|
155
|
+
"""
|
|
156
|
+
# Store configuration
|
|
157
|
+
if config is None:
|
|
158
|
+
from tinyagent.config import load_config
|
|
159
|
+
self.config = load_config()
|
|
160
|
+
else:
|
|
161
|
+
self.config = config
|
|
162
|
+
|
|
163
|
+
# Try to load environment variables from project root
|
|
164
|
+
project_root = None
|
|
165
|
+
try:
|
|
166
|
+
from dotenv import load_dotenv
|
|
167
|
+
env_path = os.getenv("TINYAGENT_ENV")
|
|
168
|
+
if not env_path:
|
|
169
|
+
cwd_env = os.path.join(os.getcwd(), '.env')
|
|
170
|
+
if os.path.isfile(cwd_env):
|
|
171
|
+
env_path = cwd_env
|
|
172
|
+
else:
|
|
173
|
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
174
|
+
env_path = os.path.join(project_root, '.env')
|
|
175
|
+
load_dotenv(env_path)
|
|
176
|
+
logger.debug(f"Loaded environment variables from {env_path}")
|
|
177
|
+
except ImportError:
|
|
178
|
+
logger.warning("python-dotenv not available, skipping .env loading")
|
|
179
|
+
project_root = None
|
|
180
|
+
|
|
181
|
+
# Get API key
|
|
182
|
+
self.api_key = os.getenv(self.ENV_API_KEY)
|
|
183
|
+
if not self.api_key:
|
|
184
|
+
raise ConfigurationError(
|
|
185
|
+
f"API key not found. The {self.ENV_API_KEY} environment variable must be set in .env file."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Set model and max_retries from config or defaults
|
|
189
|
+
self.model = model or self._get_config_value('model.default', "deepseek/deepseek-chat")
|
|
190
|
+
self.max_retries = max_retries or self._get_config_value('retries.max_attempts', 3)
|
|
191
|
+
|
|
192
|
+
# GOOD: Explicitly check and fail if not configured
|
|
193
|
+
if not self.config or 'base_url' not in self.config:
|
|
194
|
+
raise ConfigurationError("base_url must be set in config.yml")
|
|
195
|
+
self.base_url = self.config['base_url']
|
|
196
|
+
|
|
197
|
+
# Configure logging based on config
|
|
198
|
+
log_level = self._get_config_value('logging.level', 'WARNING').upper()
|
|
199
|
+
log_format = self._get_config_value('logging.format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
200
|
+
|
|
201
|
+
# Configure root logger with formatting
|
|
202
|
+
root_logger = logging.getLogger()
|
|
203
|
+
root_logger.setLevel(log_level)
|
|
204
|
+
|
|
205
|
+
# Remove existing handlers to avoid duplicates
|
|
206
|
+
for handler in root_logger.handlers[:]:
|
|
207
|
+
root_logger.removeHandler(handler)
|
|
208
|
+
|
|
209
|
+
# Add console handler with formatting
|
|
210
|
+
console_handler = logging.StreamHandler()
|
|
211
|
+
console_handler.setLevel(log_level)
|
|
212
|
+
formatter = logging.Formatter(log_format)
|
|
213
|
+
console_handler.setFormatter(formatter)
|
|
214
|
+
root_logger.addHandler(console_handler)
|
|
215
|
+
|
|
216
|
+
# Log configuration using structured logging with formatting
|
|
217
|
+
logger.info("\n" + "="*50)
|
|
218
|
+
logger.info("Agent Configuration")
|
|
219
|
+
logger.info("="*50)
|
|
220
|
+
logger.info(f"Base URL: {self.base_url}")
|
|
221
|
+
logger.info(f"Model: {self.model}")
|
|
222
|
+
logger.info(f"Log Level: {log_level}")
|
|
223
|
+
logger.info("="*50 + "\n")
|
|
224
|
+
|
|
225
|
+
# Initialize tools dictionary
|
|
226
|
+
self._tools: Dict[str, Tool] = {}
|
|
227
|
+
|
|
228
|
+
# Add built-in chat tool
|
|
229
|
+
self.create_tool(
|
|
230
|
+
name="chat",
|
|
231
|
+
description="Respond to general queries and conversation. Always requires a message parameter.",
|
|
232
|
+
func=lambda **kwargs: kwargs["message"] if kwargs.get("message") else "I apologize, but I need a message to respond to.",
|
|
233
|
+
parameters={"message": "The message or response to be sent"}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Initialize parser
|
|
237
|
+
self.parser = None
|
|
238
|
+
if config and "parsing" in config:
|
|
239
|
+
try:
|
|
240
|
+
from .utils.json_parser import create_parser
|
|
241
|
+
self.parser = create_parser(config, self._tools)
|
|
242
|
+
logger.debug("Parser initialized from core.utils.parser")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.warning(f"Failed to initialize parser: {str(e)}")
|
|
245
|
+
logger.warning("Using default parsing behavior")
|
|
246
|
+
|
|
247
|
+
# Initialize history
|
|
248
|
+
self.history: List[Union[ToolCallResult, ToolCallError]] = []
|
|
249
|
+
|
|
250
|
+
# Add cache initialization
|
|
251
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
252
|
+
|
|
253
|
+
# Initialize prompt manager with project root for development mode
|
|
254
|
+
self.prompt_manager = PromptManager(project_root=project_root)
|
|
255
|
+
|
|
256
|
+
# Initialize retry manager
|
|
257
|
+
self.retry_manager = RetryManager(self.config)
|
|
258
|
+
|
|
259
|
+
def create_tool(self, name: str, description: str, func: Callable, parameters: Optional[Dict[str, str]] = None) -> None:
|
|
260
|
+
"""Create and register a new tool."""
|
|
261
|
+
if name in self._tools:
|
|
262
|
+
logger.debug(f"Tool {name} already exists, updating with new function and parameters")
|
|
263
|
+
# Update existing tool instead of raising error
|
|
264
|
+
tool = Tool(
|
|
265
|
+
name=name,
|
|
266
|
+
description=description,
|
|
267
|
+
func=func,
|
|
268
|
+
parameters=parameters or {}
|
|
269
|
+
)
|
|
270
|
+
self._tools[name] = tool
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
tool = Tool(
|
|
274
|
+
name=name,
|
|
275
|
+
description=description,
|
|
276
|
+
func=func,
|
|
277
|
+
parameters=parameters or {}
|
|
278
|
+
)
|
|
279
|
+
self._tools[name] = tool
|
|
280
|
+
|
|
281
|
+
def get_available_tools(self) -> List[Tool]:
|
|
282
|
+
"""Get list of available tools."""
|
|
283
|
+
return list(self._tools.values())
|
|
284
|
+
|
|
285
|
+
def execute_tool(self, tool_name: str, **kwargs) -> Any:
|
|
286
|
+
"""Execute a tool by name with given arguments."""
|
|
287
|
+
if tool_name not in self._tools:
|
|
288
|
+
raise ValueError(f"Tool {tool_name} not found")
|
|
289
|
+
|
|
290
|
+
tool = self._tools[tool_name]
|
|
291
|
+
# Call the tool directly as a callable, not using an 'execute' method
|
|
292
|
+
return tool.func(**kwargs)
|
|
293
|
+
|
|
294
|
+
def _get_config_value(self, key_path: str, default: Any) -> Any:
|
|
295
|
+
"""
|
|
296
|
+
Get a value from the configuration by dot-separated key path.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
key_path: Dot-separated path to the configuration value
|
|
300
|
+
default: Default value if not found in configuration
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Configuration value or default
|
|
304
|
+
"""
|
|
305
|
+
if not self.config:
|
|
306
|
+
return default
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
from tinyagent.config import get_config_value
|
|
310
|
+
return get_config_value(self.config, key_path, default)
|
|
311
|
+
except ImportError:
|
|
312
|
+
# Manual implementation if config_loader is not available
|
|
313
|
+
parts = key_path.split('.')
|
|
314
|
+
value = self.config
|
|
315
|
+
|
|
316
|
+
for part in parts:
|
|
317
|
+
if isinstance(value, dict) and part in value:
|
|
318
|
+
value = value[part]
|
|
319
|
+
else:
|
|
320
|
+
return default
|
|
321
|
+
|
|
322
|
+
return value
|
|
323
|
+
|
|
324
|
+
def format_tools_for_prompt(self) -> str:
|
|
325
|
+
"""Format tools into documentation for the LLM prompt."""
|
|
326
|
+
tools_desc = []
|
|
327
|
+
for tool in self.get_available_tools():
|
|
328
|
+
params = [f"{k}: {v}" for k, v in tool.parameters.items()]
|
|
329
|
+
param_desc = ", ".join(params)
|
|
330
|
+
json_example = {
|
|
331
|
+
"tool": tool.name,
|
|
332
|
+
"arguments": {k: f"<{v}_value>" for k, v in tool.parameters.items()}
|
|
333
|
+
}
|
|
334
|
+
tools_desc.append(
|
|
335
|
+
f"- {tool.name}({param_desc})\n"
|
|
336
|
+
f" Description: {tool.description}\n"
|
|
337
|
+
f" JSON Example: {json.dumps(json_example, indent=2)}"
|
|
338
|
+
)
|
|
339
|
+
return "\n\n".join(tools_desc)
|
|
340
|
+
|
|
341
|
+
def _generate_cache_key(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
342
|
+
"""Generate a unique cache key from tool name and arguments."""
|
|
343
|
+
# Create a deterministic string representation of arguments
|
|
344
|
+
args_str = json.dumps(arguments, sort_keys=True)
|
|
345
|
+
# Combine tool name and arguments
|
|
346
|
+
key_content = f"{tool_name}:{args_str}"
|
|
347
|
+
# Create a hash of the content
|
|
348
|
+
return hashlib.md5(key_content.encode()).hexdigest()
|
|
349
|
+
|
|
350
|
+
def _cleanup_cache(self) -> None:
|
|
351
|
+
"""Remove expired entries and enforce size limit."""
|
|
352
|
+
current_time = time.time()
|
|
353
|
+
|
|
354
|
+
# Remove expired entries
|
|
355
|
+
expired_keys = [
|
|
356
|
+
key for key, entry in self._cache.items()
|
|
357
|
+
if current_time - entry['timestamp'] > self.CACHE_TTL
|
|
358
|
+
]
|
|
359
|
+
for key in expired_keys:
|
|
360
|
+
del self._cache[key]
|
|
361
|
+
|
|
362
|
+
# If still over size limit, remove oldest entries
|
|
363
|
+
if len(self._cache) > self.MAX_CACHE_SIZE:
|
|
364
|
+
sorted_entries = sorted(
|
|
365
|
+
self._cache.items(),
|
|
366
|
+
key=lambda x: x[1]['timestamp']
|
|
367
|
+
)
|
|
368
|
+
excess_count = len(self._cache) - self.MAX_CACHE_SIZE
|
|
369
|
+
for key, _ in sorted_entries[:excess_count]:
|
|
370
|
+
del self._cache[key]
|
|
371
|
+
|
|
372
|
+
def execute_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
|
373
|
+
"""Execute a tool with caching, error handling and history tracking."""
|
|
374
|
+
# Generate cache key
|
|
375
|
+
cache_key = self._generate_cache_key(tool_name, arguments)
|
|
376
|
+
|
|
377
|
+
# Clean up cache before checking
|
|
378
|
+
self._cleanup_cache()
|
|
379
|
+
|
|
380
|
+
# Check cache for existing result
|
|
381
|
+
if cache_key in self._cache:
|
|
382
|
+
cache_entry = self._cache[cache_key]
|
|
383
|
+
# Verify entry hasn't expired
|
|
384
|
+
if time.time() - cache_entry['timestamp'] <= self.CACHE_TTL:
|
|
385
|
+
logger.info("\n" + "="*50)
|
|
386
|
+
logger.info("Cache Hit")
|
|
387
|
+
logger.info("="*50)
|
|
388
|
+
logger.info(f"Tool: {tool_name}")
|
|
389
|
+
logger.info(f"Arguments: {arguments}")
|
|
390
|
+
logger.info(f"Cached Result: {cache_entry['result']}")
|
|
391
|
+
logger.info("="*50 + "\n")
|
|
392
|
+
return cache_entry['result']
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
logger.info("\n" + "="*50)
|
|
396
|
+
logger.info("Tool Execution")
|
|
397
|
+
logger.info("="*50)
|
|
398
|
+
logger.info(f"Tool: {tool_name}")
|
|
399
|
+
logger.info(f"Arguments: {arguments}")
|
|
400
|
+
logger.info("="*50 + "\n")
|
|
401
|
+
|
|
402
|
+
# Execute tool directly
|
|
403
|
+
result = self.execute_tool(tool_name, **arguments)
|
|
404
|
+
|
|
405
|
+
logger.info("\n" + "="*50)
|
|
406
|
+
logger.info("Execution Results")
|
|
407
|
+
logger.info("="*50)
|
|
408
|
+
logger.info(f"Tool: {tool_name}")
|
|
409
|
+
logger.info(f"Result: {result}")
|
|
410
|
+
logger.info(f"Status: Success")
|
|
411
|
+
logger.info("="*50 + "\n")
|
|
412
|
+
|
|
413
|
+
# Cache the successful result
|
|
414
|
+
self._cache[cache_key] = {
|
|
415
|
+
'result': result,
|
|
416
|
+
'timestamp': time.time(),
|
|
417
|
+
'tool': tool_name,
|
|
418
|
+
'args': arguments
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# Log successful tool call
|
|
422
|
+
self.history.append(cast(ToolCallResult, {
|
|
423
|
+
"tool": tool_name,
|
|
424
|
+
"args": arguments,
|
|
425
|
+
"result": result,
|
|
426
|
+
"success": True,
|
|
427
|
+
"timestamp": time.time()
|
|
428
|
+
}))
|
|
429
|
+
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error("\n" + "="*50)
|
|
434
|
+
logger.error(f"Tool: {tool_name}")
|
|
435
|
+
logger.error(f"Arguments: {arguments}")
|
|
436
|
+
logger.error(f"Error: {str(e)}")
|
|
437
|
+
logger.error("="*50 + "\n")
|
|
438
|
+
|
|
439
|
+
# Log failed tool call
|
|
440
|
+
self.history.append(cast(ToolCallError, {
|
|
441
|
+
"tool": tool_name,
|
|
442
|
+
"args": arguments,
|
|
443
|
+
"error": str(e),
|
|
444
|
+
"success": False,
|
|
445
|
+
"timestamp": time.time()
|
|
446
|
+
}))
|
|
447
|
+
raise
|
|
448
|
+
|
|
449
|
+
def _build_system_prompt(self) -> str:
|
|
450
|
+
"""
|
|
451
|
+
Build the system prompt for the LLM based on configuration.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
System prompt to send to the LLM
|
|
455
|
+
"""
|
|
456
|
+
# Check if we're in strict JSON mode
|
|
457
|
+
strict_json = self._get_config_value('parsing.strict_json', False)
|
|
458
|
+
|
|
459
|
+
# Load appropriate template
|
|
460
|
+
template_name = "strict_json" if strict_json else "agent"
|
|
461
|
+
template = self.prompt_manager.load_template(f"system/{template_name}.md")
|
|
462
|
+
|
|
463
|
+
# Process template with tools
|
|
464
|
+
return self.prompt_manager.process_template(template, {
|
|
465
|
+
"tools": self.format_tools_for_prompt()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
def _build_retry_prompt(self) -> str:
|
|
469
|
+
"""
|
|
470
|
+
Build a stricter system prompt for retry attempts.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
System prompt to send to the LLM on retry
|
|
474
|
+
"""
|
|
475
|
+
# Load retry template
|
|
476
|
+
template = self.prompt_manager.load_template("system/retry.md")
|
|
477
|
+
|
|
478
|
+
# Process template with tools
|
|
479
|
+
return self.prompt_manager.process_template(template, {
|
|
480
|
+
"tools": self.format_tools_for_prompt()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
def _parse_response(self, content: str) -> Optional[Dict[str, Any]]:
|
|
484
|
+
"""
|
|
485
|
+
Parse LLM response to extract JSON object.
|
|
486
|
+
|
|
487
|
+
If the parser is available, this uses the configured parser.
|
|
488
|
+
Otherwise, uses the robust_json_parse utility with fallback strategies.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
content: LLM response content
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Dict containing parsed JSON or None if parsing fails
|
|
495
|
+
"""
|
|
496
|
+
# First, try schema-enforced parsing if enabled
|
|
497
|
+
if self.config.get("structured_outputs", False):
|
|
498
|
+
parsed = parse_strict_response(content)
|
|
499
|
+
if parsed is not None:
|
|
500
|
+
logger.info("Successfully parsed schema-enforced JSON response.")
|
|
501
|
+
return parsed
|
|
502
|
+
else:
|
|
503
|
+
logger.warning("Schema-enforced parsing failed, falling back to robust parser.")
|
|
504
|
+
|
|
505
|
+
# Use the configured parser if available
|
|
506
|
+
if self.parser:
|
|
507
|
+
return self.parser.parse(content)
|
|
508
|
+
|
|
509
|
+
# Use the robust JSON parser with fallback strategies
|
|
510
|
+
try:
|
|
511
|
+
# Import here to avoid circular imports
|
|
512
|
+
from .utils.json_parser import robust_json_parse, extract_json_debug_info
|
|
513
|
+
|
|
514
|
+
# Define expected keys for validation
|
|
515
|
+
expected_keys = ["tool", "arguments"]
|
|
516
|
+
|
|
517
|
+
# Enable verbose mode based on configuration
|
|
518
|
+
verbose = self._get_config_value('parsing.verbose', False)
|
|
519
|
+
|
|
520
|
+
# Use the robust parser with all strategies
|
|
521
|
+
result = robust_json_parse(content, expected_keys, verbose)
|
|
522
|
+
|
|
523
|
+
# Validate structure if we got a result
|
|
524
|
+
if result and self._validate_parsed_data(result):
|
|
525
|
+
return result
|
|
526
|
+
|
|
527
|
+
# If parsing failed, log debug information
|
|
528
|
+
if verbose and not result:
|
|
529
|
+
debug_info = extract_json_debug_info(content)
|
|
530
|
+
logger.warning(f"JSON parsing failed: {debug_info['identified_issues']}")
|
|
531
|
+
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
except ImportError:
|
|
535
|
+
# Fall back to basic parsing if the json_parser module is unavailable
|
|
536
|
+
logger.warning("Robust JSON parser not available, using basic parsing")
|
|
537
|
+
|
|
538
|
+
# Fix common syntax errors in JSON (missing commas between fields)
|
|
539
|
+
fixed_content = content
|
|
540
|
+
if '{' in content and '}' in content:
|
|
541
|
+
# Extract the JSON-like part
|
|
542
|
+
json_match = re.search(r'({[\s\S]*})', content)
|
|
543
|
+
if json_match:
|
|
544
|
+
json_part = json_match.group(1)
|
|
545
|
+
# Add missing commas between fields
|
|
546
|
+
fixed_json = re.sub(r'"\s+"', '", "', json_part)
|
|
547
|
+
fixed_content = content.replace(json_part, fixed_json)
|
|
548
|
+
|
|
549
|
+
# Basic regex extraction
|
|
550
|
+
json_match = re.search(r'({[\s\S]*})', fixed_content)
|
|
551
|
+
if json_match:
|
|
552
|
+
try:
|
|
553
|
+
data = json.loads(json_match.group(1))
|
|
554
|
+
if self._validate_parsed_data(data):
|
|
555
|
+
return data
|
|
556
|
+
except json.JSONDecodeError:
|
|
557
|
+
# Try again with more aggressive fixing
|
|
558
|
+
try:
|
|
559
|
+
extracted_json = json_match.group(1)
|
|
560
|
+
# Add missing commas between key-value pairs
|
|
561
|
+
fixed_json = re.sub(r'"\s+("?\w+"\s*:)', '", \1', extracted_json)
|
|
562
|
+
data = json.loads(fixed_json)
|
|
563
|
+
if self._validate_parsed_data(data):
|
|
564
|
+
return data
|
|
565
|
+
except (json.JSONDecodeError, Exception):
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
# Try parsing entire content as JSON
|
|
569
|
+
try:
|
|
570
|
+
data = json.loads(fixed_content)
|
|
571
|
+
if self._validate_parsed_data(data):
|
|
572
|
+
return data
|
|
573
|
+
except json.JSONDecodeError:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
# Last resort: Try to extract fields directly with regex
|
|
577
|
+
try:
|
|
578
|
+
# Check for orchestrator assessment format fields
|
|
579
|
+
if "assessment" in content:
|
|
580
|
+
# Extract fields with regex for orchestrator format
|
|
581
|
+
assessment_match = re.search(r'"assessment"\s*:\s*"([^"]+)"', content)
|
|
582
|
+
requires_new_agent_match = re.search(r'"requires_new_agent"\s*:\s*(true|false)', content)
|
|
583
|
+
agent_id_match = re.search(r'"agent_id"\s*:\s*([^,}\s]+)', content)
|
|
584
|
+
reasoning_match = re.search(r'"reasoning"\s*:\s*"([^"]+)"', content)
|
|
585
|
+
|
|
586
|
+
if assessment_match:
|
|
587
|
+
# Construct a minimal valid dict with assessment
|
|
588
|
+
data = {
|
|
589
|
+
"assessment": assessment_match.group(1)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# Add requires_new_agent if found, or infer it
|
|
593
|
+
if requires_new_agent_match:
|
|
594
|
+
data["requires_new_agent"] = requires_new_agent_match.group(1).lower() == "true"
|
|
595
|
+
else:
|
|
596
|
+
data["requires_new_agent"] = data["assessment"] == "create_new"
|
|
597
|
+
|
|
598
|
+
# Add optional fields if found
|
|
599
|
+
if agent_id_match:
|
|
600
|
+
agent_id = agent_id_match.group(1)
|
|
601
|
+
if agent_id.lower() == "null":
|
|
602
|
+
data["agent_id"] = None
|
|
603
|
+
else:
|
|
604
|
+
data["agent_id"] = agent_id.strip('"')
|
|
605
|
+
|
|
606
|
+
if reasoning_match:
|
|
607
|
+
data["reasoning"] = reasoning_match.group(1)
|
|
608
|
+
|
|
609
|
+
return data
|
|
610
|
+
|
|
611
|
+
# Check for tool execution format fields
|
|
612
|
+
elif "tool" in content:
|
|
613
|
+
tool_match = re.search(r'"tool"\s*:\s*"([^"]+)"', content)
|
|
614
|
+
arguments_match = re.search(r'"arguments"\s*:\s*({[^}]+})', content)
|
|
615
|
+
|
|
616
|
+
if tool_match:
|
|
617
|
+
# Construct a minimal valid dict with tool
|
|
618
|
+
data = {
|
|
619
|
+
"tool": tool_match.group(1),
|
|
620
|
+
"arguments": {}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# Add arguments if found
|
|
624
|
+
if arguments_match:
|
|
625
|
+
try:
|
|
626
|
+
arguments = json.loads(arguments_match.group(1))
|
|
627
|
+
data["arguments"] = arguments
|
|
628
|
+
except json.JSONDecodeError:
|
|
629
|
+
# If we can't parse the arguments JSON, use an empty dict
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
return data
|
|
633
|
+
|
|
634
|
+
# If no JSON-like structure is found, but there's text content,
|
|
635
|
+
# assume it's a chat response and wrap it
|
|
636
|
+
if len(content.strip()) > 0:
|
|
637
|
+
return {
|
|
638
|
+
"tool": "chat",
|
|
639
|
+
"arguments": {
|
|
640
|
+
"message": content.strip()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
except Exception as e:
|
|
645
|
+
logger.warning(f"Failed to extract fields from malformed JSON: {str(e)}")
|
|
646
|
+
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
def _validate_parsed_data(self, data: Any) -> bool:
|
|
650
|
+
"""
|
|
651
|
+
Validate that parsed data matches expected structure.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
data: Data to validate
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
True if data is valid, False otherwise
|
|
658
|
+
"""
|
|
659
|
+
if not isinstance(data, dict):
|
|
660
|
+
return False
|
|
661
|
+
|
|
662
|
+
# Accept orchestrator assessment format with more flexible validation
|
|
663
|
+
if "assessment" in data:
|
|
664
|
+
# For this format, only "assessment" is absolutely required
|
|
665
|
+
# "requires_new_agent" can be inferred if missing
|
|
666
|
+
if not isinstance(data["assessment"], str):
|
|
667
|
+
return False
|
|
668
|
+
|
|
669
|
+
# If requires_new_agent is present, it should be boolean
|
|
670
|
+
if "requires_new_agent" in data and not isinstance(data["requires_new_agent"], bool):
|
|
671
|
+
# Try to convert string "true"/"false" to boolean
|
|
672
|
+
if isinstance(data["requires_new_agent"], str):
|
|
673
|
+
try:
|
|
674
|
+
data["requires_new_agent"] = data["requires_new_agent"].lower() == "true"
|
|
675
|
+
except:
|
|
676
|
+
return False
|
|
677
|
+
else:
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
# If requires_new_agent is missing, set a default value
|
|
681
|
+
if "requires_new_agent" not in data:
|
|
682
|
+
data["requires_new_agent"] = data["assessment"] == "create_new"
|
|
683
|
+
|
|
684
|
+
return True
|
|
685
|
+
|
|
686
|
+
# Original tool execution format validation
|
|
687
|
+
if "tool" in data:
|
|
688
|
+
if not isinstance(data["tool"], str):
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
# For tool format, arguments must be present and be a dict
|
|
692
|
+
if "arguments" not in data:
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
if not isinstance(data["arguments"], dict):
|
|
696
|
+
# Try to convert string to dict if it looks like JSON
|
|
697
|
+
if isinstance(data["arguments"], str):
|
|
698
|
+
try:
|
|
699
|
+
import json
|
|
700
|
+
data["arguments"] = json.loads(data["arguments"])
|
|
701
|
+
except:
|
|
702
|
+
return False
|
|
703
|
+
else:
|
|
704
|
+
return False
|
|
705
|
+
|
|
706
|
+
return True
|
|
707
|
+
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
def run(self, query: str, template_path: Optional[str] = None, variables: Optional[Dict[str, Any]] = None, expected_type: Optional[type] = None) -> Any:
|
|
711
|
+
"""Run the Agent with enhanced retry mechanism."""
|
|
712
|
+
logger.info("\n" + "="*50)
|
|
713
|
+
logger.info("Agent Execution")
|
|
714
|
+
logger.info("="*50 + "\n")
|
|
715
|
+
|
|
716
|
+
retry_history = []
|
|
717
|
+
|
|
718
|
+
if not self.get_available_tools():
|
|
719
|
+
logger.warning("No tools available for execution")
|
|
720
|
+
return "No tools available"
|
|
721
|
+
|
|
722
|
+
system_prompt = self._build_system_prompt()
|
|
723
|
+
user_prompt = query
|
|
724
|
+
|
|
725
|
+
# Initialize retry manager for this run
|
|
726
|
+
self.retry_manager = RetryManager(self.config)
|
|
727
|
+
|
|
728
|
+
while self.retry_manager.should_retry():
|
|
729
|
+
temperature, current_model = self.retry_manager.next_attempt()
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
logger.debug(f"[Agent.run] Attempt {self.retry_manager.current_attempt} with model {current_model} and temperature {temperature}")
|
|
733
|
+
# Initialize OpenAI client with configuration
|
|
734
|
+
client = OpenAI(
|
|
735
|
+
base_url=self.base_url,
|
|
736
|
+
api_key=self.api_key,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
messages = [
|
|
740
|
+
{"role": "system", "content": system_prompt},
|
|
741
|
+
{"role": "user", "content": user_prompt}
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
payload = build_openrouter_payload(
|
|
745
|
+
messages=messages,
|
|
746
|
+
config=self.config,
|
|
747
|
+
model=current_model,
|
|
748
|
+
temperature=temperature
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
api_key = os.getenv("OPENROUTER_API_KEY")
|
|
752
|
+
completion = make_openrouter_request(self.config, api_key, payload)
|
|
753
|
+
logger.debug(f"[Agent.run] Raw completion: {repr(completion)}")
|
|
754
|
+
|
|
755
|
+
if not get_choices(completion):
|
|
756
|
+
error_msg = f"Invalid response format - no choices returned"
|
|
757
|
+
logger.debug(f"[Agent.run] {error_msg}")
|
|
758
|
+
retry_history.append({
|
|
759
|
+
"attempt": self.retry_manager.current_attempt,
|
|
760
|
+
"model": current_model,
|
|
761
|
+
"temperature": temperature,
|
|
762
|
+
"error": error_msg
|
|
763
|
+
})
|
|
764
|
+
continue
|
|
765
|
+
|
|
766
|
+
choices = get_choices(completion)
|
|
767
|
+
content = choices[0].message.content if hasattr(choices[0], "message") else choices[0]["message"]["content"]
|
|
768
|
+
logger.debug(f"[Agent.run] Raw LLM content: {repr(content)}")
|
|
769
|
+
parsed = self._parse_response(content)
|
|
770
|
+
logger.debug(f"[Agent.run] Parsed response: {repr(parsed)}")
|
|
771
|
+
|
|
772
|
+
# Output debug information about the response
|
|
773
|
+
logger.info(f"Raw LLM response: {content[:500]}")
|
|
774
|
+
if parsed:
|
|
775
|
+
logger.info(f"Parsed response: {parsed}")
|
|
776
|
+
|
|
777
|
+
if parsed and self._validate_parsed_data(parsed):
|
|
778
|
+
# Handle orchestrator assessment format
|
|
779
|
+
if "assessment" in parsed:
|
|
780
|
+
logger.info(f"Successfully parsed assessment format: {parsed}")
|
|
781
|
+
logger.info("\n" + "="*50)
|
|
782
|
+
logger.info(f"Task completed. Final answer: {parsed}")
|
|
783
|
+
logger.info("="*50 + "\n")
|
|
784
|
+
|
|
785
|
+
# Apply type conversion using utility if requested
|
|
786
|
+
if expected_type:
|
|
787
|
+
logger.warning(f"Expected type '{expected_type.__name__}' requested for assessment result. Conversion not applied.")
|
|
788
|
+
return parsed # Return original parsed dict for assessment
|
|
789
|
+
|
|
790
|
+
# Handle tool execution format
|
|
791
|
+
elif "tool" in parsed and "arguments" in parsed:
|
|
792
|
+
logger.info(f"Successfully parsed tool execution format: {parsed}")
|
|
793
|
+
tool_name, tool_args = parsed['tool'], parsed['arguments']
|
|
794
|
+
result = self.execute_tool_call(tool_name, tool_args)
|
|
795
|
+
logger.info("\n" + "="*50)
|
|
796
|
+
logger.info(f"Task completed. Final answer: {result}")
|
|
797
|
+
logger.info("="*50 + "\n")
|
|
798
|
+
|
|
799
|
+
# Apply type conversion using utility if requested
|
|
800
|
+
final_result = convert_to_expected_type(result, expected_type, logger)
|
|
801
|
+
return final_result
|
|
802
|
+
|
|
803
|
+
# If we couldn't parse the response properly, but it's not empty
|
|
804
|
+
elif content and content.strip():
|
|
805
|
+
# Create a direct assessment response for triage_agent when the query contains keywords related to triage
|
|
806
|
+
if "triage" in query.lower() or any(kw in query.lower() for kw in ["analyze", "task", "categorize", "determine"]):
|
|
807
|
+
logger.warning(f"Converting plain text to triage assessment: {content[:100]}...")
|
|
808
|
+
assessment = "direct" # Default
|
|
809
|
+
|
|
810
|
+
# Try to infer assessment type from content
|
|
811
|
+
if re.search(r'\b(phased|multi-step|research|planning|implementation)\b', content, re.IGNORECASE):
|
|
812
|
+
assessment = "phased"
|
|
813
|
+
elif re.search(r'\b(new agent|specialized|create agent)\b', content, re.IGNORECASE):
|
|
814
|
+
assessment = "create_new"
|
|
815
|
+
|
|
816
|
+
triage_result = {
|
|
817
|
+
"assessment": assessment,
|
|
818
|
+
"requires_new_agent": assessment == "create_new",
|
|
819
|
+
"reasoning": f"Inferred from response: {content[:150]}...",
|
|
820
|
+
"agent_id": "triage" if assessment == "delegate" else None
|
|
821
|
+
}
|
|
822
|
+
logger.info(f"Created triage assessment: {triage_result}")
|
|
823
|
+
return triage_result
|
|
824
|
+
else:
|
|
825
|
+
# For non-triage queries, ensure we pass the content as a message
|
|
826
|
+
logger.warning(f"Response format not recognized as structured JSON, treating as chat: {content[:100]}...")
|
|
827
|
+
logger.info("Returning content as chat response")
|
|
828
|
+
# Ensure we pass a non-empty message
|
|
829
|
+
message = content.strip() if content.strip() else "I apologize, but I couldn't generate a proper response. Could you please rephrase your question?"
|
|
830
|
+
result = self.execute_tool_call("chat", {"message": message})
|
|
831
|
+
logger.info("\n" + "="*50)
|
|
832
|
+
logger.info(f"Task completed. Final answer: {result}")
|
|
833
|
+
logger.info("="*50 + "\n")
|
|
834
|
+
|
|
835
|
+
# Apply type conversion using utility if requested
|
|
836
|
+
final_result = convert_to_expected_type(result, expected_type, logger)
|
|
837
|
+
return final_result
|
|
838
|
+
else:
|
|
839
|
+
# Only count as an error for retry if response is completely empty
|
|
840
|
+
error_msg = f"Invalid response format - {content[:100]}..."
|
|
841
|
+
logger.debug(f"[Agent.run] {error_msg}")
|
|
842
|
+
retry_history.append({
|
|
843
|
+
"attempt": self.retry_manager.current_attempt,
|
|
844
|
+
"model": current_model,
|
|
845
|
+
"temperature": temperature,
|
|
846
|
+
"error": error_msg,
|
|
847
|
+
"raw_content": content[:200]
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
# Use stricter prompt for next attempt
|
|
851
|
+
system_prompt = self._build_retry_prompt()
|
|
852
|
+
|
|
853
|
+
except Exception as e:
|
|
854
|
+
error_msg = f"Attempt failed: {str(e)}"
|
|
855
|
+
logger.debug(f"[Agent.run] Exception: {error_msg}")
|
|
856
|
+
retry_history.append({
|
|
857
|
+
"attempt": self.retry_manager.current_attempt,
|
|
858
|
+
"model": current_model,
|
|
859
|
+
"temperature": temperature,
|
|
860
|
+
"error": error_msg
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
# If we've exhausted all retries, create a fallback result
|
|
864
|
+
fallback_result = self.execute_tool_call("chat", {
|
|
865
|
+
"message": "I couldn't understand how to process your request. Could you please rephrase it?"
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
# Apply type conversion using utility if requested (unlikely to succeed here)
|
|
869
|
+
final_result = convert_to_expected_type(fallback_result, expected_type, logger)
|
|
870
|
+
# Still raise the exception after processing fallback
|
|
871
|
+
|
|
872
|
+
raise AgentRetryExceeded(
|
|
873
|
+
f"Failed to get valid response after {self.retry_manager.current_attempt} attempts",
|
|
874
|
+
history=retry_history
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def get_llm(model: Optional[str] = None) -> Callable[[str], str]:
|
|
879
|
+
"""
|
|
880
|
+
Get a callable LLM instance that can be used by other modules.
|
|
881
|
+
|
|
882
|
+
Args:
|
|
883
|
+
model: Optional model name to use
|
|
884
|
+
|
|
885
|
+
Returns:
|
|
886
|
+
A callable function that takes a prompt string and returns a response string
|
|
887
|
+
|
|
888
|
+
Raises:
|
|
889
|
+
ConfigurationError: If API key is missing or if base_url is not configured
|
|
890
|
+
"""
|
|
891
|
+
# Try to load environment variables from project root
|
|
892
|
+
try:
|
|
893
|
+
from dotenv import load_dotenv
|
|
894
|
+
env_path = os.getenv("TINYAGENT_ENV")
|
|
895
|
+
if not env_path:
|
|
896
|
+
cwd_env = os.path.join(os.getcwd(), '.env')
|
|
897
|
+
if os.path.isfile(cwd_env):
|
|
898
|
+
env_path = cwd_env
|
|
899
|
+
else:
|
|
900
|
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
901
|
+
env_path = os.path.join(project_root, '.env')
|
|
902
|
+
load_dotenv(env_path)
|
|
903
|
+
logger.debug(f"Loaded environment variables from {env_path}")
|
|
904
|
+
except ImportError:
|
|
905
|
+
logger.warning("python-dotenv not available, skipping .env loading")
|
|
906
|
+
|
|
907
|
+
api_key = os.getenv("OPENROUTER_API_KEY")
|
|
908
|
+
if not api_key:
|
|
909
|
+
raise ConfigurationError("OPENROUTER_API_KEY must be set in .env")
|
|
910
|
+
|
|
911
|
+
# Get model and base_url from config
|
|
912
|
+
try:
|
|
913
|
+
from tinyagent.config import load_config, get_config_value
|
|
914
|
+
config = load_config()
|
|
915
|
+
if not config:
|
|
916
|
+
raise ConfigurationError("config.yml not found")
|
|
917
|
+
|
|
918
|
+
if 'base_url' not in config:
|
|
919
|
+
raise ConfigurationError("base_url must be set in config.yml")
|
|
920
|
+
|
|
921
|
+
base_url = config['base_url']
|
|
922
|
+
model = model or get_config_value(config, 'model.default', "deepseek/deepseek-chat")
|
|
923
|
+
|
|
924
|
+
except ImportError:
|
|
925
|
+
raise ConfigurationError("Failed to load configuration. Make sure config.yml exists and contains base_url")
|
|
926
|
+
|
|
927
|
+
# Initialize OpenAI client with configuration
|
|
928
|
+
client = OpenAI(
|
|
929
|
+
base_url=base_url,
|
|
930
|
+
api_key=api_key,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def llm_call(prompt: str) -> str:
|
|
934
|
+
"""Call the LLM with a prompt and return the response."""
|
|
935
|
+
try:
|
|
936
|
+
completion = client.chat.completions.create(
|
|
937
|
+
extra_headers={
|
|
938
|
+
"HTTP-Referer": "https://tinyagent.xyz",
|
|
939
|
+
},
|
|
940
|
+
model=model,
|
|
941
|
+
messages=[{"role": "user", "content": prompt}],
|
|
942
|
+
temperature=0.7,
|
|
943
|
+
max_tokens=2000
|
|
944
|
+
)
|
|
945
|
+
return completion.choices[0].message.content
|
|
946
|
+
except Exception as e:
|
|
947
|
+
logger.error(f"Error calling LLM: {str(e)}")
|
|
948
|
+
return f"Error: {str(e)}"
|
|
949
|
+
|
|
950
|
+
return llm_call
|
|
951
|
+
|
|
952
|
+
def tiny_agent(tools=None, model=None):
|
|
953
|
+
"""
|
|
954
|
+
Simplified alias to create an Agent using AgentFactory with given tools and optional model.
|
|
955
|
+
"""
|
|
956
|
+
from .factory.agent_factory import AgentFactory
|
|
957
|
+
return AgentFactory.get_instance().create_agent(tools=tools, model=model)
|