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.
Files changed (64) hide show
  1. tiny_agent_os-0.0.1.dist-info/METADATA +377 -0
  2. tiny_agent_os-0.0.1.dist-info/RECORD +64 -0
  3. tiny_agent_os-0.0.1.dist-info/WHEEL +5 -0
  4. tiny_agent_os-0.0.1.dist-info/entry_points.txt +2 -0
  5. tiny_agent_os-0.0.1.dist-info/licenses/LICENSE +53 -0
  6. tiny_agent_os-0.0.1.dist-info/top_level.txt +1 -0
  7. tinyagent/__init__.py +75 -0
  8. tinyagent/_version.py +21 -0
  9. tinyagent/agent.py +957 -0
  10. tinyagent/chat/__init__.py +12 -0
  11. tinyagent/chat/chat_mode.py +291 -0
  12. tinyagent/cli/__init__.py +16 -0
  13. tinyagent/cli/colors.py +104 -0
  14. tinyagent/cli/main.py +664 -0
  15. tinyagent/cli/spinner.py +94 -0
  16. tinyagent/cli.py +47 -0
  17. tinyagent/config/__init__.py +14 -0
  18. tinyagent/config/config.py +258 -0
  19. tinyagent/decorators.py +187 -0
  20. tinyagent/exceptions.py +85 -0
  21. tinyagent/factory/__init__.py +18 -0
  22. tinyagent/factory/agent_factory.py +439 -0
  23. tinyagent/factory/dynamic_agent_factory.py +561 -0
  24. tinyagent/factory/orchestrator.py +1514 -0
  25. tinyagent/factory/tiny_chain.py +552 -0
  26. tinyagent/logging.py +97 -0
  27. tinyagent/mcp/__init__.py +14 -0
  28. tinyagent/mcp/manager.py +321 -0
  29. tinyagent/prompts/README.md +133 -0
  30. tinyagent/prompts/default.md +14 -0
  31. tinyagent/prompts/prompt_manager.py +206 -0
  32. tinyagent/prompts/system/agent.md +50 -0
  33. tinyagent/prompts/system/retry.md +55 -0
  34. tinyagent/prompts/system/strict_json.md +54 -0
  35. tinyagent/prompts/system.md +10 -0
  36. tinyagent/prompts/tools/calculator.md +13 -0
  37. tinyagent/prompts/tools/weather.md +7 -0
  38. tinyagent/prompts/workflows/riv_reflect.md +62 -0
  39. tinyagent/prompts/workflows/riv_verify.md +47 -0
  40. tinyagent/prompts/workflows/triage.md +129 -0
  41. tinyagent/tool.py +185 -0
  42. tinyagent/tools/README.md +391 -0
  43. tinyagent/tools/__init__.py +39 -0
  44. tinyagent/tools/aider.py +122 -0
  45. tinyagent/tools/anon_coder.py +296 -0
  46. tinyagent/tools/boilerplate_tool.py +147 -0
  47. tinyagent/tools/brave_search.py +104 -0
  48. tinyagent/tools/codeagent_tool.py +217 -0
  49. tinyagent/tools/content_processor.py +285 -0
  50. tinyagent/tools/custom_text_browser.py +965 -0
  51. tinyagent/tools/duckduckgo_search.py +153 -0
  52. tinyagent/tools/external.py +303 -0
  53. tinyagent/tools/file_manipulator.py +274 -0
  54. tinyagent/tools/final_extractor_tool.py +249 -0
  55. tinyagent/tools/llm_serializer.py +124 -0
  56. tinyagent/tools/markdown_gen.py +300 -0
  57. tinyagent/tools/ripgrep.py +136 -0
  58. tinyagent/utils/__init__.py +13 -0
  59. tinyagent/utils/json_parser.py +231 -0
  60. tinyagent/utils/logging_utils.py +78 -0
  61. tinyagent/utils/openrouter_request.py +123 -0
  62. tinyagent/utils/serialization.py +185 -0
  63. tinyagent/utils/structured_outputs.py +131 -0
  64. 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)