patchpal 0.4.5__py3-none-any.whl → 0.6.0__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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.4.5"
3
+ __version__ = "0.6.0"
4
4
 
5
5
  from patchpal.agent import create_agent
6
6
  from patchpal.tools import (
patchpal/agent.py CHANGED
@@ -811,12 +811,17 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
811
811
  class PatchPalAgent:
812
812
  """Simple agent that uses LiteLLM for tool calling."""
813
813
 
814
- def __init__(self, model_id: str = "anthropic/claude-sonnet-4-5"):
814
+ def __init__(self, model_id: str = "anthropic/claude-sonnet-4-5", custom_tools=None):
815
815
  """Initialize the agent.
816
816
 
817
817
  Args:
818
818
  model_id: LiteLLM model identifier
819
+ custom_tools: Optional list of Python functions to add as tools
819
820
  """
821
+ # Store custom tools
822
+ self.custom_tools = custom_tools or []
823
+ self.custom_tool_funcs = {func.__name__: func for func in self.custom_tools}
824
+
820
825
  # Convert ollama/ to ollama_chat/ for LiteLLM compatibility
821
826
  if model_id.startswith("ollama/"):
822
827
  model_id = model_id.replace("ollama/", "ollama_chat/", 1)
@@ -1029,6 +1034,67 @@ class PatchPalAgent:
1029
1034
  if self.enable_auto_compact and self.context_manager.needs_compaction(self.messages):
1030
1035
  self._perform_auto_compaction()
1031
1036
 
1037
+ # Agent loop with interrupt handling
1038
+ try:
1039
+ return self._run_agent_loop(max_iterations)
1040
+ except KeyboardInterrupt:
1041
+ # Clean up conversation state if interrupted mid-execution
1042
+ self._cleanup_interrupted_state()
1043
+ raise # Re-raise so CLI can handle it
1044
+
1045
+ def _cleanup_interrupted_state(self):
1046
+ """Clean up conversation state after KeyboardInterrupt.
1047
+
1048
+ If the last message is an assistant message with tool_calls but no
1049
+ corresponding tool responses, we need to either remove the message
1050
+ or add error responses to maintain valid conversation structure.
1051
+ """
1052
+ if not self.messages:
1053
+ return
1054
+
1055
+ last_msg = self.messages[-1]
1056
+
1057
+ # Check if last message is assistant with tool_calls
1058
+ if last_msg.get("role") == "assistant" and last_msg.get("tool_calls"):
1059
+ tool_calls = last_msg["tool_calls"]
1060
+
1061
+ # Check if we have tool responses for all tool_calls
1062
+ tool_call_ids = {tc.id for tc in tool_calls}
1063
+
1064
+ # Look for tool responses after this assistant message
1065
+ # (should be immediately following, but scan to be safe)
1066
+ response_ids = set()
1067
+ for msg in self.messages[self.messages.index(last_msg) + 1 :]:
1068
+ if msg.get("role") == "tool":
1069
+ response_ids.add(msg.get("tool_call_id"))
1070
+
1071
+ # If we're missing responses, add error responses for all tool calls
1072
+ if tool_call_ids != response_ids:
1073
+ missing_ids = tool_call_ids - response_ids
1074
+
1075
+ # Add error tool responses for the missing tool calls
1076
+ for tool_call in tool_calls:
1077
+ if tool_call.id in missing_ids:
1078
+ self.messages.append(
1079
+ {
1080
+ "role": "tool",
1081
+ "tool_call_id": tool_call.id,
1082
+ "name": tool_call.function.name,
1083
+ "content": "Error: Operation interrupted by user (Ctrl-C)",
1084
+ }
1085
+ )
1086
+
1087
+ def _run_agent_loop(self, max_iterations: int) -> str:
1088
+ """Internal method that runs the agent loop.
1089
+
1090
+ Separated from run() to enable proper interrupt handling.
1091
+
1092
+ Args:
1093
+ max_iterations: Maximum number of agent iterations
1094
+
1095
+ Returns:
1096
+ The agent's final response
1097
+ """
1032
1098
  # Agent loop
1033
1099
  for iteration in range(max_iterations):
1034
1100
  # Show thinking message
@@ -1042,10 +1108,18 @@ class PatchPalAgent:
1042
1108
 
1043
1109
  # Use LiteLLM for all providers
1044
1110
  try:
1111
+ # Build tool list (built-in + custom)
1112
+ tools = list(TOOLS)
1113
+ if self.custom_tools:
1114
+ from patchpal.tool_schema import function_to_tool_schema
1115
+
1116
+ for func in self.custom_tools:
1117
+ tools.append(function_to_tool_schema(func))
1118
+
1045
1119
  response = litellm.completion(
1046
1120
  model=self.model_id,
1047
1121
  messages=messages,
1048
- tools=TOOLS,
1122
+ tools=tools,
1049
1123
  tool_choice="auto",
1050
1124
  **self.litellm_kwargs,
1051
1125
  )
@@ -1099,15 +1173,25 @@ class PatchPalAgent:
1099
1173
  tool_result = f"Error: Invalid JSON arguments for {tool_name}"
1100
1174
  print(f"\033[1;31m✗ {tool_name}: Invalid arguments\033[0m")
1101
1175
  else:
1102
- # Get the tool function
1103
- tool_func = TOOL_FUNCTIONS.get(tool_name)
1176
+ # Get the tool function (check custom tools first, then built-in)
1177
+ tool_func = self.custom_tool_funcs.get(tool_name) or TOOL_FUNCTIONS.get(
1178
+ tool_name
1179
+ )
1104
1180
  if tool_func is None:
1105
1181
  tool_result = f"Error: Unknown tool {tool_name}"
1106
1182
  print(f"\033[1;31m✗ Unknown tool: {tool_name}\033[0m")
1107
1183
  else:
1108
1184
  # Show tool call message
1109
- tool_display = tool_name.replace("_", " ").title()
1110
- if tool_name == "read_file":
1185
+ if tool_name in self.custom_tool_funcs:
1186
+ # Custom tool - show generic message with args
1187
+ args_preview = str(tool_args)[:60]
1188
+ if len(str(tool_args)) > 60:
1189
+ args_preview += "..."
1190
+ print(
1191
+ f"\033[2m🔧 {tool_name}({args_preview})\033[0m",
1192
+ flush=True,
1193
+ )
1194
+ elif tool_name == "read_file":
1111
1195
  print(
1112
1196
  f"\033[2m📖 Reading: {tool_args.get('path', '')}\033[0m",
1113
1197
  flush=True,
@@ -1250,7 +1334,7 @@ class PatchPalAgent:
1250
1334
  tool_result = tool_func(**filtered_args)
1251
1335
  except Exception as e:
1252
1336
  tool_result = f"Error executing {tool_name}: {e}"
1253
- print(f"\033[1;31m✗ {tool_display}: {e}\033[0m")
1337
+ print(f"\033[1;31m✗ {tool_name}: {e}\033[0m")
1254
1338
 
1255
1339
  # Add tool result to messages
1256
1340
  self.messages.append(
@@ -1299,18 +1383,33 @@ class PatchPalAgent:
1299
1383
  )
1300
1384
 
1301
1385
 
1302
- def create_agent(model_id: str = "anthropic/claude-sonnet-4-5") -> PatchPalAgent:
1386
+ def create_agent(model_id: str = "anthropic/claude-sonnet-4-5", custom_tools=None) -> PatchPalAgent:
1303
1387
  """Create and return a PatchPal agent.
1304
1388
 
1305
1389
  Args:
1306
1390
  model_id: LiteLLM model identifier (default: anthropic/claude-sonnet-4-5)
1391
+ custom_tools: Optional list of Python functions to use as custom tools.
1392
+ Each function should have type hints and a docstring.
1307
1393
 
1308
1394
  Returns:
1309
1395
  A configured PatchPalAgent instance
1396
+
1397
+ Example:
1398
+ def calculator(x: int, y: int) -> str:
1399
+ '''Add two numbers.
1400
+
1401
+ Args:
1402
+ x: First number
1403
+ y: Second number
1404
+ '''
1405
+ return str(x + y)
1406
+
1407
+ agent = create_agent(custom_tools=[calculator])
1408
+ response = agent.run("What's 5 + 3?")
1310
1409
  """
1311
1410
  # Reset session todos for new session
1312
1411
  from patchpal.tools import reset_session_todos
1313
1412
 
1314
1413
  reset_session_todos()
1315
1414
 
1316
- return PatchPalAgent(model_id=model_id)
1415
+ return PatchPalAgent(model_id=model_id, custom_tools=custom_tools)
patchpal/cli.py CHANGED
@@ -211,9 +211,26 @@ Supported models: Any LiteLLM-supported model
211
211
  # Determine model to use (priority: CLI arg > env var > default)
212
212
  model_id = args.model or os.getenv("PATCHPAL_MODEL") or "anthropic/claude-sonnet-4-5"
213
213
 
214
- # Create the agent with the specified model
214
+ # Discover custom tools from ~/.patchpal/tools/
215
+ from patchpal.tool_schema import discover_tools, list_custom_tools
216
+
217
+ custom_tools = discover_tools()
218
+
219
+ # Show custom tools info if any were loaded
220
+ custom_tool_info = list_custom_tools()
221
+ if custom_tool_info:
222
+ tool_names = [name for name, _, _ in custom_tool_info]
223
+ tools_str = ", ".join(tool_names)
224
+ # Store for later display (after model info)
225
+ custom_tools_message = (
226
+ f"\033[1;36m🔧 Loaded {len(custom_tool_info)} custom tool(s): {tools_str}\033[0m"
227
+ )
228
+ else:
229
+ custom_tools_message = None
230
+
231
+ # Create the agent with the specified model and custom tools
215
232
  # LiteLLM will handle API key validation and provide appropriate error messages
216
- agent = create_agent(model_id=model_id)
233
+ agent = create_agent(model_id=model_id, custom_tools=custom_tools)
217
234
 
218
235
  # Get max iterations from environment variable or use default
219
236
  max_iterations = int(os.getenv("PATCHPAL_MAX_ITERATIONS", "100"))
@@ -238,6 +255,10 @@ Supported models: Any LiteLLM-supported model
238
255
  print("=" * 80)
239
256
  print(f"\nUsing model: {model_id}")
240
257
 
258
+ # Show custom tools info if any were loaded
259
+ if custom_tools_message:
260
+ print(custom_tools_message)
261
+
241
262
  # Show require-permission-for-all indicator if active
242
263
  if args.require_permission_for_all:
243
264
  print("\033[1;33m🔒 Permission required for ALL operations (including reads)\033[0m")
@@ -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
@@ -2521,8 +2521,24 @@ def web_search(query: str, max_results: int = 5) -> str:
2521
2521
  max_results = min(max_results, 10)
2522
2522
 
2523
2523
  try:
2524
+ # Determine SSL verification setting
2525
+ # Priority: PATCHPAL_VERIFY_SSL env var > SSL_CERT_FILE > REQUESTS_CA_BUNDLE > default True
2526
+ verify_ssl = os.getenv("PATCHPAL_VERIFY_SSL")
2527
+ if verify_ssl is not None:
2528
+ # User explicitly set PATCHPAL_VERIFY_SSL
2529
+ if verify_ssl.lower() in ("false", "0", "no"):
2530
+ verify = False
2531
+ elif verify_ssl.lower() in ("true", "1", "yes"):
2532
+ verify = True
2533
+ else:
2534
+ # Treat as path to CA bundle
2535
+ verify = verify_ssl
2536
+ else:
2537
+ # Use SSL_CERT_FILE or REQUESTS_CA_BUNDLE if set (for corporate environments)
2538
+ verify = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE") or True
2539
+
2524
2540
  # Perform search using DuckDuckGo
2525
- with DDGS() as ddgs:
2541
+ with DDGS(verify=verify) as ddgs:
2526
2542
  results = list(ddgs.text(query, max_results=max_results))
2527
2543
 
2528
2544
  if not results:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.4.5
3
+ Version: 0.6.0
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -46,6 +46,18 @@ Dynamic: license-file
46
46
 
47
47
  A key goal of this project is to approximate Claude Code's core functionality while remaining lean, accessible, and configurable, enabling learning, experimentation, and broad applicability across use cases.
48
48
 
49
+ ```bash
50
+ $ls ./patchpal
51
+ __init__.py agent.py cli.py context.py permissions.py skills.py system_prompt.md tool_schema.py tools.py
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ```bash
57
+ $ pip install patchpal # install
58
+ $ patchpal # start
59
+ ```
60
+
49
61
  ## Table of Contents
50
62
 
51
63
  - [Installation](https://github.com/amaiya/patchpal?tab=readme-ov-file#installation)
@@ -58,12 +70,14 @@ A key goal of this project is to approximate Claude Code's core functionality wh
58
70
  - [Git Operations](https://github.com/amaiya/patchpal?tab=readme-ov-file#git-operations-no-permission-required)
59
71
  - [Web Capabilities](https://github.com/amaiya/patchpal?tab=readme-ov-file#web-capabilities-requires-permission)
60
72
  - [Skills System](https://github.com/amaiya/patchpal?tab=readme-ov-file#skills-system)
73
+ - [Custom Tools](https://github.com/amaiya/patchpal?tab=readme-ov-file#custom-tools)
61
74
  - [Model Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#model-configuration)
62
75
  - [Supported Models](https://github.com/amaiya/patchpal?tab=readme-ov-file#supported-models)
63
76
  - [Using Local Models (vLLM & Ollama)](https://github.com/amaiya/patchpal?tab=readme-ov-file#using-local-models-vllm--ollama)
64
77
  - [Air-Gapped and Offline Environments](https://github.com/amaiya/patchpal?tab=readme-ov-file#air-gapped-and-offline-environments)
65
78
  - [Maximum Security Mode](https://github.com/amaiya/patchpal?tab=readme-ov-file#maximum-security-mode)
66
79
  - [Usage](https://github.com/amaiya/patchpal?tab=readme-ov-file#usage)
80
+ - [Python API](https://github.com/amaiya/patchpal?tab=readme-ov-file#python-api)
67
81
  - [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration)
68
82
  - [Example Tasks](https://github.com/amaiya/patchpal?tab=readme-ov-file#example-tasks)
69
83
  - [Safety](https://github.com/amaiya/patchpal?tab=readme-ov-file#safety)
@@ -71,10 +85,6 @@ A key goal of this project is to approximate Claude Code's core functionality wh
71
85
  - [Troubleshooting](https://github.com/amaiya/patchpal?tab=readme-ov-file#troubleshooting)
72
86
 
73
87
 
74
- ```bash
75
- $ls ./patchpal
76
- __init__.py agent.py cli.py context.py permissions.py skills.py system_prompt.md tools.py
77
- ```
78
88
 
79
89
  ## Installation
80
90
 
@@ -307,6 +317,197 @@ You: list skills
307
317
 
308
318
  Project skills (`.patchpal/skills/`) override personal skills (`~/.patchpal/skills/`) with the same name.
309
319
 
320
+ ### Custom Tools
321
+
322
+ Custom tools extend PatchPal's capabilities by adding new Python functions that the agent can call. Unlike skills (which are prompt-based workflows), custom tools are executable Python code that the agent invokes automatically when needed.
323
+
324
+ **Key Differences:**
325
+ - **Skills**: Markdown files with instructions for the agent to follow
326
+ - **Custom Tools**: Python functions that execute code and return results
327
+
328
+ **Installation:**
329
+
330
+ 1. **Create the tools directory:**
331
+ ```bash
332
+ mkdir -p ~/.patchpal/tools
333
+ ```
334
+
335
+ 2. **Copy the example tools (or create your own):**
336
+ ```bash
337
+ # After pip install patchpal, get the example tools
338
+ curl -L https://github.com/amaiya/patchpal/archive/main.tar.gz | tar xz --strip=1 patchpal-main/examples
339
+
340
+ # Copy to your tools directory
341
+ cp examples/tools/calculator.py ~/.patchpal/tools/
342
+ ```
343
+
344
+ 3. **Start PatchPal - tools are loaded automatically:**
345
+ ```bash
346
+ $ patchpal
347
+ ================================================================================
348
+ PatchPal - Claude Code–inspired coding and automation assistant
349
+ ================================================================================
350
+
351
+ Using model: anthropic/claude-sonnet-4-5
352
+ 🔧 Loaded 7 custom tool(s): add, subtract, multiply, divide, calculate_percentage, fahrenheit_to_celsius, celsius_to_fahrenheit
353
+ ```
354
+
355
+ **Creating Custom Tools:**
356
+
357
+ Custom tools are Python functions with specific requirements:
358
+
359
+ **Requirements:**
360
+ 1. **Type hints** for all parameters
361
+ 2. **Docstring** with description and Args section (Google-style)
362
+ 3. **Module-level** functions (not nested inside classes)
363
+ 4. **Return type** should typically be `str` (for LLM consumption)
364
+ 5. Function names **cannot start with underscore** (private functions ignored)
365
+
366
+ **Example:**
367
+
368
+ ```python
369
+ # ~/.patchpal/tools/my_tools.py
370
+
371
+ def add(x: int, y: int) -> str:
372
+ """Add two numbers together.
373
+
374
+ Args:
375
+ x: First number
376
+ y: Second number
377
+
378
+ Returns:
379
+ The sum as a string
380
+ """
381
+ result = x + y
382
+ return f"{x} + {y} = {result}"
383
+
384
+
385
+ def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
386
+ """Convert between currencies.
387
+
388
+ Args:
389
+ amount: Amount to convert
390
+ from_currency: Source currency code (e.g., USD)
391
+ to_currency: Target currency code (e.g., EUR)
392
+
393
+ Returns:
394
+ Converted amount as a string
395
+ """
396
+ # Your implementation here (API call, etc.)
397
+ # This is just a simple example
398
+ rates = {"USD": 1.0, "EUR": 0.85, "GBP": 0.73}
399
+ usd_amount = amount / rates.get(from_currency, 1.0)
400
+ result = usd_amount * rates.get(to_currency, 1.0)
401
+ return f"{amount} {from_currency} = {result:.2f} {to_currency}"
402
+ ```
403
+
404
+ **Using Custom Tools:**
405
+
406
+ Once loaded, the agent calls your custom tools automatically:
407
+
408
+ ```bash
409
+ You: What's 15 + 27?
410
+ Agent: [Calls the add tool]
411
+ 15 + 27 = 42
412
+
413
+ You: Convert 100 USD to EUR
414
+ Agent: [Calls convert_currency tool]
415
+ 100 USD = 85.00 EUR
416
+ ```
417
+
418
+ **Tool Discovery:**
419
+
420
+ PatchPal discovers tools from `~/.patchpal/tools/*.py` at startup. All `.py` files are scanned for valid tool functions.
421
+
422
+ **What Gets Loaded:**
423
+ - ✅ Functions with type hints and docstrings
424
+ - ✅ Multiple functions per file
425
+ - ✅ Files can import standard libraries
426
+ - ❌ Functions without type hints
427
+ - ❌ Functions without docstrings
428
+ - ❌ Private functions (starting with `_`)
429
+ - ❌ Imported functions (must be defined in the file)
430
+
431
+ **Example Tools:**
432
+
433
+ The repository includes [example tools](https://github.com/amaiya/patchpal/tree/main/examples/tools):
434
+ - **calculator.py**: Basic arithmetic (add, subtract, multiply, divide), temperature conversion, percentage calculations
435
+ - Demonstrates different numeric types (int, float)
436
+ - Shows proper formatting of results for LLM consumption
437
+ - Examples: `add`, `subtract`, `multiply`, `divide`, `calculate_percentage`, `fahrenheit_to_celsius`
438
+
439
+ View the [examples/tools/](https://github.com/amaiya/patchpal/tree/main/examples/tools) directory for complete examples and a detailed README.
440
+
441
+ **Security Note:**
442
+
443
+ ⚠️ Custom tools execute arbitrary Python code on your system. Only install tools from sources you trust.
444
+
445
+ - Tools are only loaded from `~/.patchpal/tools/` (your home directory)
446
+ - Project-local tools (`.patchpal/tools/`) are **not supported** for security
447
+ - This prevents accidental execution of untrusted code from repositories
448
+
449
+ **Advanced Features:**
450
+
451
+ **Optional Parameters:**
452
+ ```python
453
+ from typing import Optional
454
+
455
+ def greet(name: str, greeting: Optional[str] = "Hello") -> str:
456
+ """Greet someone.
457
+
458
+ Args:
459
+ name: Person's name
460
+ greeting: Optional greeting message (default: "Hello")
461
+ """
462
+ return f"{greeting}, {name}!"
463
+ ```
464
+
465
+ **Complex Types:**
466
+ ```python
467
+ from typing import List
468
+
469
+ def sum_numbers(numbers: List[int]) -> str:
470
+ """Sum a list of numbers.
471
+
472
+ Args:
473
+ numbers: List of integers to sum
474
+ """
475
+ total = sum(numbers)
476
+ return f"Sum of {numbers} = {total}"
477
+ ```
478
+
479
+ **Python API:**
480
+
481
+ Custom tools can also be used programmatically:
482
+
483
+ ```python
484
+ from patchpal.agent import create_agent
485
+
486
+ def calculator(x: int, y: int) -> str:
487
+ """Add two numbers.
488
+
489
+ Args:
490
+ x: First number
491
+ y: Second number
492
+ """
493
+ return str(x + y)
494
+
495
+ # Create agent with custom tools
496
+ agent = create_agent(custom_tools=[calculator])
497
+ response = agent.run("What's 5 + 3?")
498
+ ```
499
+
500
+ See the [Python API](https://github.com/amaiya/patchpal?tab=readme-ov-file#python-api) section for more details.
501
+
502
+ **Troubleshooting:**
503
+
504
+ If tools aren't loading:
505
+ 1. Check the file has a `.py` extension
506
+ 2. Ensure functions have type hints for all parameters
507
+ 3. Verify docstrings follow Google style (with Args: section)
508
+ 4. Look for warning messages when starting PatchPal
509
+ 5. Test the function directly in Python to check for syntax errors
510
+
310
511
  ## Model Configuration
311
512
 
312
513
  PatchPal supports any LiteLLM-compatible model. You can configure the model in three ways (in order of priority):
@@ -668,6 +869,163 @@ The agent will process your request and show you the results. You can continue w
668
869
  - **Interrupt Agent**: Press `Ctrl-C` during agent execution to stop the current task without exiting PatchPal
669
870
  - **Exit**: Type `exit`, `quit`, or press `Ctrl-C` at the prompt to exit PatchPal
670
871
 
872
+ ## Python API
873
+
874
+ PatchPal can be used programmatically from Python scripts or a REPL, giving you full agent capabilities with a simple API. **Unlike fully autonomous agent frameworks, PatchPal is designed for human-in-the-loop workflows** where users maintain control through interactive permission prompts, making it ideal for code assistance, debugging, and automation tasks that benefit from human oversight.
875
+
876
+ **Basic Usage:**
877
+
878
+ ```python
879
+ from patchpal.agent import create_agent
880
+
881
+ # Create an agent (uses default model or PATCHPAL_MODEL env var)
882
+ agent = create_agent()
883
+
884
+ # Or specify a model explicitly
885
+ agent = create_agent(model_id="anthropic/claude-sonnet-4_5")
886
+
887
+ # Run the agent on a task
888
+ response = agent.run("List all Python files in this directory")
889
+ print(response)
890
+
891
+ # Continue the conversation (history is maintained)
892
+ response = agent.run("Now read the main agent file")
893
+ print(response)
894
+ ```
895
+
896
+ **Adding Custom Tools:**
897
+
898
+ Custom tools can be used in two ways:
899
+
900
+ 1. **CLI**: Place `.py` files in `~/.patchpal/tools/` (auto-discovered at startup)
901
+ 2. **Python API**: Pass functions directly to `create_agent(custom_tools=[...])`
902
+
903
+ Both methods use the same tool schema auto-generation from Python functions with type hints and docstrings:
904
+
905
+ ```python
906
+ from patchpal.agent import create_agent
907
+
908
+ def calculator(x: int, y: int, operation: str = "add") -> str:
909
+ """Perform basic arithmetic operations.
910
+
911
+ Args:
912
+ x: First number
913
+ y: Second number
914
+ operation: Operation to perform (add, subtract, multiply, divide)
915
+
916
+ Returns:
917
+ Result as a string
918
+ """
919
+ if operation == "add":
920
+ return f"{x} + {y} = {x + y}"
921
+ elif operation == "subtract":
922
+ return f"{x} - {y} = {x - y}"
923
+ elif operation == "multiply":
924
+ return f"{x} * {y} = {x * y}"
925
+ elif operation == "divide":
926
+ if y == 0:
927
+ return "Error: Cannot divide by zero"
928
+ return f"{x} / {y} = {x / y}"
929
+ return "Unknown operation"
930
+
931
+
932
+ def get_weather(city: str, units: str = "celsius") -> str:
933
+ """Get weather information for a city.
934
+
935
+ Args:
936
+ city: Name of the city
937
+ units: Temperature units (celsius or fahrenheit)
938
+
939
+ Returns:
940
+ Weather information string
941
+ """
942
+ # Your implementation here (API call, etc.)
943
+ return f"Weather in {city}: 22°{units[0].upper()}, Sunny"
944
+
945
+
946
+ # Create agent with custom tools
947
+ agent = create_agent(
948
+ model_id="anthropic/claude-sonnet-4-5",
949
+ custom_tools=[calculator, get_weather]
950
+ )
951
+
952
+ # Use the agent - it will call your custom tools when appropriate
953
+ response = agent.run("What's 15 multiplied by 23?")
954
+ print(response)
955
+
956
+ response = agent.run("What's the weather in Paris?")
957
+ print(response)
958
+ ```
959
+
960
+ **Key Points:**
961
+ - Custom tools are automatically converted to LLM tool schemas
962
+ - Functions should have type hints and Google-style docstrings
963
+ - The agent will call your functions when appropriate
964
+ - Tool execution follows the same permission system as built-in tools
965
+
966
+ **Advanced Usage:**
967
+
968
+ ```python
969
+ from patchpal.agent import PatchPalAgent
970
+
971
+ # Create agent with custom configuration
972
+ agent = PatchPalAgent(model_id="anthropic/claude-sonnet-4-5")
973
+
974
+ # Set custom max iterations for complex tasks
975
+ response = agent.run("Refactor the entire codebase", max_iterations=200)
976
+
977
+ # Access conversation history
978
+ print(f"Messages in history: {len(agent.messages)}")
979
+
980
+ # Check context window usage
981
+ stats = agent.context_manager.get_usage_stats(agent.messages)
982
+ print(f"Token usage: {stats['total_tokens']:,} / {stats['context_limit']:,}")
983
+ print(f"Usage: {stats['usage_percent']}%")
984
+
985
+ # Manually trigger compaction if needed
986
+ if agent.context_manager.needs_compaction(agent.messages):
987
+ agent._perform_auto_compaction()
988
+
989
+ # Track API costs (cumulative token counts across session)
990
+ print(f"Total LLM calls: {agent.total_llm_calls}")
991
+ print(f"Cumulative input tokens: {agent.cumulative_input_tokens:,}")
992
+ print(f"Cumulative output tokens: {agent.cumulative_output_tokens:,}")
993
+ print(f"Total tokens: {agent.cumulative_input_tokens + agent.cumulative_output_tokens:,}")
994
+ ```
995
+
996
+ **Use Cases:**
997
+ - **Interactive debugging**: Use in Jupyter notebooks for hands-on debugging with agent assistance
998
+ - **Automation scripts**: Build scripts that use the agent for complex tasks with human oversight
999
+ - **Custom workflows**: Integrate PatchPal into your own tools and pipelines
1000
+ - **Code review assistance**: Programmatic code analysis with permission controls
1001
+ - **Batch processing**: Process multiple tasks programmatically while maintaining control
1002
+ - **Testing and evaluation**: Test agent behavior with different prompts and configurations
1003
+
1004
+ **Key Features:**
1005
+ - **Human-in-the-loop design**: Permission prompts ensure human oversight (unlike fully autonomous frameworks)
1006
+ - **Stateful conversations**: Agent maintains full conversation history
1007
+ - **Custom tools**: Add your own Python functions (via CLI auto-discovery or API parameter) with automatic schema generation
1008
+ - **Automatic context management**: Auto-compaction works the same as CLI
1009
+ - **All built-in tools available**: File operations, git, web search, skills, etc.
1010
+ - **Model flexibility**: Works with any LiteLLM-compatible model
1011
+ - **Token tracking**: Monitor API usage and costs in real-time
1012
+ - **Environment variables respected**: All `PATCHPAL_*` settings apply
1013
+
1014
+ **PatchPal vs. Other Agent Frameworks:**
1015
+
1016
+ Unlike fully autonomous agent frameworks (e.g., smolagents, autogen), PatchPal is explicitly designed for **human-in-the-loop workflows**:
1017
+
1018
+ | Feature | PatchPal | Autonomous Frameworks |
1019
+ |---------|----------|----------------------|
1020
+ | **Design Philosophy** | Human oversight & control | Autonomous execution |
1021
+ | **Permission System** | Interactive prompts for sensitive operations | Typically no prompts |
1022
+ | **Primary Use Case** | Code assistance, debugging, interactive tasks | Automated workflows, batch processing |
1023
+ | **Safety Model** | Write boundary protection, command blocking | Varies by framework |
1024
+ | **Custom Tools** | Yes, with automatic schema generation | Yes (varies by framework) |
1025
+ | **Best For** | Developers who want AI assistance with control | Automation, research, agent benchmarks |
1026
+
1027
+ The Python API uses the same agent implementation as the CLI, so you get the complete feature set including permissions, safety guardrails, and context management.
1028
+
671
1029
  ## Configuration
672
1030
 
673
1031
  PatchPal can be configured through `PATCHPAL_*` environment variables to customize behavior, security, and performance.
@@ -734,6 +1092,16 @@ export PATCHPAL_PRUNE_MINIMUM=20000 # Minimum tokens to prune (default:
734
1092
  # Enable/Disable Web Access
735
1093
  export PATCHPAL_ENABLE_WEB=false # Disable web search/fetch for air-gapped environments (default: true)
736
1094
 
1095
+ # SSL Certificate Verification (for web_search)
1096
+ export PATCHPAL_VERIFY_SSL=true # SSL verification for web searches (default: true)
1097
+ # Set to 'false' to disable (not recommended for production)
1098
+ # Or set to path of CA bundle file for corporate certificates
1099
+ # Auto-detects SSL_CERT_FILE and REQUESTS_CA_BUNDLE if not set
1100
+ # Examples:
1101
+ # export PATCHPAL_VERIFY_SSL=false # Disable verification
1102
+ # export PATCHPAL_VERIFY_SSL=/path/to/ca-bundle.crt # Custom CA bundle
1103
+ # (Leave unset to auto-detect from SSL_CERT_FILE/REQUESTS_CA_BUNDLE)
1104
+
737
1105
  # Web Request Limits
738
1106
  export PATCHPAL_WEB_TIMEOUT=60 # Web request timeout in seconds (default: 30)
739
1107
  export PATCHPAL_MAX_WEB_SIZE=10485760 # Max web content size in bytes (default: 5MB)
@@ -0,0 +1,15 @@
1
+ patchpal/__init__.py,sha256=S3dYO3L8dSQG2Eaosbu4Pbdq5eTxXLmmvxSzh-TIPiI,606
2
+ patchpal/agent.py,sha256=ayMkZUoohUsf5Tz4esBjOPZUvBT5n-ijOzoOp3c9LAA,59719
3
+ patchpal/cli.py,sha256=6Imrd4hGupIrTi9jnnfwvraNZ_Pq0VJxfo6aSjLRoCY,24131
4
+ patchpal/context.py,sha256=hdTUvyAXXUP47JY1Q3YJDU7noGAcHuBGlNuU272Fjp4,14831
5
+ patchpal/permissions.py,sha256=pVlzit2KFmCpfcbHrHhjPA0LPka04wOtaQdZCf3CCa0,10781
6
+ patchpal/skills.py,sha256=ESLPHkDI8DH4mnAbN8mIcbZ6Bis4vCcqS_NjlYPNCOs,3926
7
+ patchpal/system_prompt.md,sha256=LQzcILr41s65hk7JjaX_WzjUHBHCazVSrx_F_ErqTmA,10850
8
+ patchpal/tool_schema.py,sha256=dGEGYV160G9c7EnSMtnbQ_mYuoR1n6PHHE8T20BriYE,8357
9
+ patchpal/tools.py,sha256=YAUX2-8BBqjZEadIWlUdO-KV2-WHGazgKdMHkYRAExI,93819
10
+ patchpal-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ patchpal-0.6.0.dist-info/METADATA,sha256=hjleiaXTNaavuW0OygY1XPdbuflYxMQb0hAWw9pGWPw,57384
12
+ patchpal-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ patchpal-0.6.0.dist-info/entry_points.txt,sha256=XcuQikKu5i8Sd8AfHLuKxSE2RWByInTcQgWpP61sr48,47
14
+ patchpal-0.6.0.dist-info/top_level.txt,sha256=YWgv2F-_PIHCu-sF3AF8N1ut5_FbOT-VV6HB70pGSQ8,9
15
+ patchpal-0.6.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- patchpal/__init__.py,sha256=BZKk70eNcw6BLNVrj1TWs91vUTXbRMaHOHObNttZgrM,606
2
- patchpal/agent.py,sha256=uO-MA1ptt2FdpXrrbo07vVIPQj3radcIwP5izdjzmkQ,55519
3
- patchpal/cli.py,sha256=6UKoMxtow6Xd643vQ89tb6podepAgU1TjGlE2p8FFzE,23352
4
- patchpal/context.py,sha256=hdTUvyAXXUP47JY1Q3YJDU7noGAcHuBGlNuU272Fjp4,14831
5
- patchpal/permissions.py,sha256=pVlzit2KFmCpfcbHrHhjPA0LPka04wOtaQdZCf3CCa0,10781
6
- patchpal/skills.py,sha256=ESLPHkDI8DH4mnAbN8mIcbZ6Bis4vCcqS_NjlYPNCOs,3926
7
- patchpal/system_prompt.md,sha256=LQzcILr41s65hk7JjaX_WzjUHBHCazVSrx_F_ErqTmA,10850
8
- patchpal/tools.py,sha256=6zynI83wW6hwddIDKCYVRK8ICprj93bopp0K60PabS8,93042
9
- patchpal-0.4.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
10
- patchpal-0.4.5.dist-info/METADATA,sha256=9J5IPsOWx0OTPkghGpE6tF-6CnchyBJYFL_DBLr-uIA,44203
11
- patchpal-0.4.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- patchpal-0.4.5.dist-info/entry_points.txt,sha256=XcuQikKu5i8Sd8AfHLuKxSE2RWByInTcQgWpP61sr48,47
13
- patchpal-0.4.5.dist-info/top_level.txt,sha256=YWgv2F-_PIHCu-sF3AF8N1ut5_FbOT-VV6HB70pGSQ8,9
14
- patchpal-0.4.5.dist-info/RECORD,,