tinyagent-py 0.0.4__py3-none-any.whl → 0.0.6__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.
- tinyagent/__init__.py +4 -0
- tinyagent/mcp_client.py +162 -0
- tinyagent/tiny_agent.py +1003 -0
- {tinyagent_py-0.0.4.dist-info → tinyagent_py-0.0.6.dist-info}/METADATA +19 -3
- tinyagent_py-0.0.6.dist-info/RECORD +20 -0
- tinyagent_py-0.0.6.dist-info/top_level.txt +1 -0
- tinyagent_py-0.0.4.dist-info/RECORD +0 -17
- tinyagent_py-0.0.4.dist-info/top_level.txt +0 -2
- {hooks → tinyagent/hooks}/__init__.py +0 -0
- {hooks → tinyagent/hooks}/agno_storage_hook.py +0 -0
- {hooks → tinyagent/hooks}/gradio_callback.py +0 -0
- {hooks → tinyagent/hooks}/logging_manager.py +0 -0
- {hooks → tinyagent/hooks}/rich_ui_callback.py +0 -0
- {storage → tinyagent/storage}/__init__.py +0 -0
- {storage → tinyagent/storage}/agno_storage.py +0 -0
- {storage → tinyagent/storage}/base.py +0 -0
- {storage → tinyagent/storage}/json_file_storage.py +0 -0
- {storage → tinyagent/storage}/postgres_storage.py +0 -0
- {storage → tinyagent/storage}/redis_storage.py +0 -0
- {storage → tinyagent/storage}/sqlite_storage.py +0 -0
- {tinyagent_py-0.0.4.dist-info → tinyagent_py-0.0.6.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.4.dist-info → tinyagent_py-0.0.6.dist-info}/licenses/LICENSE +0 -0
tinyagent/__init__.py
ADDED
tinyagent/mcp_client.py
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
from typing import Dict, List, Optional, Any, Tuple, Callable
|
5
|
+
|
6
|
+
# Keep your MCPClient implementation unchanged
|
7
|
+
import asyncio
|
8
|
+
from contextlib import AsyncExitStack
|
9
|
+
|
10
|
+
# MCP core imports
|
11
|
+
from mcp import ClientSession, StdioServerParameters
|
12
|
+
from mcp.client.stdio import stdio_client
|
13
|
+
|
14
|
+
# Set up logging
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
class MCPClient:
|
18
|
+
def __init__(self, logger: Optional[logging.Logger] = None):
|
19
|
+
self.session = None
|
20
|
+
self.exit_stack = AsyncExitStack()
|
21
|
+
self.logger = logger or logging.getLogger(__name__)
|
22
|
+
|
23
|
+
# Simplified callback system
|
24
|
+
self.callbacks: List[callable] = []
|
25
|
+
|
26
|
+
self.logger.debug("MCPClient initialized")
|
27
|
+
|
28
|
+
def add_callback(self, callback: callable) -> None:
|
29
|
+
"""
|
30
|
+
Add a callback function to the client.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
callback: A function that accepts (event_name, client, **kwargs)
|
34
|
+
"""
|
35
|
+
self.callbacks.append(callback)
|
36
|
+
|
37
|
+
async def _run_callbacks(self, event_name: str, **kwargs) -> None:
|
38
|
+
"""
|
39
|
+
Run all registered callbacks for an event.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
event_name: The name of the event
|
43
|
+
**kwargs: Additional data for the event
|
44
|
+
"""
|
45
|
+
for callback in self.callbacks:
|
46
|
+
try:
|
47
|
+
logger.debug(f"Running callback: {callback}")
|
48
|
+
if asyncio.iscoroutinefunction(callback):
|
49
|
+
logger.debug(f"Callback is a coroutine function")
|
50
|
+
await callback(event_name, self, **kwargs)
|
51
|
+
else:
|
52
|
+
# Check if the callback is a class with an async __call__ method
|
53
|
+
if hasattr(callback, '__call__') and asyncio.iscoroutinefunction(callback.__call__):
|
54
|
+
logger.debug(f"Callback is a class with an async __call__ method")
|
55
|
+
await callback(event_name, self, **kwargs)
|
56
|
+
else:
|
57
|
+
logger.debug(f"Callback is a regular function")
|
58
|
+
callback(event_name, self, **kwargs)
|
59
|
+
except Exception as e:
|
60
|
+
logger.error(f"Error in callback for {event_name}: {str(e)}")
|
61
|
+
|
62
|
+
async def connect(self, command: str, args: list[str]):
|
63
|
+
"""
|
64
|
+
Launches the MCP server subprocess and initializes the client session.
|
65
|
+
:param command: e.g. "python" or "node"
|
66
|
+
:param args: list of args to pass, e.g. ["my_server.py"] or ["build/index.js"]
|
67
|
+
"""
|
68
|
+
# Prepare stdio transport parameters
|
69
|
+
params = StdioServerParameters(command=command, args=args)
|
70
|
+
# Open the stdio client transport
|
71
|
+
self.stdio, self.sock_write = await self.exit_stack.enter_async_context(
|
72
|
+
stdio_client(params)
|
73
|
+
)
|
74
|
+
# Create and initialize the MCP client session
|
75
|
+
self.session = await self.exit_stack.enter_async_context(
|
76
|
+
ClientSession(self.stdio, self.sock_write)
|
77
|
+
)
|
78
|
+
await self.session.initialize()
|
79
|
+
|
80
|
+
async def list_tools(self):
|
81
|
+
resp = await self.session.list_tools()
|
82
|
+
print("Available tools:")
|
83
|
+
for tool in resp.tools:
|
84
|
+
print(f" • {tool.name}: {tool.description}")
|
85
|
+
|
86
|
+
async def call_tool(self, name: str, arguments: dict):
|
87
|
+
"""
|
88
|
+
Invokes a named tool and returns its raw content list.
|
89
|
+
"""
|
90
|
+
# Notify tool start
|
91
|
+
await self._run_callbacks("tool_start", tool_name=name, arguments=arguments)
|
92
|
+
|
93
|
+
try:
|
94
|
+
resp = await self.session.call_tool(name, arguments)
|
95
|
+
|
96
|
+
# Notify tool end
|
97
|
+
await self._run_callbacks("tool_end", tool_name=name, arguments=arguments,
|
98
|
+
result=resp.content, success=True)
|
99
|
+
|
100
|
+
return resp.content
|
101
|
+
except Exception as e:
|
102
|
+
# Notify tool end with error
|
103
|
+
await self._run_callbacks("tool_end", tool_name=name, arguments=arguments,
|
104
|
+
error=str(e), success=False)
|
105
|
+
raise
|
106
|
+
|
107
|
+
async def close(self):
|
108
|
+
"""Clean up subprocess and streams."""
|
109
|
+
if self.exit_stack:
|
110
|
+
try:
|
111
|
+
await self.exit_stack.aclose()
|
112
|
+
except (RuntimeError, asyncio.CancelledError) as e:
|
113
|
+
# Log the error but don't re-raise it
|
114
|
+
self.logger.error(f"Error during client cleanup: {e}")
|
115
|
+
finally:
|
116
|
+
# Always reset these regardless of success or failure
|
117
|
+
self.session = None
|
118
|
+
self.exit_stack = AsyncExitStack()
|
119
|
+
|
120
|
+
async def run_example():
|
121
|
+
"""Example usage of MCPClient with proper logging."""
|
122
|
+
import sys
|
123
|
+
from tinyagent.hooks.logging_manager import LoggingManager
|
124
|
+
|
125
|
+
# Create and configure logging manager
|
126
|
+
log_manager = LoggingManager(default_level=logging.INFO)
|
127
|
+
log_manager.set_levels({
|
128
|
+
'tinyagent.mcp_client': logging.DEBUG, # Debug for this module
|
129
|
+
'tinyagent.tiny_agent': logging.INFO,
|
130
|
+
})
|
131
|
+
|
132
|
+
# Configure a console handler
|
133
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
134
|
+
log_manager.configure_handler(
|
135
|
+
console_handler,
|
136
|
+
format_string='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
137
|
+
level=logging.DEBUG
|
138
|
+
)
|
139
|
+
|
140
|
+
# Get module-specific logger
|
141
|
+
mcp_logger = log_manager.get_logger('tinyagent.mcp_client')
|
142
|
+
|
143
|
+
mcp_logger.debug("Starting MCPClient example")
|
144
|
+
|
145
|
+
# Create client with our logger
|
146
|
+
client = MCPClient(logger=mcp_logger)
|
147
|
+
|
148
|
+
try:
|
149
|
+
# Connect to a simple echo server
|
150
|
+
await client.connect("python", ["-m", "mcp.examples.echo_server"])
|
151
|
+
|
152
|
+
# List available tools
|
153
|
+
await client.list_tools()
|
154
|
+
|
155
|
+
# Call the echo tool
|
156
|
+
result = await client.call_tool("echo", {"message": "Hello, MCP!"})
|
157
|
+
mcp_logger.info(f"Echo result: {result}")
|
158
|
+
|
159
|
+
finally:
|
160
|
+
# Clean up
|
161
|
+
await client.close()
|
162
|
+
mcp_logger.debug("Example completed")
|
tinyagent/tiny_agent.py
ADDED
@@ -0,0 +1,1003 @@
|
|
1
|
+
# Import LiteLLM for model interaction
|
2
|
+
import litellm
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
from typing import Dict, List, Optional, Any, Tuple, Callable, Union, Type, get_type_hints
|
6
|
+
from .mcp_client import MCPClient
|
7
|
+
import asyncio
|
8
|
+
import tiktoken # Add tiktoken import for token counting
|
9
|
+
import inspect
|
10
|
+
import functools
|
11
|
+
import uuid
|
12
|
+
from .storage import Storage # ← your abstract base
|
13
|
+
import traceback
|
14
|
+
import time # Add time import for Unix timestamps
|
15
|
+
# Module-level logger; configuration is handled externally.
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
#litellm.callbacks = ["arize_phoenix"]
|
18
|
+
|
19
|
+
def tool(name: Optional[str] = None, description: Optional[str] = None,
|
20
|
+
schema: Optional[Dict[str, Any]] = None):
|
21
|
+
"""
|
22
|
+
Decorator to convert a Python function or class into a tool for TinyAgent.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
name: Optional custom name for the tool (defaults to function/class name)
|
26
|
+
description: Optional description (defaults to function/class docstring)
|
27
|
+
schema: Optional JSON schema for the tool parameters (auto-generated if not provided)
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
Decorated function or class with tool metadata
|
31
|
+
"""
|
32
|
+
def decorator(func_or_class):
|
33
|
+
# Determine if we're decorating a function or class
|
34
|
+
is_class = inspect.isclass(func_or_class)
|
35
|
+
|
36
|
+
# Get the name (use provided name or function/class name)
|
37
|
+
tool_name = name or func_or_class.__name__
|
38
|
+
|
39
|
+
# Get the description (use provided description or docstring)
|
40
|
+
tool_description = description or inspect.getdoc(func_or_class) or f"Tool based on {tool_name}"
|
41
|
+
|
42
|
+
# Generate schema if not provided
|
43
|
+
tool_schema = schema or {}
|
44
|
+
if not tool_schema:
|
45
|
+
if is_class:
|
46
|
+
# For classes, look at the __init__ method
|
47
|
+
init_method = func_or_class.__init__
|
48
|
+
tool_schema = _generate_schema_from_function(init_method)
|
49
|
+
else:
|
50
|
+
# For functions, use the function itself
|
51
|
+
tool_schema = _generate_schema_from_function(func_or_class)
|
52
|
+
|
53
|
+
# Attach metadata to the function or class
|
54
|
+
func_or_class._tool_metadata = {
|
55
|
+
"name": tool_name,
|
56
|
+
"description": tool_description,
|
57
|
+
"schema": tool_schema,
|
58
|
+
"is_class": is_class
|
59
|
+
}
|
60
|
+
|
61
|
+
return func_or_class
|
62
|
+
|
63
|
+
return decorator
|
64
|
+
|
65
|
+
def _generate_schema_from_function(func: Callable) -> Dict[str, Any]:
|
66
|
+
"""
|
67
|
+
Generate a JSON schema for a function based on its signature and type hints.
|
68
|
+
|
69
|
+
Args:
|
70
|
+
func: The function to analyze
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
A JSON schema object for the function parameters
|
74
|
+
"""
|
75
|
+
# Get function signature and type hints
|
76
|
+
sig = inspect.signature(func)
|
77
|
+
type_hints = get_type_hints(func)
|
78
|
+
|
79
|
+
# Skip 'self' parameter for methods
|
80
|
+
params = {
|
81
|
+
name: param for name, param in sig.parameters.items()
|
82
|
+
if name != 'self' and name != 'cls'
|
83
|
+
}
|
84
|
+
|
85
|
+
# Build properties dictionary
|
86
|
+
properties = {}
|
87
|
+
required = []
|
88
|
+
|
89
|
+
for name, param in params.items():
|
90
|
+
# Get parameter type
|
91
|
+
param_type = type_hints.get(name, Any)
|
92
|
+
|
93
|
+
# Create property schema
|
94
|
+
prop_schema = {"description": ""}
|
95
|
+
|
96
|
+
# Map Python types to JSON schema types
|
97
|
+
if param_type == str:
|
98
|
+
prop_schema["type"] = "string"
|
99
|
+
elif param_type == int:
|
100
|
+
prop_schema["type"] = "integer"
|
101
|
+
elif param_type == float:
|
102
|
+
prop_schema["type"] = "number"
|
103
|
+
elif param_type == bool:
|
104
|
+
prop_schema["type"] = "boolean"
|
105
|
+
elif param_type == list or param_type == List:
|
106
|
+
prop_schema["type"] = "array"
|
107
|
+
elif param_type == dict or param_type == Dict:
|
108
|
+
prop_schema["type"] = "object"
|
109
|
+
else:
|
110
|
+
prop_schema["type"] = "string" # Default to string for complex types
|
111
|
+
|
112
|
+
properties[name] = prop_schema
|
113
|
+
|
114
|
+
# Check if parameter is required
|
115
|
+
if param.default == inspect.Parameter.empty:
|
116
|
+
required.append(name)
|
117
|
+
|
118
|
+
# Build the final schema
|
119
|
+
schema = {
|
120
|
+
"type": "object",
|
121
|
+
"properties": properties
|
122
|
+
}
|
123
|
+
|
124
|
+
if required:
|
125
|
+
schema["required"] = required
|
126
|
+
|
127
|
+
return schema
|
128
|
+
|
129
|
+
DEFAULT_SYSTEM_PROMPT = (
|
130
|
+
"You are a helpful AI assistant with access to a variety of tools. "
|
131
|
+
"Use the tools when appropriate to accomplish tasks. "
|
132
|
+
"If a tool you need isn't available, just say so."
|
133
|
+
)
|
134
|
+
|
135
|
+
class TinyAgent:
|
136
|
+
"""
|
137
|
+
A minimal implementation of an agent powered by MCP and LiteLLM,
|
138
|
+
now with session/state persistence.
|
139
|
+
"""
|
140
|
+
session_state: Dict[str, Any] = {}
|
141
|
+
user_id: Optional[str] = None
|
142
|
+
session_id: Optional[str] = None
|
143
|
+
|
144
|
+
def __init__(
|
145
|
+
self,
|
146
|
+
model: str = "gpt-4.1-mini",
|
147
|
+
api_key: Optional[str] = None,
|
148
|
+
system_prompt: Optional[str] = None,
|
149
|
+
temperature: float = 0.0,
|
150
|
+
logger: Optional[logging.Logger] = None,
|
151
|
+
model_kwargs: Optional[Dict[str, Any]] = {},
|
152
|
+
*,
|
153
|
+
user_id: Optional[str] = None,
|
154
|
+
session_id: Optional[str] = None,
|
155
|
+
metadata: Optional[Dict[str, Any]] = None,
|
156
|
+
storage: Optional[Storage] = None,
|
157
|
+
persist_tool_configs: bool = False
|
158
|
+
):
|
159
|
+
"""
|
160
|
+
Initialize the Tiny Agent.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
model: The model to use with LiteLLM
|
164
|
+
api_key: The API key for the model provider
|
165
|
+
system_prompt: Custom system prompt for the agent
|
166
|
+
logger: Optional logger to use
|
167
|
+
session_id: Optional session ID (if provided with storage, will attempt to load existing session)
|
168
|
+
metadata: Optional metadata for the session
|
169
|
+
storage: Optional storage backend for persistence
|
170
|
+
persist_tool_configs: Whether to persist tool configurations
|
171
|
+
"""
|
172
|
+
# Set up logger
|
173
|
+
self.logger = logger or logging.getLogger(__name__)
|
174
|
+
|
175
|
+
# Instead of a single MCPClient, keep multiple:
|
176
|
+
self.mcp_clients: List[MCPClient] = []
|
177
|
+
# Map from tool_name -> MCPClient instance
|
178
|
+
self.tool_to_client: Dict[str, MCPClient] = {}
|
179
|
+
|
180
|
+
# Simplified hook system - single list of callbacks
|
181
|
+
self.callbacks: List[callable] = []
|
182
|
+
|
183
|
+
# LiteLLM configuration
|
184
|
+
self.model = model
|
185
|
+
self.api_key = api_key
|
186
|
+
self.temperature = temperature
|
187
|
+
if model in ["o1", "o1-preview","o3","o4-mini"]:
|
188
|
+
self.temperature = 1
|
189
|
+
if api_key:
|
190
|
+
litellm.api_key = api_key
|
191
|
+
|
192
|
+
self.model_kwargs = model_kwargs
|
193
|
+
self.encoder = tiktoken.get_encoding("o200k_base")
|
194
|
+
|
195
|
+
# Conversation state
|
196
|
+
self.messages = [{
|
197
|
+
"role": "system",
|
198
|
+
"content": system_prompt or DEFAULT_SYSTEM_PROMPT
|
199
|
+
}]
|
200
|
+
|
201
|
+
# This list now accumulates tools from *all* connected MCP servers:
|
202
|
+
self.available_tools: List[Dict[str, Any]] = []
|
203
|
+
|
204
|
+
# Control flow tools
|
205
|
+
self.exit_loop_tools = [
|
206
|
+
{
|
207
|
+
"type": "function",
|
208
|
+
"function": {
|
209
|
+
"name": "final_answer",
|
210
|
+
"description": "Call this tool when the task given by the user is complete",
|
211
|
+
"parameters": {"type": "object", "properties": {"content": {
|
212
|
+
"type": "string",
|
213
|
+
"description": "Your final answer to the user's problem, user only sees the content of this field. "
|
214
|
+
}}}
|
215
|
+
,
|
216
|
+
"required": ["content"]
|
217
|
+
}
|
218
|
+
},
|
219
|
+
{
|
220
|
+
"type": "function",
|
221
|
+
"function": {
|
222
|
+
"name": "ask_question",
|
223
|
+
"description": "Ask a question to the user to get more info required to solve or clarify their problem.",
|
224
|
+
"parameters": {
|
225
|
+
"type": "object",
|
226
|
+
"properties": {
|
227
|
+
"question": {
|
228
|
+
"type": "string",
|
229
|
+
"description": "The question to ask the user"
|
230
|
+
}
|
231
|
+
},
|
232
|
+
"required": ["question"]
|
233
|
+
}
|
234
|
+
}
|
235
|
+
}
|
236
|
+
]
|
237
|
+
|
238
|
+
# Add a list to store custom tools (functions and classes)
|
239
|
+
self.custom_tools: List[Dict[str, Any]] = []
|
240
|
+
self.custom_tool_handlers: Dict[str, Any] = {}
|
241
|
+
# 1) User and session management
|
242
|
+
self.user_id = user_id or self._generate_session_id()
|
243
|
+
self.session_id = session_id or self._generate_session_id()
|
244
|
+
# build default metadata
|
245
|
+
default_md = {
|
246
|
+
"model": model,
|
247
|
+
"temperature": temperature,
|
248
|
+
**(model_kwargs or {}),
|
249
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
250
|
+
}
|
251
|
+
self.metadata = metadata or default_md
|
252
|
+
self.metadata.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
253
|
+
|
254
|
+
# 2) Storage is attached immediately for auto‐saving, but loading is deferred:
|
255
|
+
self.storage = storage
|
256
|
+
self.persist_tool_configs = persist_tool_configs
|
257
|
+
# only a flag — no blocking or hidden runs in __init__
|
258
|
+
self._needs_session_load = bool(self.storage and session_id)
|
259
|
+
|
260
|
+
if self.storage:
|
261
|
+
# register auto‐save on llm_end
|
262
|
+
self.storage.attach(self)
|
263
|
+
|
264
|
+
self.logger.debug(f"TinyAgent initialized (session={self.session_id})")
|
265
|
+
|
266
|
+
# register our usage‐merging hook
|
267
|
+
self.add_callback(self._on_llm_end)
|
268
|
+
|
269
|
+
def _generate_session_id(self) -> str:
|
270
|
+
"""Produce a unique session identifier."""
|
271
|
+
return str(uuid.uuid4())
|
272
|
+
|
273
|
+
def count_tokens(self, text: str) -> int:
|
274
|
+
"""Count tokens in a string using tiktoken."""
|
275
|
+
if not self.encoder or not text:
|
276
|
+
return 0
|
277
|
+
try:
|
278
|
+
return len(self.encoder.encode(text))
|
279
|
+
except Exception as e:
|
280
|
+
self.logger.error(f"Error counting tokens: {e}")
|
281
|
+
return 0
|
282
|
+
|
283
|
+
async def save_agent(self) -> None:
|
284
|
+
"""Persist our full serialized state via the configured Storage."""
|
285
|
+
if not self.storage:
|
286
|
+
self.logger.warning("No storage configured; skipping save.")
|
287
|
+
return
|
288
|
+
data = self.to_dict()
|
289
|
+
await self.storage.save_session(self.session_id, data, self.user_id)
|
290
|
+
self.logger.info(f"Agent state saved for session={self.session_id}")
|
291
|
+
|
292
|
+
async def _on_llm_end(self, event_name: str, agent: "TinyAgent", **kwargs) -> None:
|
293
|
+
"""
|
294
|
+
Callback hook: after each LLM call, accumulate *all* fields from
|
295
|
+
litellm's response.usage into our metadata and persist.
|
296
|
+
"""
|
297
|
+
if event_name != "llm_end":
|
298
|
+
return
|
299
|
+
|
300
|
+
response = kwargs.get("response")
|
301
|
+
if response and hasattr(response, "usage") and isinstance(response.usage, dict):
|
302
|
+
usage = response.usage
|
303
|
+
bucket = self.metadata.setdefault(
|
304
|
+
"usage", {}
|
305
|
+
)
|
306
|
+
# Merge every key from the LLM usage (prompt_tokens, completion_tokens,
|
307
|
+
# total_tokens, maybe cost, etc.)
|
308
|
+
for field, value in usage.items():
|
309
|
+
try:
|
310
|
+
# only aggregate numeric fields
|
311
|
+
bucket[field] = bucket.get(field, 0) + int(value)
|
312
|
+
except (ValueError, TypeError):
|
313
|
+
# fallback: overwrite or store as-is
|
314
|
+
bucket[field] = value
|
315
|
+
|
316
|
+
# persist after each LLM call
|
317
|
+
await self.save_agent()
|
318
|
+
|
319
|
+
def to_dict(self) -> Dict[str, Any]:
|
320
|
+
"""
|
321
|
+
Serialize session_id, metadata, and a user‐extensible session_state.
|
322
|
+
"""
|
323
|
+
# start from user's own session_state
|
324
|
+
session_data = dict(self.session_state)
|
325
|
+
# always include the conversation
|
326
|
+
session_data["messages"] = self.messages
|
327
|
+
|
328
|
+
# optionally include tools
|
329
|
+
if self.persist_tool_configs:
|
330
|
+
serialized = []
|
331
|
+
for cfg in getattr(self, "_tool_configs_for_serialization", []):
|
332
|
+
if cfg["type"] == "tiny_agent":
|
333
|
+
serialized.append({
|
334
|
+
"type": "tiny_agent",
|
335
|
+
"state": cfg["state_func"]()
|
336
|
+
})
|
337
|
+
else:
|
338
|
+
serialized.append(cfg)
|
339
|
+
session_data["tool_configs"] = serialized
|
340
|
+
|
341
|
+
return {
|
342
|
+
"session_id": self.session_id,
|
343
|
+
"metadata": self.metadata,
|
344
|
+
"session_state": session_data
|
345
|
+
}
|
346
|
+
|
347
|
+
@classmethod
|
348
|
+
def from_dict(
|
349
|
+
cls,
|
350
|
+
data: Dict[str, Any],
|
351
|
+
*,
|
352
|
+
logger: Optional[logging.Logger] = None,
|
353
|
+
tool_registry: Optional[Dict[str, Any]] = None,
|
354
|
+
storage: Optional[Storage] = None
|
355
|
+
) -> "TinyAgent":
|
356
|
+
"""
|
357
|
+
Rehydrate a TinyAgent from JSON state.
|
358
|
+
"""
|
359
|
+
session_id = data["session_id"]
|
360
|
+
metadata = data.get("metadata", {})
|
361
|
+
state_blob = data.get("session_state", {})
|
362
|
+
|
363
|
+
# core config
|
364
|
+
model = metadata.get("model", "gpt-4.1-mini")
|
365
|
+
temperature = metadata.get("temperature", 0.0)
|
366
|
+
# everything else except model/temperature/usage → model_kwargs
|
367
|
+
model_kwargs = {k:v for k,v in metadata.items() if k not in ("model","temperature","usage")}
|
368
|
+
|
369
|
+
# instantiate (tools* not yet reconstructed)
|
370
|
+
agent = cls(
|
371
|
+
model=model,
|
372
|
+
api_key=None,
|
373
|
+
system_prompt=None,
|
374
|
+
temperature=temperature,
|
375
|
+
logger=logger,
|
376
|
+
model_kwargs=model_kwargs,
|
377
|
+
session_id=session_id,
|
378
|
+
metadata=metadata,
|
379
|
+
storage=storage,
|
380
|
+
persist_tool_configs=False # default off
|
381
|
+
)
|
382
|
+
|
383
|
+
# Apply the session data directly instead of loading from storage
|
384
|
+
agent._needs_session_load = False
|
385
|
+
agent._apply_session_data(data)
|
386
|
+
|
387
|
+
# rebuild tools if we persisted them
|
388
|
+
agent.tool_registry = tool_registry or {}
|
389
|
+
for tcfg in state_blob.get("tool_configs", []):
|
390
|
+
agent._reconstruct_tool(tcfg)
|
391
|
+
|
392
|
+
return agent
|
393
|
+
|
394
|
+
def add_callback(self, callback: callable) -> None:
|
395
|
+
"""
|
396
|
+
Add a callback function to the agent.
|
397
|
+
|
398
|
+
Args:
|
399
|
+
callback: A function that accepts (event_name, agent, **kwargs)
|
400
|
+
"""
|
401
|
+
self.callbacks.append(callback)
|
402
|
+
|
403
|
+
async def _run_callbacks(self, event_name: str, **kwargs) -> None:
|
404
|
+
"""
|
405
|
+
Run all registered callbacks for an event.
|
406
|
+
|
407
|
+
Args:
|
408
|
+
event_name: The name of the event
|
409
|
+
**kwargs: Additional data for the event
|
410
|
+
"""
|
411
|
+
for callback in self.callbacks:
|
412
|
+
try:
|
413
|
+
self.logger.debug(f"Running callback: {callback}")
|
414
|
+
if asyncio.iscoroutinefunction(callback):
|
415
|
+
self.logger.debug(f"Callback is a coroutine function")
|
416
|
+
await callback(event_name, self, **kwargs)
|
417
|
+
else:
|
418
|
+
# Check if the callback is a class with an async __call__ method
|
419
|
+
if hasattr(callback, '__call__') and asyncio.iscoroutinefunction(callback.__call__):
|
420
|
+
self.logger.debug(f"Callback is a class with an async __call__ method")
|
421
|
+
await callback(event_name, self, **kwargs)
|
422
|
+
else:
|
423
|
+
self.logger.debug(f"Callback is a regular function")
|
424
|
+
callback(event_name, self, **kwargs)
|
425
|
+
except Exception as e:
|
426
|
+
self.logger.error(f"Error in callback for {event_name}: {str(e)}")
|
427
|
+
|
428
|
+
async def connect_to_server(self, command: str, args: List[str],
|
429
|
+
include_tools: Optional[List[str]] = None,
|
430
|
+
exclude_tools: Optional[List[str]] = None) -> None:
|
431
|
+
"""
|
432
|
+
Connect to an MCP server and fetch available tools.
|
433
|
+
|
434
|
+
Args:
|
435
|
+
command: The command to run the server
|
436
|
+
args: List of arguments for the server
|
437
|
+
include_tools: Optional list of tool name patterns to include (if provided, only matching tools will be added)
|
438
|
+
exclude_tools: Optional list of tool name patterns to exclude (matching tools will be skipped)
|
439
|
+
"""
|
440
|
+
# 1) Create and connect a brand-new client
|
441
|
+
client = MCPClient()
|
442
|
+
|
443
|
+
# Pass our callbacks to the client
|
444
|
+
for callback in self.callbacks:
|
445
|
+
client.add_callback(callback)
|
446
|
+
|
447
|
+
await client.connect(command, args)
|
448
|
+
self.mcp_clients.append(client)
|
449
|
+
|
450
|
+
# 2) List tools on *this* server
|
451
|
+
resp = await client.session.list_tools()
|
452
|
+
|
453
|
+
# 3) For each tool, record its schema + map name->client
|
454
|
+
added_tools = 0
|
455
|
+
for tool in resp.tools:
|
456
|
+
# Apply filtering logic
|
457
|
+
tool_name = tool.name
|
458
|
+
|
459
|
+
# Skip if not in include list (when include list is provided)
|
460
|
+
if include_tools and not any(pattern in tool_name for pattern in include_tools):
|
461
|
+
self.logger.debug(f"Skipping tool {tool_name} - not in include list")
|
462
|
+
continue
|
463
|
+
|
464
|
+
# Skip if in exclude list
|
465
|
+
if exclude_tools and any(pattern in tool_name for pattern in exclude_tools):
|
466
|
+
self.logger.debug(f"Skipping tool {tool_name} - matched exclude pattern")
|
467
|
+
continue
|
468
|
+
|
469
|
+
fn_meta = {
|
470
|
+
"type": "function",
|
471
|
+
"function": {
|
472
|
+
"name": tool.name,
|
473
|
+
"description": tool.description,
|
474
|
+
"parameters": tool.inputSchema
|
475
|
+
}
|
476
|
+
}
|
477
|
+
self.available_tools.append(fn_meta)
|
478
|
+
self.tool_to_client[tool.name] = client
|
479
|
+
added_tools += 1
|
480
|
+
|
481
|
+
self.logger.info(f"Connected to {command} {args!r}, added {added_tools} tools (filtered from {len(resp.tools)} available)")
|
482
|
+
self.logger.debug(f"{command} {args!r} Available tools: {self.available_tools}")
|
483
|
+
|
484
|
+
def add_tool(self, tool_func_or_class: Any) -> None:
|
485
|
+
"""
|
486
|
+
Add a custom tool (function or class) to the agent.
|
487
|
+
|
488
|
+
Args:
|
489
|
+
tool_func_or_class: A function or class decorated with @tool
|
490
|
+
"""
|
491
|
+
# Check if the tool has the required metadata
|
492
|
+
if not hasattr(tool_func_or_class, '_tool_metadata'):
|
493
|
+
raise ValueError("Tool must be decorated with @tool decorator")
|
494
|
+
|
495
|
+
metadata = tool_func_or_class._tool_metadata
|
496
|
+
|
497
|
+
# Create tool schema
|
498
|
+
tool_schema = {
|
499
|
+
"type": "function",
|
500
|
+
"function": {
|
501
|
+
"name": metadata["name"],
|
502
|
+
"description": metadata["description"],
|
503
|
+
"parameters": metadata["schema"]
|
504
|
+
}
|
505
|
+
}
|
506
|
+
|
507
|
+
# Add to available tools
|
508
|
+
self.custom_tools.append(tool_schema)
|
509
|
+
self.available_tools.append(tool_schema)
|
510
|
+
|
511
|
+
# Store the handler (function or class)
|
512
|
+
self.custom_tool_handlers[metadata["name"]] = tool_func_or_class
|
513
|
+
|
514
|
+
self.logger.info(f"Added custom tool: {metadata['name']}")
|
515
|
+
|
516
|
+
def add_tools(self, tools: List[Any]) -> None:
|
517
|
+
"""
|
518
|
+
Add multiple custom tools to the agent.
|
519
|
+
|
520
|
+
Args:
|
521
|
+
tools: List of functions or classes decorated with @tool
|
522
|
+
"""
|
523
|
+
for tool_func_or_class in tools:
|
524
|
+
self.add_tool(tool_func_or_class)
|
525
|
+
|
526
|
+
async def _execute_custom_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
|
527
|
+
"""
|
528
|
+
Execute a custom tool and return its result.
|
529
|
+
|
530
|
+
Args:
|
531
|
+
tool_name: Name of the tool to execute
|
532
|
+
tool_args: Arguments for the tool
|
533
|
+
|
534
|
+
Returns:
|
535
|
+
String result from the tool
|
536
|
+
"""
|
537
|
+
handler = self.custom_tool_handlers.get(tool_name)
|
538
|
+
if not handler:
|
539
|
+
return f"Error: Tool '{tool_name}' not found"
|
540
|
+
|
541
|
+
try:
|
542
|
+
# Check if it's a class or function
|
543
|
+
metadata = handler._tool_metadata
|
544
|
+
|
545
|
+
if metadata["is_class"]:
|
546
|
+
# Instantiate the class and call it
|
547
|
+
instance = handler(**tool_args)
|
548
|
+
if hasattr(instance, "__call__"):
|
549
|
+
result = instance()
|
550
|
+
else:
|
551
|
+
result = instance
|
552
|
+
else:
|
553
|
+
# Call the function directly
|
554
|
+
result = handler(**tool_args)
|
555
|
+
|
556
|
+
# Handle async functions
|
557
|
+
if asyncio.iscoroutine(result):
|
558
|
+
result = await result
|
559
|
+
|
560
|
+
return str(result)
|
561
|
+
except Exception as e:
|
562
|
+
self.logger.error(f"Error executing custom tool {tool_name}: {str(e)}")
|
563
|
+
return f"Error executing tool {tool_name}: {str(e)}"
|
564
|
+
|
565
|
+
async def run(self, user_input: str, max_turns: int = 10) -> str:
|
566
|
+
# ----------------------------------------------------------------
|
567
|
+
# Ensure any deferred session‐load happens exactly once
|
568
|
+
# ----------------------------------------------------------------
|
569
|
+
if self._needs_session_load:
|
570
|
+
self.logger.debug(f"Deferred session load detected for {self.session_id}; loading now")
|
571
|
+
await self.init_async()
|
572
|
+
|
573
|
+
# ----------------------------------------------------------------
|
574
|
+
# Now proceed with the normal agent loop
|
575
|
+
# ----------------------------------------------------------------
|
576
|
+
|
577
|
+
# Notify start
|
578
|
+
await self._run_callbacks("agent_start", user_input=user_input)
|
579
|
+
|
580
|
+
# Add user message to conversation with timestamp
|
581
|
+
user_message = {
|
582
|
+
"role": "user",
|
583
|
+
"content": user_input,
|
584
|
+
"created_at": int(time.time())
|
585
|
+
}
|
586
|
+
self.messages.append(user_message)
|
587
|
+
await self._run_callbacks("message_add", message=self.messages[-1])
|
588
|
+
|
589
|
+
# Initialize loop control variables
|
590
|
+
num_turns = 0
|
591
|
+
next_turn_should_call_tools = True
|
592
|
+
|
593
|
+
# The main agent loop
|
594
|
+
while True:
|
595
|
+
# Get all available tools including exit loop tools
|
596
|
+
all_tools = self.available_tools + self.exit_loop_tools
|
597
|
+
|
598
|
+
# Call LLM with messages and tools
|
599
|
+
try:
|
600
|
+
self.logger.info(f"Calling LLM with {len(self.messages)} messages and {len(all_tools)} tools")
|
601
|
+
|
602
|
+
# Notify LLM start
|
603
|
+
await self._run_callbacks("llm_start", messages=self.messages, tools=all_tools)
|
604
|
+
|
605
|
+
response = await litellm.acompletion(
|
606
|
+
model=self.model,
|
607
|
+
messages=self.messages,
|
608
|
+
tools=all_tools,
|
609
|
+
tool_choice="auto",
|
610
|
+
temperature=self.temperature,
|
611
|
+
**self.model_kwargs
|
612
|
+
)
|
613
|
+
|
614
|
+
# Notify LLM end
|
615
|
+
await self._run_callbacks("llm_end", response=response)
|
616
|
+
|
617
|
+
# Process the response - properly handle the object
|
618
|
+
response_message = response.choices[0].message
|
619
|
+
self.logger.debug(f"🔥🔥🔥🔥🔥🔥 Response : {response_message}")
|
620
|
+
|
621
|
+
# Extract both content and any tool_calls
|
622
|
+
content = getattr(response_message, "content", "") or ""
|
623
|
+
tool_calls = getattr(response_message, "tool_calls", []) or []
|
624
|
+
has_tool_calls = bool(tool_calls)
|
625
|
+
|
626
|
+
# Now emit the "assistant" message that carries the function call (or, if no calls, the content)
|
627
|
+
if has_tool_calls:
|
628
|
+
assistant_msg = {
|
629
|
+
"role": "assistant",
|
630
|
+
"content": content, # split off above
|
631
|
+
"tool_calls": tool_calls,
|
632
|
+
"created_at": int(time.time())
|
633
|
+
}
|
634
|
+
else:
|
635
|
+
assistant_msg = {
|
636
|
+
"role": "assistant",
|
637
|
+
"content": content,
|
638
|
+
"created_at": int(time.time())
|
639
|
+
}
|
640
|
+
self.messages.append(assistant_msg)
|
641
|
+
await self._run_callbacks("message_add", message=assistant_msg)
|
642
|
+
|
643
|
+
# Process tool calls if they exist
|
644
|
+
if has_tool_calls:
|
645
|
+
self.logger.info(f"Tool calls detected: {len(tool_calls)}")
|
646
|
+
|
647
|
+
# Process each tool call one by one
|
648
|
+
for tool_call in tool_calls:
|
649
|
+
tool_call_id = tool_call.id
|
650
|
+
function_info = tool_call.function
|
651
|
+
tool_name = function_info.name
|
652
|
+
|
653
|
+
# Create a tool message
|
654
|
+
tool_message = {
|
655
|
+
"role": "tool",
|
656
|
+
"tool_call_id": tool_call_id,
|
657
|
+
"name": tool_name,
|
658
|
+
"content": "", # Default empty content
|
659
|
+
"created_at": int(time.time())
|
660
|
+
}
|
661
|
+
|
662
|
+
try:
|
663
|
+
# Parse tool arguments
|
664
|
+
try:
|
665
|
+
tool_args = json.loads(function_info.arguments)
|
666
|
+
except json.JSONDecodeError:
|
667
|
+
self.logger.error(f"Could not parse tool arguments: {function_info.arguments}")
|
668
|
+
tool_args = {}
|
669
|
+
|
670
|
+
# Handle control flow tools
|
671
|
+
if tool_name == "final_answer":
|
672
|
+
# Add a response for this tool call before returning
|
673
|
+
tool_message["content"] = tool_args.get("content", "Task completed without final answer.!!!")
|
674
|
+
self.messages.append(tool_message)
|
675
|
+
await self._run_callbacks("message_add", message=tool_message)
|
676
|
+
await self._run_callbacks("agent_end", result="Task completed.")
|
677
|
+
return tool_message["content"]
|
678
|
+
elif tool_name == "ask_question":
|
679
|
+
question = tool_args.get("question", "Could you provide more details?")
|
680
|
+
# Add a response for this tool call before returning
|
681
|
+
tool_message["content"] = f"Question asked: {question}"
|
682
|
+
self.messages.append(tool_message)
|
683
|
+
await self._run_callbacks("message_add", message=tool_message)
|
684
|
+
await self._run_callbacks("agent_end", result=f"I need more information: {question}")
|
685
|
+
return f"I need more information: {question}"
|
686
|
+
else:
|
687
|
+
# Check if it's a custom tool first
|
688
|
+
if tool_name in self.custom_tool_handlers:
|
689
|
+
tool_message["content"] = await self._execute_custom_tool(tool_name, tool_args)
|
690
|
+
else:
|
691
|
+
# Dispatch to the proper MCPClient
|
692
|
+
client = self.tool_to_client.get(tool_name)
|
693
|
+
if not client:
|
694
|
+
tool_message["content"] = f"No MCP server registered for tool '{tool_name}'"
|
695
|
+
else:
|
696
|
+
try:
|
697
|
+
self.logger.debug(f"Calling tool {tool_name} with args: {tool_args}")
|
698
|
+
self.logger.debug(f"Client: {client}")
|
699
|
+
content_list = await client.call_tool(tool_name, tool_args)
|
700
|
+
self.logger.debug(f"Tool {tool_name} returned: {content_list}")
|
701
|
+
# Safely extract text from the content
|
702
|
+
if content_list:
|
703
|
+
# Try different ways to extract the content
|
704
|
+
if hasattr(content_list[0], 'text'):
|
705
|
+
tool_message["content"] = content_list[0].text
|
706
|
+
elif isinstance(content_list[0], dict) and 'text' in content_list[0]:
|
707
|
+
tool_message["content"] = content_list[0]['text']
|
708
|
+
else:
|
709
|
+
tool_message["content"] = str(content_list)
|
710
|
+
else:
|
711
|
+
tool_message["content"] = "Tool returned no content"
|
712
|
+
except Exception as e:
|
713
|
+
self.logger.error(f"Error calling tool {tool_name}: {str(e)}")
|
714
|
+
tool_message["content"] = f"Error executing tool {tool_name}: {str(e)}"
|
715
|
+
except Exception as e:
|
716
|
+
# If any error occurs during tool call processing, make sure we still have a tool response
|
717
|
+
self.logger.error(f"Unexpected error processing tool call {tool_call_id}: {str(e)}")
|
718
|
+
tool_message["content"] = f"Error processing tool call: {str(e)}"
|
719
|
+
|
720
|
+
# Always add the tool message to ensure each tool call has a response
|
721
|
+
self.messages.append(tool_message)
|
722
|
+
await self._run_callbacks("message_add", message=tool_message)
|
723
|
+
|
724
|
+
next_turn_should_call_tools = False
|
725
|
+
else:
|
726
|
+
# No tool calls in this message
|
727
|
+
if num_turns > 0:
|
728
|
+
#if next_turn_should_call_tools and num_turns > 0:
|
729
|
+
# If we expected tool calls but didn't get any, we're done
|
730
|
+
await self._run_callbacks("agent_end", result=assistant_msg["content"] or "")
|
731
|
+
return assistant_msg["content"] or ""
|
732
|
+
|
733
|
+
next_turn_should_call_tools = True
|
734
|
+
|
735
|
+
num_turns += 1
|
736
|
+
if num_turns >= max_turns:
|
737
|
+
result = "Max turns reached. Task incomplete."
|
738
|
+
await self._run_callbacks("agent_end", result=result)
|
739
|
+
return result
|
740
|
+
|
741
|
+
except Exception as e:
|
742
|
+
self.logger.error(f"Error in agent loop: {str(e)}")
|
743
|
+
result = f"Error: {str(e)}"
|
744
|
+
await self._run_callbacks("agent_end", result=result, error=str(e))
|
745
|
+
return result
|
746
|
+
|
747
|
+
|
748
|
+
async def close(self):
|
749
|
+
"""
|
750
|
+
Clean up all resources used by the agent including MCP clients and storage.
|
751
|
+
|
752
|
+
This method should be called when the agent is no longer needed to ensure
|
753
|
+
proper resource cleanup, especially in web frameworks like FastAPI.
|
754
|
+
"""
|
755
|
+
cleanup_errors = []
|
756
|
+
|
757
|
+
# 1. First save any pending state if storage is configured
|
758
|
+
if self.storage:
|
759
|
+
try:
|
760
|
+
self.logger.debug(f"Saving final state before closing (session={self.session_id})")
|
761
|
+
await self.save_agent()
|
762
|
+
except Exception as e:
|
763
|
+
error_msg = f"Error saving final state: {str(e)}"
|
764
|
+
self.logger.error(error_msg)
|
765
|
+
cleanup_errors.append(error_msg)
|
766
|
+
|
767
|
+
# 2. Close all MCP clients
|
768
|
+
for client in self.mcp_clients:
|
769
|
+
try:
|
770
|
+
self.logger.debug(f"Closing MCP client: {client}")
|
771
|
+
await client.close()
|
772
|
+
except Exception as e:
|
773
|
+
error_msg = f"Error closing MCP client: {str(e)}"
|
774
|
+
self.logger.error(error_msg)
|
775
|
+
cleanup_errors.append(error_msg)
|
776
|
+
|
777
|
+
# 3. Close storage connection if available
|
778
|
+
if self.storage:
|
779
|
+
try:
|
780
|
+
self.logger.debug("Closing storage connection")
|
781
|
+
await self.storage.close()
|
782
|
+
except Exception as e:
|
783
|
+
error_msg = f"Error closing storage: {str(e)}"
|
784
|
+
self.logger.error(error_msg)
|
785
|
+
cleanup_errors.append(error_msg)
|
786
|
+
|
787
|
+
# 4. Run any cleanup callbacks
|
788
|
+
try:
|
789
|
+
await self._run_callbacks("agent_cleanup")
|
790
|
+
except Exception as e:
|
791
|
+
error_msg = f"Error in cleanup callbacks: {str(e)}"
|
792
|
+
self.logger.error(error_msg)
|
793
|
+
cleanup_errors.append(error_msg)
|
794
|
+
|
795
|
+
# Log summary of cleanup
|
796
|
+
if cleanup_errors:
|
797
|
+
self.logger.warning(f"TinyAgent cleanup completed with {len(cleanup_errors)} errors")
|
798
|
+
else:
|
799
|
+
self.logger.info(f"TinyAgent cleanup completed successfully (session={self.session_id})")
|
800
|
+
|
801
|
+
def clear_conversation(self):
|
802
|
+
"""
|
803
|
+
Clear the conversation history, preserving only the initial system prompt.
|
804
|
+
"""
|
805
|
+
if self.messages:
|
806
|
+
system_msg = self.messages[0]
|
807
|
+
else:
|
808
|
+
# Rebuild a default system prompt if somehow missing
|
809
|
+
default_sys = {
|
810
|
+
"role": "system",
|
811
|
+
"content": (
|
812
|
+
"You are a helpful AI assistant with access to a variety of tools. "
|
813
|
+
"Use the tools when appropriate to accomplish tasks. "
|
814
|
+
"If a tool you need isn't available, just say so."
|
815
|
+
)
|
816
|
+
}
|
817
|
+
system_msg = default_sys
|
818
|
+
self.messages = [system_msg]
|
819
|
+
self.logger.info("TinyAgent conversation history cleared.")
|
820
|
+
|
821
|
+
def as_tool(self, name: Optional[str] = None, description: Optional[str] = None) -> Dict[str, Any]:
|
822
|
+
"""
|
823
|
+
Convert this TinyAgent instance into a tool that can be used by another TinyAgent.
|
824
|
+
|
825
|
+
Args:
|
826
|
+
name: Optional custom name for the tool (defaults to "TinyAgentTool")
|
827
|
+
description: Optional description (defaults to a generic description)
|
828
|
+
|
829
|
+
Returns:
|
830
|
+
A tool function that can be added to another TinyAgent
|
831
|
+
"""
|
832
|
+
tool_name = name or f"TinyAgentTool_{id(self)}"
|
833
|
+
tool_description = description or f"A tool that uses a TinyAgent with model {self.model} to solve tasks"
|
834
|
+
|
835
|
+
@tool(name=tool_name, description=tool_description)
|
836
|
+
async def agent_tool(query: str, max_turns: int = 5) -> str:
|
837
|
+
"""
|
838
|
+
Run this TinyAgent with the given query.
|
839
|
+
|
840
|
+
Args:
|
841
|
+
query: The task or question to process
|
842
|
+
max_turns: Maximum number of turns (default: 5)
|
843
|
+
|
844
|
+
Returns:
|
845
|
+
The agent's response
|
846
|
+
"""
|
847
|
+
return await self.run(query, max_turns=max_turns)
|
848
|
+
|
849
|
+
return agent_tool
|
850
|
+
|
851
|
+
async def init_async(self) -> "TinyAgent":
|
852
|
+
"""
|
853
|
+
Load session data from storage if flagged. Safe to call only once.
|
854
|
+
"""
|
855
|
+
if not self._needs_session_load:
|
856
|
+
return self
|
857
|
+
|
858
|
+
try:
|
859
|
+
data = await self.storage.load_session(self.session_id, self.user_id)
|
860
|
+
if data:
|
861
|
+
self.logger.info(f"Resuming session {self.session_id}")
|
862
|
+
self._apply_session_data(data)
|
863
|
+
else:
|
864
|
+
self.logger.info(f"No existing session for {self.session_id}")
|
865
|
+
except Exception as e:
|
866
|
+
self.logger.error(f"Failed to load session {self.session_id}: {traceback.format_exc()}")
|
867
|
+
finally:
|
868
|
+
self._needs_session_load = False
|
869
|
+
|
870
|
+
return self
|
871
|
+
|
872
|
+
@classmethod
|
873
|
+
async def create(
|
874
|
+
cls,
|
875
|
+
model: str = "gpt-4.1-mini",
|
876
|
+
api_key: Optional[str] = None,
|
877
|
+
system_prompt: Optional[str] = None,
|
878
|
+
temperature: float = 0.0,
|
879
|
+
logger: Optional[logging.Logger] = None,
|
880
|
+
model_kwargs: Optional[Dict[str, Any]] = {},
|
881
|
+
*,
|
882
|
+
user_id: Optional[str] = None,
|
883
|
+
session_id: Optional[str] = None,
|
884
|
+
metadata: Optional[Dict[str, Any]] = None,
|
885
|
+
storage: Optional[Storage] = None,
|
886
|
+
persist_tool_configs: bool = False
|
887
|
+
) -> "TinyAgent":
|
888
|
+
"""
|
889
|
+
Async factory: constructs the agent, then loads an existing session
|
890
|
+
if (storage and session_id) were provided.
|
891
|
+
"""
|
892
|
+
agent = cls(
|
893
|
+
model=model,
|
894
|
+
api_key=api_key,
|
895
|
+
system_prompt=system_prompt,
|
896
|
+
temperature=temperature,
|
897
|
+
logger=logger,
|
898
|
+
model_kwargs=model_kwargs,
|
899
|
+
user_id=user_id,
|
900
|
+
session_id=session_id,
|
901
|
+
metadata=metadata,
|
902
|
+
storage=storage,
|
903
|
+
persist_tool_configs=persist_tool_configs
|
904
|
+
)
|
905
|
+
if agent._needs_session_load:
|
906
|
+
await agent.init_async()
|
907
|
+
return agent
|
908
|
+
|
909
|
+
def _apply_session_data(self, data: Dict[str, Any]) -> None:
|
910
|
+
"""
|
911
|
+
Apply loaded session data to this agent instance.
|
912
|
+
|
913
|
+
Args:
|
914
|
+
data: Session data dictionary from storage
|
915
|
+
"""
|
916
|
+
# Update metadata (preserving model and temperature from constructor)
|
917
|
+
if "metadata" in data:
|
918
|
+
# Keep original model/temperature/api_key but merge everything else
|
919
|
+
stored_metadata = data["metadata"]
|
920
|
+
for key, value in stored_metadata.items():
|
921
|
+
if key not in ("model", "temperature"): # Don't override these
|
922
|
+
self.metadata[key] = value
|
923
|
+
|
924
|
+
# Load session state
|
925
|
+
if "session_state" in data:
|
926
|
+
state_blob = data["session_state"]
|
927
|
+
|
928
|
+
# Restore conversation history
|
929
|
+
if "messages" in state_blob:
|
930
|
+
self.messages = state_blob["messages"]
|
931
|
+
|
932
|
+
# Restore other session state
|
933
|
+
for key, value in state_blob.items():
|
934
|
+
if key != "messages" and key != "tool_configs":
|
935
|
+
self.session_state[key] = value
|
936
|
+
|
937
|
+
# Tool configs would be handled separately if needed
|
938
|
+
|
939
|
+
async def run_example():
|
940
|
+
"""Example usage of TinyAgent with proper logging."""
|
941
|
+
import os
|
942
|
+
import sys
|
943
|
+
from tinyagent.hooks.logging_manager import LoggingManager
|
944
|
+
from tinyagent.hooks.rich_ui_callback import RichUICallback
|
945
|
+
|
946
|
+
# Create and configure logging manager
|
947
|
+
log_manager = LoggingManager(default_level=logging.INFO)
|
948
|
+
log_manager.set_levels({
|
949
|
+
'tinyagent.tiny_agent': logging.DEBUG, # Debug for this module
|
950
|
+
'tinyagent.mcp_client': logging.INFO,
|
951
|
+
'tinyagent.hooks.rich_ui_callback': logging.INFO,
|
952
|
+
})
|
953
|
+
|
954
|
+
# Configure a console handler
|
955
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
956
|
+
log_manager.configure_handler(
|
957
|
+
console_handler,
|
958
|
+
format_string='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
959
|
+
level=logging.DEBUG
|
960
|
+
)
|
961
|
+
|
962
|
+
# Get module-specific loggers
|
963
|
+
agent_logger = log_manager.get_logger('tinyagent.tiny_agent')
|
964
|
+
ui_logger = log_manager.get_logger('tinyagent.hooks.rich_ui_callback')
|
965
|
+
mcp_logger = log_manager.get_logger('tinyagent.mcp_client')
|
966
|
+
|
967
|
+
agent_logger.debug("Starting TinyAgent example")
|
968
|
+
|
969
|
+
# Get API key from environment
|
970
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
971
|
+
if not api_key:
|
972
|
+
agent_logger.error("Please set the OPENAI_API_KEY environment variable")
|
973
|
+
return
|
974
|
+
|
975
|
+
# Initialize the agent with our logger
|
976
|
+
agent = await TinyAgent.create(
|
977
|
+
model="gpt-4.1-mini",
|
978
|
+
api_key=api_key,
|
979
|
+
logger=agent_logger,
|
980
|
+
session_id="my-session-123",
|
981
|
+
storage=None
|
982
|
+
)
|
983
|
+
|
984
|
+
# Add the Rich UI callback with our logger
|
985
|
+
rich_ui = RichUICallback(
|
986
|
+
markdown=True,
|
987
|
+
show_message=True,
|
988
|
+
show_thinking=True,
|
989
|
+
show_tool_calls=True,
|
990
|
+
logger=ui_logger
|
991
|
+
)
|
992
|
+
agent.add_callback(rich_ui)
|
993
|
+
|
994
|
+
# Run the agent with a user query
|
995
|
+
user_input = "What is the capital of France?"
|
996
|
+
agent_logger.info(f"Running agent with input: {user_input}")
|
997
|
+
result = await agent.run(user_input)
|
998
|
+
|
999
|
+
agent_logger.info(f"Final result: {result}")
|
1000
|
+
|
1001
|
+
# Clean up
|
1002
|
+
await agent.close()
|
1003
|
+
agent_logger.debug("Example completed")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tinyagent-py
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.6
|
4
4
|
Summary: Tiny Agent with MCP Client
|
5
5
|
Author-email: Mahdi Golchin <golchin@askdev.ai>
|
6
6
|
Project-URL: Homepage, https://github.com/askbudi/tinyagent
|
@@ -31,11 +31,17 @@ Requires-Dist: aiosqlite>=0.18.0; extra == "all"
|
|
31
31
|
Requires-Dist: gradio>=3.50.0; extra == "all"
|
32
32
|
Dynamic: license-file
|
33
33
|
|
34
|
-
#
|
35
|
-
Tiny Agent: 100 lines Agent with MCP
|
34
|
+
# TinyAgent
|
35
|
+
Tiny Agent: 100 lines Agent with MCP and extendable hook system
|
36
|
+
|
37
|
+
[](https://askdev.ai/github/askbudi/tinyagent)
|
38
|
+
|
39
|
+
|
36
40
|

|
37
41
|
|
38
42
|
|
43
|
+
[](https://askdev.ai/github/askbudi/tinyagent)
|
44
|
+
|
39
45
|
|
40
46
|
Inspired by:
|
41
47
|
- [Tiny Agents blog post](https://huggingface.co/blog/tiny-agents)
|
@@ -91,6 +97,8 @@ uv pip install tinyagent-py[dev]
|
|
91
97
|
```
|
92
98
|
|
93
99
|
## Usage
|
100
|
+
[](https://askdev.ai/github/askbudi/tinyagent)
|
101
|
+
|
94
102
|
|
95
103
|
```python
|
96
104
|
from tinyagent import TinyAgent
|
@@ -239,6 +247,14 @@ if __name__ == "__main__":
|
|
239
247
|
```
|
240
248
|
---
|
241
249
|
|
250
|
+
## Build your own TinyAgent
|
251
|
+
|
252
|
+
You can chat with TinyAgent and build your own TinyAgent for your use case.
|
253
|
+
|
254
|
+
[](https://askdev.ai/github/askbudi/tinyagent)
|
255
|
+
|
256
|
+
---
|
257
|
+
|
242
258
|
## Contributing Hooks
|
243
259
|
|
244
260
|
- Place new hooks in the `tinyagent/hooks/` directory.
|
@@ -0,0 +1,20 @@
|
|
1
|
+
tinyagent/__init__.py,sha256=GrD21npMQGzl9ZYKYTP8VxHLzCfJCvA0oTKQZTkmnCw,117
|
2
|
+
tinyagent/mcp_client.py,sha256=9dmLtJ8CTwKWKTH6K9z8CaCQuaicOH9ifAuNyX7Kdo0,6030
|
3
|
+
tinyagent/tiny_agent.py,sha256=PW_mY0uqKqzVN3ANWauF0RmvXNobR_EoPjGv40m1oKw,41074
|
4
|
+
tinyagent/hooks/__init__.py,sha256=UztCHjoqF5JyDolbWwkBsBZkWguDQg23l2GD_zMHt-s,178
|
5
|
+
tinyagent/hooks/agno_storage_hook.py,sha256=5qvvjmtraanPa-A46Zstrqq3s1e-sC7Ly0o3zifuw_4,5003
|
6
|
+
tinyagent/hooks/gradio_callback.py,sha256=jGsZlObAd6I5lN9cE53dDL_LfiB8I0tBsicuHwwmL-M,44833
|
7
|
+
tinyagent/hooks/logging_manager.py,sha256=UpdmpQ7HRPyer-jrmQSXcBwi409tV9LnGvXSHjTcYTI,7935
|
8
|
+
tinyagent/hooks/rich_ui_callback.py,sha256=5iCNOiJmhc1lOL7ZjaOt5Sk3rompko4zu_pAxfTVgJQ,22897
|
9
|
+
tinyagent/storage/__init__.py,sha256=NebvYxwEGJtvPnRO9dGa-bgOwA7cPkLjFHnMWDxMg5I,261
|
10
|
+
tinyagent/storage/agno_storage.py,sha256=ol4qwdH-9jYjBjDvsYkHh7I-vu8uHArPtQylUpoEaCc,4322
|
11
|
+
tinyagent/storage/base.py,sha256=GGAMvOoslmm1INLFG_jtwOkRk2Qg39QXx-1LnN7fxDI,1474
|
12
|
+
tinyagent/storage/json_file_storage.py,sha256=SYD8lvTHu2-FEHm1tZmsrcgEOirBrlUsUM186X-UPgI,1114
|
13
|
+
tinyagent/storage/postgres_storage.py,sha256=IGwan8UXHNnTZFK1F8x4kvMDex3GAAGWUg9ePx_5IF4,9018
|
14
|
+
tinyagent/storage/redis_storage.py,sha256=hu3y7wHi49HkpiR-AW7cWVQuTVOUk1WaB8TEPGUKVJ8,1742
|
15
|
+
tinyagent/storage/sqlite_storage.py,sha256=7lk1XZpr2t4s2bjVr9-AqrI74w4hwkuK3taWtyJZhBc,5769
|
16
|
+
tinyagent_py-0.0.6.dist-info/licenses/LICENSE,sha256=YIogcVQnknaaE4K-oaQylFWo8JGRBWnwmGb3fWB_Pww,1064
|
17
|
+
tinyagent_py-0.0.6.dist-info/METADATA,sha256=rL8hpf-PC0CBRcoyDEe71okWlzLi9SEln03ZHRs_ikI,9054
|
18
|
+
tinyagent_py-0.0.6.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
19
|
+
tinyagent_py-0.0.6.dist-info/top_level.txt,sha256=Ny8aJNchZpc2Vvhp3306L5vjceJakvFxBk-UjjVeA_I,10
|
20
|
+
tinyagent_py-0.0.6.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
tinyagent
|
@@ -1,17 +0,0 @@
|
|
1
|
-
hooks/__init__.py,sha256=UztCHjoqF5JyDolbWwkBsBZkWguDQg23l2GD_zMHt-s,178
|
2
|
-
hooks/agno_storage_hook.py,sha256=5qvvjmtraanPa-A46Zstrqq3s1e-sC7Ly0o3zifuw_4,5003
|
3
|
-
hooks/gradio_callback.py,sha256=jGsZlObAd6I5lN9cE53dDL_LfiB8I0tBsicuHwwmL-M,44833
|
4
|
-
hooks/logging_manager.py,sha256=UpdmpQ7HRPyer-jrmQSXcBwi409tV9LnGvXSHjTcYTI,7935
|
5
|
-
hooks/rich_ui_callback.py,sha256=5iCNOiJmhc1lOL7ZjaOt5Sk3rompko4zu_pAxfTVgJQ,22897
|
6
|
-
storage/__init__.py,sha256=NebvYxwEGJtvPnRO9dGa-bgOwA7cPkLjFHnMWDxMg5I,261
|
7
|
-
storage/agno_storage.py,sha256=ol4qwdH-9jYjBjDvsYkHh7I-vu8uHArPtQylUpoEaCc,4322
|
8
|
-
storage/base.py,sha256=GGAMvOoslmm1INLFG_jtwOkRk2Qg39QXx-1LnN7fxDI,1474
|
9
|
-
storage/json_file_storage.py,sha256=SYD8lvTHu2-FEHm1tZmsrcgEOirBrlUsUM186X-UPgI,1114
|
10
|
-
storage/postgres_storage.py,sha256=IGwan8UXHNnTZFK1F8x4kvMDex3GAAGWUg9ePx_5IF4,9018
|
11
|
-
storage/redis_storage.py,sha256=hu3y7wHi49HkpiR-AW7cWVQuTVOUk1WaB8TEPGUKVJ8,1742
|
12
|
-
storage/sqlite_storage.py,sha256=7lk1XZpr2t4s2bjVr9-AqrI74w4hwkuK3taWtyJZhBc,5769
|
13
|
-
tinyagent_py-0.0.4.dist-info/licenses/LICENSE,sha256=YIogcVQnknaaE4K-oaQylFWo8JGRBWnwmGb3fWB_Pww,1064
|
14
|
-
tinyagent_py-0.0.4.dist-info/METADATA,sha256=MDvRoleb36ya8z44BxQvtgSFJ_-WfH5kv7eSWeaMdJQ,8254
|
15
|
-
tinyagent_py-0.0.4.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
16
|
-
tinyagent_py-0.0.4.dist-info/top_level.txt,sha256=PfpFqZliMhzue7YU7RrBiZGoAqVBPr9sRc310dWabug,14
|
17
|
-
tinyagent_py-0.0.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|