patchpal 0.4.5__py3-none-any.whl → 0.7.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.
- patchpal/__init__.py +1 -1
- patchpal/agent.py +248 -12
- patchpal/cli.py +72 -2
- patchpal/tool_schema.py +288 -0
- patchpal/tools.py +21 -2
- {patchpal-0.4.5.dist-info → patchpal-0.7.1.dist-info}/METADATA +402 -17
- patchpal-0.7.1.dist-info/RECORD +15 -0
- patchpal-0.4.5.dist-info/RECORD +0 -14
- {patchpal-0.4.5.dist-info → patchpal-0.7.1.dist-info}/WHEEL +0 -0
- {patchpal-0.4.5.dist-info → patchpal-0.7.1.dist-info}/entry_points.txt +0 -0
- {patchpal-0.4.5.dist-info → patchpal-0.7.1.dist-info}/licenses/LICENSE +0 -0
- {patchpal-0.4.5.dist-info → patchpal-0.7.1.dist-info}/top_level.txt +0 -0
patchpal/tool_schema.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Utility to automatically convert Python functions to LiteLLM tool schemas.
|
|
2
|
+
|
|
3
|
+
Also provides custom tools discovery system for loading user-defined tools
|
|
4
|
+
from ~/.patchpal/tools/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
import sys
|
|
9
|
+
from importlib import util
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Union, get_args, get_origin, get_type_hints
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def python_type_to_json_schema(py_type: Any) -> Dict[str, Any]:
|
|
15
|
+
"""Convert Python type hint to JSON schema type.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
py_type: Python type hint
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
JSON schema type dict
|
|
22
|
+
"""
|
|
23
|
+
if py_type is type(None):
|
|
24
|
+
return {"type": "null"}
|
|
25
|
+
|
|
26
|
+
origin = get_origin(py_type)
|
|
27
|
+
|
|
28
|
+
# Handle Optional/Union types
|
|
29
|
+
if origin is Union:
|
|
30
|
+
args = get_args(py_type)
|
|
31
|
+
non_none = [a for a in args if a is not type(None)]
|
|
32
|
+
if non_none:
|
|
33
|
+
return python_type_to_json_schema(non_none[0])
|
|
34
|
+
|
|
35
|
+
# Handle List
|
|
36
|
+
if origin is list:
|
|
37
|
+
args = get_args(py_type)
|
|
38
|
+
if args:
|
|
39
|
+
return {"type": "array", "items": python_type_to_json_schema(args[0])}
|
|
40
|
+
return {"type": "array"}
|
|
41
|
+
|
|
42
|
+
# Handle Dict
|
|
43
|
+
if origin is dict:
|
|
44
|
+
return {"type": "object"}
|
|
45
|
+
|
|
46
|
+
# Basic types
|
|
47
|
+
type_map = {
|
|
48
|
+
str: {"type": "string"},
|
|
49
|
+
int: {"type": "integer"},
|
|
50
|
+
float: {"type": "number"},
|
|
51
|
+
bool: {"type": "boolean"},
|
|
52
|
+
list: {"type": "array"},
|
|
53
|
+
dict: {"type": "object"},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return type_map.get(py_type, {"type": "string"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_docstring_params(docstring: str) -> Dict[str, str]:
|
|
60
|
+
"""Parse parameter descriptions from Google-style docstring.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
docstring: Function docstring
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Dict mapping parameter names to descriptions
|
|
67
|
+
"""
|
|
68
|
+
if not docstring:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
params = {}
|
|
72
|
+
lines = docstring.split("\n")
|
|
73
|
+
in_args = False
|
|
74
|
+
|
|
75
|
+
for i, line in enumerate(lines):
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
|
|
78
|
+
if stripped.lower() in ("args:", "arguments:", "parameters:"):
|
|
79
|
+
in_args = True
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if in_args:
|
|
83
|
+
# Check if we left the Args section
|
|
84
|
+
if stripped and not line.startswith((" ", "\t")) and ":" in stripped:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
# Parse "param_name: description"
|
|
88
|
+
if ":" in stripped:
|
|
89
|
+
parts = stripped.split(":", 1)
|
|
90
|
+
param_name = parts[0].strip()
|
|
91
|
+
description = parts[1].strip()
|
|
92
|
+
|
|
93
|
+
# Collect continuation lines
|
|
94
|
+
for j in range(i + 1, len(lines)):
|
|
95
|
+
next_line = lines[j].strip()
|
|
96
|
+
if not next_line or ":" in next_line:
|
|
97
|
+
break
|
|
98
|
+
description += " " + next_line
|
|
99
|
+
|
|
100
|
+
params[param_name] = description
|
|
101
|
+
|
|
102
|
+
return params
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def function_to_tool_schema(func: Callable) -> Dict[str, Any]:
|
|
106
|
+
"""Convert a Python function to LiteLLM tool schema.
|
|
107
|
+
|
|
108
|
+
Extracts schema from function signature and docstring.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
func: Python function with type hints and docstring
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
LiteLLM tool schema dict
|
|
115
|
+
"""
|
|
116
|
+
sig = inspect.signature(func)
|
|
117
|
+
docstring = inspect.getdoc(func) or ""
|
|
118
|
+
|
|
119
|
+
# Extract description (first paragraph)
|
|
120
|
+
description = (
|
|
121
|
+
docstring.split("\n\n")[0].replace("\n", " ").strip() or f"Execute {func.__name__}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Parse parameter descriptions
|
|
125
|
+
param_descriptions = parse_docstring_params(docstring)
|
|
126
|
+
|
|
127
|
+
# Get type hints
|
|
128
|
+
try:
|
|
129
|
+
type_hints = get_type_hints(func)
|
|
130
|
+
except Exception:
|
|
131
|
+
type_hints = {}
|
|
132
|
+
|
|
133
|
+
# Build parameters
|
|
134
|
+
properties = {}
|
|
135
|
+
required = []
|
|
136
|
+
|
|
137
|
+
for param_name, param in sig.parameters.items():
|
|
138
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
param_type = type_hints.get(param_name, str)
|
|
142
|
+
param_schema = python_type_to_json_schema(param_type)
|
|
143
|
+
param_schema["description"] = param_descriptions.get(param_name, f"Parameter {param_name}")
|
|
144
|
+
|
|
145
|
+
properties[param_name] = param_schema
|
|
146
|
+
|
|
147
|
+
if param.default is inspect.Parameter.empty:
|
|
148
|
+
required.append(param_name)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"type": "function",
|
|
152
|
+
"function": {
|
|
153
|
+
"name": func.__name__,
|
|
154
|
+
"description": description,
|
|
155
|
+
"parameters": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"properties": properties,
|
|
158
|
+
"required": required,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_valid_tool_function(func: Callable) -> bool:
|
|
165
|
+
"""Check if a function is valid for use as a tool.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
func: Function to validate
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if function can be used as a tool
|
|
172
|
+
"""
|
|
173
|
+
# Must have a docstring
|
|
174
|
+
if not func.__doc__:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# Must have type hints
|
|
178
|
+
try:
|
|
179
|
+
sig = inspect.signature(func)
|
|
180
|
+
for param_name, param in sig.parameters.items():
|
|
181
|
+
# Skip *args, **kwargs
|
|
182
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
183
|
+
continue
|
|
184
|
+
# Check if parameter has annotation
|
|
185
|
+
if param.annotation is inspect.Parameter.empty:
|
|
186
|
+
return False
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def discover_tools(tools_dir: Optional[Path] = None) -> List[Callable]:
|
|
194
|
+
"""Discover custom tool functions from Python files.
|
|
195
|
+
|
|
196
|
+
Loads all .py files from the tools directory and extracts functions
|
|
197
|
+
that have proper type hints and docstrings.
|
|
198
|
+
|
|
199
|
+
Tool functions must:
|
|
200
|
+
- Have type hints for all parameters
|
|
201
|
+
- Have a docstring with description and Args section
|
|
202
|
+
- Be defined at module level (not nested)
|
|
203
|
+
- Not start with underscore (private functions ignored)
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
tools_dir: Directory to search for tool files (default: ~/.patchpal/tools/)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of callable tool functions
|
|
210
|
+
"""
|
|
211
|
+
if tools_dir is None:
|
|
212
|
+
tools_dir = Path.home() / ".patchpal" / "tools"
|
|
213
|
+
|
|
214
|
+
if not tools_dir.exists():
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
tools = []
|
|
218
|
+
loaded_modules = []
|
|
219
|
+
|
|
220
|
+
# Discover all .py files
|
|
221
|
+
for tool_file in sorted(tools_dir.glob("*.py")):
|
|
222
|
+
try:
|
|
223
|
+
# Create a unique module name to avoid conflicts
|
|
224
|
+
module_name = f"patchpal_custom_tools.{tool_file.stem}"
|
|
225
|
+
|
|
226
|
+
# Load the module
|
|
227
|
+
spec = util.spec_from_file_location(module_name, tool_file)
|
|
228
|
+
if spec and spec.loader:
|
|
229
|
+
module = util.module_from_spec(spec)
|
|
230
|
+
|
|
231
|
+
# Store reference to prevent garbage collection
|
|
232
|
+
sys.modules[module_name] = module
|
|
233
|
+
loaded_modules.append(module)
|
|
234
|
+
|
|
235
|
+
# Execute the module
|
|
236
|
+
spec.loader.exec_module(module)
|
|
237
|
+
|
|
238
|
+
# Extract valid tool functions
|
|
239
|
+
for name, obj in inspect.getmembers(module, inspect.isfunction):
|
|
240
|
+
# Skip private functions
|
|
241
|
+
if name.startswith("_"):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
# Skip functions from imports (only module-level definitions)
|
|
245
|
+
if obj.__module__ != module_name:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Validate tool function
|
|
249
|
+
if _is_valid_tool_function(obj):
|
|
250
|
+
tools.append(obj)
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
# Print warning but continue with other tools
|
|
254
|
+
print(
|
|
255
|
+
f"\033[1;33m⚠️ Warning: Failed to load custom tool from {tool_file.name}: {e}\033[0m"
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
return tools
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def list_custom_tools(tools_dir: Optional[Path] = None) -> List[tuple[str, str, Path]]:
|
|
263
|
+
"""List all custom tools with their descriptions.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
tools_dir: Directory to search for tool files (default: ~/.patchpal/tools/)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of (tool_name, description, file_path) tuples
|
|
270
|
+
"""
|
|
271
|
+
tools = discover_tools(tools_dir)
|
|
272
|
+
|
|
273
|
+
result = []
|
|
274
|
+
for tool in tools:
|
|
275
|
+
# Extract description from docstring (first line)
|
|
276
|
+
description = ""
|
|
277
|
+
if tool.__doc__:
|
|
278
|
+
description = tool.__doc__.split("\n")[0].strip()
|
|
279
|
+
|
|
280
|
+
# Get source file
|
|
281
|
+
try:
|
|
282
|
+
source_file = Path(inspect.getfile(tool))
|
|
283
|
+
except Exception:
|
|
284
|
+
source_file = Path("unknown")
|
|
285
|
+
|
|
286
|
+
result.append((tool.__name__, description, source_file))
|
|
287
|
+
|
|
288
|
+
return result
|
patchpal/tools.py
CHANGED
|
@@ -80,7 +80,10 @@ CRITICAL_FILES = {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
# Configuration
|
|
83
|
-
|
|
83
|
+
# Reduced from 10MB to 500KB to prevent context window explosions
|
|
84
|
+
# A 3.46MB file = ~1.15M tokens which exceeds most model context limits (128K-200K)
|
|
85
|
+
# 500KB ≈ 166K tokens which is safe for most models
|
|
86
|
+
MAX_FILE_SIZE = int(os.getenv("PATCHPAL_MAX_FILE_SIZE", 500 * 1024)) # 500KB default
|
|
84
87
|
READ_ONLY_MODE = os.getenv("PATCHPAL_READ_ONLY", "false").lower() == "true"
|
|
85
88
|
ALLOW_SENSITIVE = os.getenv("PATCHPAL_ALLOW_SENSITIVE", "false").lower() == "true"
|
|
86
89
|
ENABLE_AUDIT_LOG = os.getenv("PATCHPAL_AUDIT_LOG", "true").lower() == "true"
|
|
@@ -2521,8 +2524,24 @@ def web_search(query: str, max_results: int = 5) -> str:
|
|
|
2521
2524
|
max_results = min(max_results, 10)
|
|
2522
2525
|
|
|
2523
2526
|
try:
|
|
2527
|
+
# Determine SSL verification setting
|
|
2528
|
+
# Priority: PATCHPAL_VERIFY_SSL env var > SSL_CERT_FILE > REQUESTS_CA_BUNDLE > default True
|
|
2529
|
+
verify_ssl = os.getenv("PATCHPAL_VERIFY_SSL")
|
|
2530
|
+
if verify_ssl is not None:
|
|
2531
|
+
# User explicitly set PATCHPAL_VERIFY_SSL
|
|
2532
|
+
if verify_ssl.lower() in ("false", "0", "no"):
|
|
2533
|
+
verify = False
|
|
2534
|
+
elif verify_ssl.lower() in ("true", "1", "yes"):
|
|
2535
|
+
verify = True
|
|
2536
|
+
else:
|
|
2537
|
+
# Treat as path to CA bundle
|
|
2538
|
+
verify = verify_ssl
|
|
2539
|
+
else:
|
|
2540
|
+
# Use SSL_CERT_FILE or REQUESTS_CA_BUNDLE if set (for corporate environments)
|
|
2541
|
+
verify = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE") or True
|
|
2542
|
+
|
|
2524
2543
|
# Perform search using DuckDuckGo
|
|
2525
|
-
with DDGS() as ddgs:
|
|
2544
|
+
with DDGS(verify=verify) as ddgs:
|
|
2526
2545
|
results = list(ddgs.text(query, max_results=max_results))
|
|
2527
2546
|
|
|
2528
2547
|
if not results:
|