clap-agents 0.1.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.
@@ -0,0 +1,229 @@
1
+
2
+ import json
3
+ import inspect
4
+ import functools # Import functools
5
+ from typing import Callable, Any
6
+ import anyio
7
+ import jsonschema
8
+
9
+
10
+ def get_fn_signature(fn: Callable) -> dict:
11
+ """
12
+ Generates the signature (schema) for a given function in JSON Schema format.
13
+
14
+ Args:
15
+ fn (Callable): The function whose signature needs to be extracted.
16
+
17
+ Returns:
18
+ dict: A dictionary representing the function's schema.
19
+ """
20
+
21
+ type_mapping = {
22
+ "int": "integer",
23
+ "str": "string",
24
+ "bool": "boolean",
25
+ "float": "number",
26
+ "list": "array", # Basic support for lists
27
+ "dict": "object", # Basic support for dicts
28
+ }
29
+
30
+ parameters = {"type": "object", "properties": {}, "required": []}
31
+ sig = inspect.signature(fn)
32
+
33
+ for name, type_hint in fn.__annotations__.items():
34
+ if name == "return":
35
+ continue
36
+ param_type_name = getattr(type_hint, "__name__", str(type_hint))
37
+ schema_type = type_mapping.get(param_type_name, "string")
38
+
39
+ parameters["properties"][name] = {"type": schema_type}
40
+
41
+ if sig.parameters[name].default is inspect.Parameter.empty:
42
+ parameters["required"].append(name)
43
+
44
+ if not parameters["required"]:
45
+ del parameters["required"]
46
+
47
+ fn_schema: dict = {
48
+ "type": "function",
49
+ "function": {
50
+ "name": fn.__name__,
51
+ "description": fn.__doc__,
52
+ "parameters": parameters,
53
+ }
54
+ }
55
+
56
+ return fn_schema
57
+
58
+
59
+ def validate_arguments(tool_call_args: dict, tool_schema: dict) -> dict:
60
+ """
61
+ Validates and converts arguments in the input dictionary based on the tool's JSON schema.
62
+ NOTE: This is a simplified validator. For production, use a robust JSON Schema validator.
63
+
64
+ Args:
65
+ tool_call_args (dict): The arguments provided for the tool call (usually strings from LLM).
66
+ tool_schema (dict): The JSON schema for the tool's parameters.
67
+
68
+ Returns:
69
+ dict: The arguments dictionary with values converted to the correct types if possible.
70
+
71
+ Raises:
72
+ ValueError: If conversion fails for a required argument.
73
+ """
74
+ properties = tool_schema.get("function", {}).get("parameters", {}).get("properties", {})
75
+ validated_args = {}
76
+
77
+ type_mapping = {
78
+ "integer": int,
79
+ "string": str,
80
+ "boolean": bool,
81
+ "number": float,
82
+ "array": list,
83
+ "object": dict
84
+ }
85
+
86
+ for arg_name, arg_value in tool_call_args.items():
87
+ prop_schema = properties.get(arg_name)
88
+ if not prop_schema:
89
+ # Argument not defined in schema, potentially skip or warn
90
+ print(f"Warning: Argument '{arg_name}' not found in tool schema.")
91
+ validated_args[arg_name] = arg_value # Pass through unknown args for now
92
+ continue
93
+
94
+ expected_type_name = prop_schema.get("type")
95
+ expected_type = type_mapping.get(expected_type_name)
96
+
97
+ if expected_type:
98
+ try:
99
+ if not isinstance(arg_value, expected_type):
100
+ if expected_type is bool and isinstance(arg_value, str):
101
+ if arg_value.lower() in ['true', '1', 'yes']:
102
+ validated_args[arg_name] = True
103
+ elif arg_value.lower() in ['false', '0', 'no']:
104
+ validated_args[arg_name] = False
105
+ else:
106
+ raise ValueError(f"Cannot convert string '{arg_value}' to boolean.")
107
+ # Basic handling for array/object assuming JSON string
108
+ elif expected_type in [list, dict] and isinstance(arg_value, str):
109
+ try:
110
+ validated_args[arg_name] = json.loads(arg_value)
111
+ if not isinstance(validated_args[arg_name], expected_type):
112
+ raise ValueError(f"Decoded JSON for '{arg_name}' is not the expected type '{expected_type_name}'.")
113
+ except json.JSONDecodeError:
114
+ raise ValueError(f"Argument '{arg_name}' with value '{arg_value}' is not valid JSON for type '{expected_type_name}'.")
115
+ else:
116
+ validated_args[arg_name] = expected_type(arg_value)
117
+ else:
118
+ # Type is already correct
119
+ validated_args[arg_name] = arg_value
120
+ except (ValueError, TypeError) as e:
121
+ raise ValueError(f"Error converting argument '{arg_name}' with value '{arg_value}' to type '{expected_type_name}': {e}")
122
+ else:
123
+ # Unknown type in schema, pass through
124
+ validated_args[arg_name] = arg_value
125
+
126
+ # Check for missing required arguments (optional, depends on strictness)
127
+ # required_args = tool_schema.get("function", {}).get("parameters", {}).get("required", [])
128
+ # for req_arg in required_args:
129
+ # if req_arg not in validated_args:
130
+ # raise ValueError(f"Missing required argument: '{req_arg}'")
131
+
132
+
133
+ return validated_args
134
+
135
+ class Tool:
136
+ """
137
+ A class representing a tool that wraps a callable and its schema.
138
+ Handles both synchronous and asynchronous functions.
139
+
140
+ Attributes:
141
+ name (str): The name of the tool (function).
142
+ fn (Callable): The function that the tool represents (can be sync or async).
143
+ fn_schema (dict): Dictionary representing the function's schema in JSON Schema format.
144
+ fn_signature (str): JSON string representation of the function's signature (legacy, kept for potential compatibility).
145
+ """
146
+
147
+ def __init__(self, name: str, fn: Callable, fn_schema: dict):
148
+ self.name = name
149
+ self.fn = fn
150
+ self.fn_schema = fn_schema
151
+ self.fn_signature = json.dumps(fn_schema)
152
+
153
+ def __str__(self):
154
+ return json.dumps(self.fn_schema, indent=2)
155
+
156
+ async def run(self, **kwargs) -> Any:
157
+ """
158
+ Executes the tool (function) with provided arguments asynchronously.
159
+ Validates arguments against the tool's JSON schema before execution.
160
+ Handles both sync and async tool functions appropriately.
161
+
162
+ Args:
163
+ **kwargs: Keyword arguments provided for the tool call.
164
+
165
+ Returns:
166
+ The result of the function call, or an error string.
167
+ """
168
+ parameter_schema = self.fn_schema.get("function", {}).get("parameters", {})
169
+
170
+ # --- Use jsonschema for validation ---
171
+ try:
172
+ # Validate the incoming arguments against the parameter schema
173
+ # Note: jsonschema validates, it doesn't coerce types like the old function
174
+ jsonschema.validate(instance=kwargs, schema=parameter_schema)
175
+ # If validation passes, kwargs are structurally correct according to schema
176
+
177
+ # Type Coercion/Conversion might still be needed depending on self.fn
178
+ # If self.fn uses Pydantic models or type hints, it might handle coercion.
179
+ # Or, you could apply specific conversions based on schema after validation if needed.
180
+ # For now, assume self.fn or Pydantic handles coercion post-validation.
181
+ validated_kwargs = kwargs # Use original kwargs after validation passes
182
+
183
+ except jsonschema.ValidationError as e:
184
+ print(f"Argument validation failed for tool {self.name}: {e.message}")
185
+ return f"Error: Invalid arguments provided - {e.message}"
186
+ except Exception as e: # Catch other potential validation setup errors
187
+ print(f"An unexpected error occurred during argument validation for tool {self.name}: {e}")
188
+ return f"Error: Argument validation failed."
189
+ # --- End jsonschema validation ---
190
+
191
+ # --- Execute the function (sync or async) ---
192
+ try:
193
+ if inspect.iscoroutinefunction(self.fn):
194
+ return await self.fn(**validated_kwargs)
195
+ else:
196
+ func_with_args = functools.partial(self.fn, **validated_kwargs)
197
+ return await anyio.to_thread.run_sync(func_with_args)
198
+ except Exception as e:
199
+ # Catch errors during the actual tool execution
200
+ print(f"Error executing tool {self.name}: {e}")
201
+ # Consider logging traceback here
202
+ return f"Error executing tool: {e}"
203
+
204
+
205
+
206
+ def tool(fn: Callable):
207
+ """
208
+ A decorator that wraps a function (sync or async) into a Tool object,
209
+ including its JSON schema.
210
+
211
+ Args:
212
+ fn (Callable): The function to be wrapped.
213
+
214
+ Returns:
215
+ Tool: A Tool object containing the function, its name, and its schema.
216
+ """
217
+
218
+ def wrapper():
219
+ fn_schema = get_fn_signature(fn)
220
+ if not fn_schema or 'function' not in fn_schema or 'name' not in fn_schema['function']:
221
+ raise ValueError(f"Could not generate valid schema for function {fn.__name__}")
222
+ return Tool(
223
+ name=fn_schema["function"]["name"],
224
+ fn=fn,
225
+ fn_schema=fn_schema
226
+ )
227
+
228
+ return wrapper()
229
+
@@ -0,0 +1,241 @@
1
+ # --- START OF ASYNC MODIFIED tool_agent.py (Init Fix) ---
2
+
3
+ import json
4
+ import asyncio
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ from colorama import Fore
8
+ from dotenv import load_dotenv
9
+ from groq import AsyncGroq
10
+
11
+ from clap.tool_pattern.tool import Tool
12
+ from clap.mcp_client.client import MCPClientManager
13
+ from clap.utils.completions import build_prompt_structure
14
+ from clap.utils.completions import ChatHistory
15
+ from clap.utils.completions import completions_create
16
+ from clap.utils.completions import update_chat_history
17
+ from mcp import types as mcp_types
18
+
19
+ load_dotenv()
20
+
21
+ NATIVE_TOOL_SYSTEM_PROMPT = """
22
+ You are a helpful assistant. Use the available tools (local or remote) if necessary to answer the user's request.
23
+ If you use a tool, you will be given the results, and then you should provide the final response to the user based on those results.
24
+ If no tool is needed, answer directly.
25
+ """
26
+
27
+ class ToolAgent:
28
+ """
29
+ A simple agent that uses native tool calling asynchronously to answer user queries.
30
+ Supports both local Python tools and remote MCP tools via an MCPClientManager.
31
+ It makes one attempt to call tools if needed, processes the results,
32
+ and then generates a final response.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ tools: Optional[Tool | List[Tool]] = None,
38
+ mcp_manager: Optional[MCPClientManager] = None,
39
+ mcp_server_names: Optional[List[str]] = None,
40
+ model: str = "llama-3.3-70b-versatile",
41
+ system_prompt: str = NATIVE_TOOL_SYSTEM_PROMPT,
42
+ ) -> None:
43
+ self.client = AsyncGroq()
44
+ self.model = model
45
+ self.system_prompt = system_prompt
46
+
47
+ if tools is None:
48
+ self.local_tools = []
49
+ elif isinstance(tools, list):
50
+ self.local_tools = tools
51
+ else:
52
+ self.local_tools = [tools]
53
+
54
+ self.local_tools_dict = {tool.name: tool for tool in self.local_tools}
55
+ self.local_tool_schemas = [tool.fn_schema for tool in self.local_tools]
56
+
57
+ self.mcp_manager = mcp_manager
58
+ self.mcp_server_names = mcp_server_names or []
59
+ self.remote_tools_dict: Dict[str, mcp_types.Tool] = {}
60
+ self.remote_tool_server_map: Dict[str, str] = {}
61
+
62
+
63
+ async def _get_combined_tool_schemas(self) -> List[Dict[str, Any]]:
64
+ """Fetches remote tools and combines their schemas with local ones."""
65
+ all_schemas = list(self.local_tool_schemas) # Start with local schemas
66
+ self.remote_tools_dict = {} # Reset remote tools for this run
67
+ self.remote_tool_server_map = {}
68
+
69
+ if self.mcp_manager and self.mcp_server_names:
70
+ fetch_tasks = [
71
+ self.mcp_manager.list_remote_tools(name)
72
+ for name in self.mcp_server_names
73
+ ]
74
+ results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
75
+
76
+ for server_name, result in zip(self.mcp_server_names, results):
77
+ if isinstance(result, Exception):
78
+ print(f"{Fore.RED}Error listing tools from MCP server '{server_name}': {result}{Fore.RESET}")
79
+ continue
80
+
81
+ if isinstance(result, list):
82
+ for tool in result:
83
+ if isinstance(tool, mcp_types.Tool):
84
+ if tool.name in self.local_tools_dict:
85
+ print(f"{Fore.YELLOW}Warning: Remote tool '{tool.name}' from server '{server_name}' conflicts with local tool. Local tool will be used.{Fore.RESET}")
86
+ continue
87
+ if tool.name in self.remote_tools_dict:
88
+ print(f"{Fore.YELLOW}Warning: Remote tool '{tool.name}' from server '{server_name}' conflicts with another remote tool from server '{self.remote_tool_server_map[tool.name]}'. Skipping duplicate.{Fore.RESET}")
89
+ continue
90
+
91
+ self.remote_tools_dict[tool.name] = tool
92
+ self.remote_tool_server_map[tool.name] = server_name
93
+
94
+ translated_schema = {
95
+ "type": "function",
96
+ "function": {
97
+ "name": tool.name,
98
+ "description": tool.description or "",
99
+ "parameters": tool.inputSchema
100
+ }
101
+ }
102
+ all_schemas.append(translated_schema)
103
+ else:
104
+ print(f"{Fore.YELLOW}Warning: Received non-Tool object from {server_name}: {type(tool)}{Fore.RESET}")
105
+
106
+ print(f"{Fore.BLUE}Total tools available to LLM: {len(all_schemas)}{Fore.RESET}")
107
+ return all_schemas
108
+
109
+ async def process_tool_calls(self, tool_calls: List[Any]) -> List[Dict[str, Any]]:
110
+ """
111
+ Processes tool calls requested by the LLM asynchronously, dispatches execution
112
+ to local or remote tools, and collects results formatted as 'tool' role messages.
113
+ """
114
+ observation_messages = []
115
+ if not isinstance(tool_calls, list):
116
+ print(f"{Fore.RED}Error: Expected a list of tool_calls, got {type(tool_calls)}{Fore.RESET}")
117
+ return observation_messages
118
+
119
+ tasks = [self._execute_single_tool_call(tc) for tc in tool_calls]
120
+ results = await asyncio.gather(*tasks, return_exceptions=True)
121
+
122
+ for result in results:
123
+ if isinstance(result, dict):
124
+ if len(result) == 1:
125
+ tool_call_id, result_str = list(result.items())[0]
126
+ observation_messages.append(
127
+ build_prompt_structure(role="tool", content=result_str, tool_call_id=tool_call_id)
128
+ )
129
+ else:
130
+ print(f"{Fore.RED}Error: Unexpected result format from tool execution: {result}{Fore.RESET}")
131
+ elif isinstance(result, Exception):
132
+ print(f"{Fore.RED}Error during concurrent tool execution: {result}{Fore.RESET}")
133
+ else:
134
+ print(f"{Fore.RED}Error: Unexpected item in tool execution results: {result}{Fore.RESET}")
135
+
136
+ return observation_messages
137
+
138
+ async def _execute_single_tool_call(self, tool_call: Any) -> Dict[str, Any]:
139
+ """Helper to execute a single tool call (local or remote)."""
140
+ tool_call_id = getattr(tool_call, 'id', 'error_no_id')
141
+ function_call = getattr(tool_call, 'function', None)
142
+ tool_name = getattr(function_call, 'name', 'error_unknown_name')
143
+ result_str = f"Error: Processing failed for tool call '{tool_name}' (id: {tool_call_id})."
144
+
145
+ try:
146
+ if not function_call:
147
+ raise ValueError("Invalid tool_call object structure: missing 'function'.")
148
+
149
+ arguments_str = getattr(function_call, 'arguments', '{}')
150
+ arguments = json.loads(arguments_str)
151
+
152
+ if tool_name in self.local_tools_dict:
153
+ tool = self.local_tools_dict[tool_name]
154
+ print(f"{Fore.GREEN}\nExecuting Local Tool: {tool_name}{Fore.RESET}")
155
+ print(f"Tool call ID: {tool_call_id}")
156
+ print(f"Arguments: {arguments}")
157
+ result = await tool.run(**arguments)
158
+ elif tool_name in self.remote_tool_server_map and self.mcp_manager:
159
+ server_name = self.remote_tool_server_map[tool_name]
160
+ print(f"{Fore.CYAN}\nExecuting Remote MCP Tool: {tool_name} on {server_name}{Fore.RESET}")
161
+ print(f"Tool call ID: {tool_call_id}")
162
+ print(f"Arguments: {arguments}")
163
+ result = await self.mcp_manager.call_remote_tool(server_name, tool_name, arguments)
164
+ else:
165
+ print(f"{Fore.RED}Error: Tool '{tool_name}' not found locally or in known remote servers.{Fore.RESET}")
166
+ result_str = f"Error: Tool '{tool_name}' is not available."
167
+ return {tool_call_id: result_str}
168
+
169
+ if not isinstance(result, (str, int, float, bool, list, dict, type(None))):
170
+ result_str = str(result)
171
+ else:
172
+ try: result_str = json.dumps(result)
173
+ except TypeError: result_str = str(result)
174
+ print(f"{Fore.GREEN}Tool '{tool_name}' result: {result_str[:100]}...{Fore.RESET}")
175
+
176
+ except json.JSONDecodeError:
177
+ print(f"{Fore.RED}Error: Could not decode arguments for tool {tool_name}: {arguments_str}{Fore.RESET}")
178
+ result_str = f"Error: Invalid arguments JSON provided for {tool_name}"
179
+ except Exception as e:
180
+ print(f"{Fore.RED}Error processing or running tool {tool_name} (id: {tool_call_id}): {e}{Fore.RESET}")
181
+ result_str = f"Error executing tool {tool_name}: {e}"
182
+
183
+ return {tool_call_id: result_str}
184
+
185
+ async def run(
186
+ self,
187
+ user_msg: str,
188
+ ) -> str:
189
+ """
190
+ Handles the asynchronous interaction: user message -> LLM (tool decision) ->
191
+ execute tools (local or remote) -> LLM (final response).
192
+ """
193
+ combined_tool_schemas = await self._get_combined_tool_schemas()
194
+
195
+ initial_user_message = build_prompt_structure(role="user", content=user_msg)
196
+ chat_history = ChatHistory(
197
+ [
198
+ build_prompt_structure(role="system", content=self.system_prompt),
199
+ initial_user_message,
200
+ ]
201
+ )
202
+
203
+ print(f"{Fore.CYAN}\n--- Calling LLM for Tool Decision ---{Fore.RESET}")
204
+ assistant_message_1 = await completions_create(
205
+ self.client,
206
+ messages=list(chat_history),
207
+ model=self.model,
208
+ tools=combined_tool_schemas,
209
+ tool_choice="auto"
210
+ )
211
+
212
+ update_chat_history(chat_history, assistant_message_1)
213
+
214
+ final_response = "Agent encountered an issue."
215
+
216
+ if hasattr(assistant_message_1, 'tool_calls') and assistant_message_1.tool_calls:
217
+ print(f"{Fore.YELLOW}\nAssistant requests tool calls:{Fore.RESET}")
218
+ observation_messages = await self.process_tool_calls(assistant_message_1.tool_calls)
219
+ print(f"{Fore.BLUE}\nObservations prepared for LLM: {observation_messages}{Fore.RESET}")
220
+
221
+ for obs_msg in observation_messages:
222
+ update_chat_history(chat_history, obs_msg)
223
+
224
+ print(f"{Fore.CYAN}\n--- Calling LLM for Final Response ---{Fore.RESET}")
225
+ assistant_message_2 = await completions_create(
226
+ self.client,
227
+ messages=list(chat_history),
228
+ model=self.model,
229
+ )
230
+ final_response = str(assistant_message_2.content) if assistant_message_2.content else "Agent did not provide a final response after using tools."
231
+
232
+ elif assistant_message_1.content is not None:
233
+ print(f"{Fore.CYAN}\nAssistant provided direct response (no tools used):{Fore.RESET}")
234
+ final_response = assistant_message_1.content
235
+ else:
236
+ print(f"{Fore.RED}Error: Assistant message has neither content nor tool calls.{Fore.RESET}")
237
+ final_response = "Error: Received an unexpected empty response from the assistant."
238
+
239
+ print(f"{Fore.GREEN}\nFinal Response:\n{final_response}{Fore.RESET}")
240
+ return final_response
241
+
clap/tools/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+
2
+ from .web_search import duckduckgo_search
3
+ from .web_crawler import scrape_url, extract_text_by_query # Removed smart_extract
4
+ from .email_tools import send_email, fetch_recent_emails
5
+
6
+ __all__ = [
7
+ "duckduckgo_search",
8
+ "scrape_url",
9
+ "extract_text_by_query",
10
+ "send_email",
11
+ "fetch_recent_emails",
12
+ ]
13
+