patchpal 0.4.4__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {patchpal-0.4.4/patchpal.egg-info → patchpal-0.5.0}/PKG-INFO +166 -5
  2. {patchpal-0.4.4 → patchpal-0.5.0}/README.md +165 -4
  3. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/__init__.py +1 -1
  4. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/agent.py +108 -9
  5. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/permissions.py +41 -3
  6. patchpal-0.5.0/patchpal/tool_schema.py +154 -0
  7. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/tools.py +181 -6
  8. {patchpal-0.4.4 → patchpal-0.5.0/patchpal.egg-info}/PKG-INFO +166 -5
  9. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal.egg-info/SOURCES.txt +3 -0
  10. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_agent.py +206 -0
  11. patchpal-0.5.0/tests/test_custom_tools.py +76 -0
  12. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_guardrails.py +233 -57
  13. patchpal-0.5.0/tests/test_permissions.py +302 -0
  14. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_tools.py +29 -0
  15. {patchpal-0.4.4 → patchpal-0.5.0}/LICENSE +0 -0
  16. {patchpal-0.4.4 → patchpal-0.5.0}/MANIFEST.in +0 -0
  17. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/cli.py +0 -0
  18. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/context.py +0 -0
  19. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/skills.py +0 -0
  20. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal/system_prompt.md +0 -0
  21. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal.egg-info/dependency_links.txt +0 -0
  22. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal.egg-info/entry_points.txt +0 -0
  23. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal.egg-info/requires.txt +0 -0
  24. {patchpal-0.4.4 → patchpal-0.5.0}/patchpal.egg-info/top_level.txt +0 -0
  25. {patchpal-0.4.4 → patchpal-0.5.0}/pyproject.toml +0 -0
  26. {patchpal-0.4.4 → patchpal-0.5.0}/setup.cfg +0 -0
  27. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_cli.py +0 -0
  28. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_context.py +0 -0
  29. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_operational_safety.py +0 -0
  30. {patchpal-0.4.4 → patchpal-0.5.0}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.4.4
3
+ Version: 0.5.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)
@@ -64,6 +76,7 @@ A key goal of this project is to approximate Claude Code's core functionality wh
64
76
  - [Air-Gapped and Offline Environments](https://github.com/amaiya/patchpal?tab=readme-ov-file#air-gapped-and-offline-environments)
65
77
  - [Maximum Security Mode](https://github.com/amaiya/patchpal?tab=readme-ov-file#maximum-security-mode)
66
78
  - [Usage](https://github.com/amaiya/patchpal?tab=readme-ov-file#usage)
79
+ - [Python API](https://github.com/amaiya/patchpal?tab=readme-ov-file#python-api)
67
80
  - [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration)
68
81
  - [Example Tasks](https://github.com/amaiya/patchpal?tab=readme-ov-file#example-tasks)
69
82
  - [Safety](https://github.com/amaiya/patchpal?tab=readme-ov-file#safety)
@@ -71,10 +84,6 @@ A key goal of this project is to approximate Claude Code's core functionality wh
71
84
  - [Troubleshooting](https://github.com/amaiya/patchpal?tab=readme-ov-file#troubleshooting)
72
85
 
73
86
 
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
87
 
79
88
  ## Installation
80
89
 
@@ -668,6 +677,158 @@ The agent will process your request and show you the results. You can continue w
668
677
  - **Interrupt Agent**: Press `Ctrl-C` during agent execution to stop the current task without exiting PatchPal
669
678
  - **Exit**: Type `exit`, `quit`, or press `Ctrl-C` at the prompt to exit PatchPal
670
679
 
680
+ ## Python API
681
+
682
+ 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.
683
+
684
+ **Basic Usage:**
685
+
686
+ ```python
687
+ from patchpal.agent import create_agent
688
+
689
+ # Create an agent (uses default model or PATCHPAL_MODEL env var)
690
+ agent = create_agent()
691
+
692
+ # Or specify a model explicitly
693
+ agent = create_agent(model_id="anthropic/claude-sonnet-4_5")
694
+
695
+ # Run the agent on a task
696
+ response = agent.run("List all Python files in this directory")
697
+ print(response)
698
+
699
+ # Continue the conversation (history is maintained)
700
+ response = agent.run("Now read the main agent file")
701
+ print(response)
702
+ ```
703
+
704
+ **Adding Custom Tools:**
705
+
706
+ One advantage of the Python API, is that it can easily be used with custom tools that you define as Python functions. Tool schemas are auto-generated from Python functions with type hints and docstrings:
707
+
708
+ ```python
709
+ from patchpal.agent import create_agent
710
+
711
+ def calculator(x: int, y: int, operation: str = "add") -> str:
712
+ """Perform basic arithmetic operations.
713
+
714
+ Args:
715
+ x: First number
716
+ y: Second number
717
+ operation: Operation to perform (add, subtract, multiply, divide)
718
+
719
+ Returns:
720
+ Result as a string
721
+ """
722
+ if operation == "add":
723
+ return f"{x} + {y} = {x + y}"
724
+ elif operation == "subtract":
725
+ return f"{x} - {y} = {x - y}"
726
+ elif operation == "multiply":
727
+ return f"{x} * {y} = {x * y}"
728
+ elif operation == "divide":
729
+ if y == 0:
730
+ return "Error: Cannot divide by zero"
731
+ return f"{x} / {y} = {x / y}"
732
+ return "Unknown operation"
733
+
734
+
735
+ def get_weather(city: str, units: str = "celsius") -> str:
736
+ """Get weather information for a city.
737
+
738
+ Args:
739
+ city: Name of the city
740
+ units: Temperature units (celsius or fahrenheit)
741
+
742
+ Returns:
743
+ Weather information string
744
+ """
745
+ # Your implementation here (API call, etc.)
746
+ return f"Weather in {city}: 22°{units[0].upper()}, Sunny"
747
+
748
+
749
+ # Create agent with custom tools
750
+ agent = create_agent(
751
+ model_id="anthropic/claude-sonnet-4-5",
752
+ custom_tools=[calculator, get_weather]
753
+ )
754
+
755
+ # Use the agent - it will call your custom tools when appropriate
756
+ response = agent.run("What's 15 multiplied by 23?")
757
+ print(response)
758
+
759
+ response = agent.run("What's the weather in Paris?")
760
+ print(response)
761
+ ```
762
+
763
+ **Key Points:**
764
+ - Custom tools are automatically converted to LLM tool schemas
765
+ - Functions should have type hints and Google-style docstrings
766
+ - The agent will call your functions when appropriate
767
+ - Tool execution follows the same permission system as built-in tools
768
+
769
+ **Advanced Usage:**
770
+
771
+ ```python
772
+ from patchpal.agent import PatchPalAgent
773
+
774
+ # Create agent with custom configuration
775
+ agent = PatchPalAgent(model_id="anthropic/claude-sonnet-4-5")
776
+
777
+ # Set custom max iterations for complex tasks
778
+ response = agent.run("Refactor the entire codebase", max_iterations=200)
779
+
780
+ # Access conversation history
781
+ print(f"Messages in history: {len(agent.messages)}")
782
+
783
+ # Check context window usage
784
+ stats = agent.context_manager.get_usage_stats(agent.messages)
785
+ print(f"Token usage: {stats['total_tokens']:,} / {stats['context_limit']:,}")
786
+ print(f"Usage: {stats['usage_percent']}%")
787
+
788
+ # Manually trigger compaction if needed
789
+ if agent.context_manager.needs_compaction(agent.messages):
790
+ agent._perform_auto_compaction()
791
+
792
+ # Track API costs (cumulative token counts across session)
793
+ print(f"Total LLM calls: {agent.total_llm_calls}")
794
+ print(f"Cumulative input tokens: {agent.cumulative_input_tokens:,}")
795
+ print(f"Cumulative output tokens: {agent.cumulative_output_tokens:,}")
796
+ print(f"Total tokens: {agent.cumulative_input_tokens + agent.cumulative_output_tokens:,}")
797
+ ```
798
+
799
+ **Use Cases:**
800
+ - **Interactive debugging**: Use in Jupyter notebooks for hands-on debugging with agent assistance
801
+ - **Automation scripts**: Build scripts that use the agent for complex tasks with human oversight
802
+ - **Custom workflows**: Integrate PatchPal into your own tools and pipelines
803
+ - **Code review assistance**: Programmatic code analysis with permission controls
804
+ - **Batch processing**: Process multiple tasks programmatically while maintaining control
805
+ - **Testing and evaluation**: Test agent behavior with different prompts and configurations
806
+
807
+ **Key Features:**
808
+ - **Human-in-the-loop design**: Permission prompts ensure human oversight (unlike fully autonomous frameworks)
809
+ - **Stateful conversations**: Agent maintains full conversation history
810
+ - **Custom tools**: Add your own Python functions as tools with automatic schema generation
811
+ - **Automatic context management**: Auto-compaction works the same as CLI
812
+ - **All built-in tools available**: File operations, git, web search, skills, etc.
813
+ - **Model flexibility**: Works with any LiteLLM-compatible model
814
+ - **Token tracking**: Monitor API usage and costs in real-time
815
+ - **Environment variables respected**: All `PATCHPAL_*` settings apply
816
+
817
+ **PatchPal vs. Other Agent Frameworks:**
818
+
819
+ Unlike fully autonomous agent frameworks (e.g., smolagents, autogen), PatchPal is explicitly designed for **human-in-the-loop workflows**:
820
+
821
+ | Feature | PatchPal | Autonomous Frameworks |
822
+ |---------|----------|----------------------|
823
+ | **Design Philosophy** | Human oversight & control | Autonomous execution |
824
+ | **Permission System** | Interactive prompts for sensitive operations | Typically no prompts |
825
+ | **Primary Use Case** | Code assistance, debugging, interactive tasks | Automated workflows, batch processing |
826
+ | **Safety Model** | Write boundary protection, command blocking | Varies by framework |
827
+ | **Custom Tools** | Yes, with automatic schema generation | Yes (varies by framework) |
828
+ | **Best For** | Developers who want AI assistance with control | Automation, research, agent benchmarks |
829
+
830
+ 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.
831
+
671
832
  ## Configuration
672
833
 
673
834
  PatchPal can be configured through `PATCHPAL_*` environment variables to customize behavior, security, and performance.
@@ -9,6 +9,18 @@
9
9
 
10
10
  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.
11
11
 
12
+ ```bash
13
+ $ls ./patchpal
14
+ __init__.py agent.py cli.py context.py permissions.py skills.py system_prompt.md tool_schema.py tools.py
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ $ pip install patchpal # install
21
+ $ patchpal # start
22
+ ```
23
+
12
24
  ## Table of Contents
13
25
 
14
26
  - [Installation](https://github.com/amaiya/patchpal?tab=readme-ov-file#installation)
@@ -27,6 +39,7 @@ A key goal of this project is to approximate Claude Code's core functionality wh
27
39
  - [Air-Gapped and Offline Environments](https://github.com/amaiya/patchpal?tab=readme-ov-file#air-gapped-and-offline-environments)
28
40
  - [Maximum Security Mode](https://github.com/amaiya/patchpal?tab=readme-ov-file#maximum-security-mode)
29
41
  - [Usage](https://github.com/amaiya/patchpal?tab=readme-ov-file#usage)
42
+ - [Python API](https://github.com/amaiya/patchpal?tab=readme-ov-file#python-api)
30
43
  - [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration)
31
44
  - [Example Tasks](https://github.com/amaiya/patchpal?tab=readme-ov-file#example-tasks)
32
45
  - [Safety](https://github.com/amaiya/patchpal?tab=readme-ov-file#safety)
@@ -34,10 +47,6 @@ A key goal of this project is to approximate Claude Code's core functionality wh
34
47
  - [Troubleshooting](https://github.com/amaiya/patchpal?tab=readme-ov-file#troubleshooting)
35
48
 
36
49
 
37
- ```bash
38
- $ls ./patchpal
39
- __init__.py agent.py cli.py context.py permissions.py skills.py system_prompt.md tools.py
40
- ```
41
50
 
42
51
  ## Installation
43
52
 
@@ -631,6 +640,158 @@ The agent will process your request and show you the results. You can continue w
631
640
  - **Interrupt Agent**: Press `Ctrl-C` during agent execution to stop the current task without exiting PatchPal
632
641
  - **Exit**: Type `exit`, `quit`, or press `Ctrl-C` at the prompt to exit PatchPal
633
642
 
643
+ ## Python API
644
+
645
+ 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.
646
+
647
+ **Basic Usage:**
648
+
649
+ ```python
650
+ from patchpal.agent import create_agent
651
+
652
+ # Create an agent (uses default model or PATCHPAL_MODEL env var)
653
+ agent = create_agent()
654
+
655
+ # Or specify a model explicitly
656
+ agent = create_agent(model_id="anthropic/claude-sonnet-4_5")
657
+
658
+ # Run the agent on a task
659
+ response = agent.run("List all Python files in this directory")
660
+ print(response)
661
+
662
+ # Continue the conversation (history is maintained)
663
+ response = agent.run("Now read the main agent file")
664
+ print(response)
665
+ ```
666
+
667
+ **Adding Custom Tools:**
668
+
669
+ One advantage of the Python API, is that it can easily be used with custom tools that you define as Python functions. Tool schemas are auto-generated from Python functions with type hints and docstrings:
670
+
671
+ ```python
672
+ from patchpal.agent import create_agent
673
+
674
+ def calculator(x: int, y: int, operation: str = "add") -> str:
675
+ """Perform basic arithmetic operations.
676
+
677
+ Args:
678
+ x: First number
679
+ y: Second number
680
+ operation: Operation to perform (add, subtract, multiply, divide)
681
+
682
+ Returns:
683
+ Result as a string
684
+ """
685
+ if operation == "add":
686
+ return f"{x} + {y} = {x + y}"
687
+ elif operation == "subtract":
688
+ return f"{x} - {y} = {x - y}"
689
+ elif operation == "multiply":
690
+ return f"{x} * {y} = {x * y}"
691
+ elif operation == "divide":
692
+ if y == 0:
693
+ return "Error: Cannot divide by zero"
694
+ return f"{x} / {y} = {x / y}"
695
+ return "Unknown operation"
696
+
697
+
698
+ def get_weather(city: str, units: str = "celsius") -> str:
699
+ """Get weather information for a city.
700
+
701
+ Args:
702
+ city: Name of the city
703
+ units: Temperature units (celsius or fahrenheit)
704
+
705
+ Returns:
706
+ Weather information string
707
+ """
708
+ # Your implementation here (API call, etc.)
709
+ return f"Weather in {city}: 22°{units[0].upper()}, Sunny"
710
+
711
+
712
+ # Create agent with custom tools
713
+ agent = create_agent(
714
+ model_id="anthropic/claude-sonnet-4-5",
715
+ custom_tools=[calculator, get_weather]
716
+ )
717
+
718
+ # Use the agent - it will call your custom tools when appropriate
719
+ response = agent.run("What's 15 multiplied by 23?")
720
+ print(response)
721
+
722
+ response = agent.run("What's the weather in Paris?")
723
+ print(response)
724
+ ```
725
+
726
+ **Key Points:**
727
+ - Custom tools are automatically converted to LLM tool schemas
728
+ - Functions should have type hints and Google-style docstrings
729
+ - The agent will call your functions when appropriate
730
+ - Tool execution follows the same permission system as built-in tools
731
+
732
+ **Advanced Usage:**
733
+
734
+ ```python
735
+ from patchpal.agent import PatchPalAgent
736
+
737
+ # Create agent with custom configuration
738
+ agent = PatchPalAgent(model_id="anthropic/claude-sonnet-4-5")
739
+
740
+ # Set custom max iterations for complex tasks
741
+ response = agent.run("Refactor the entire codebase", max_iterations=200)
742
+
743
+ # Access conversation history
744
+ print(f"Messages in history: {len(agent.messages)}")
745
+
746
+ # Check context window usage
747
+ stats = agent.context_manager.get_usage_stats(agent.messages)
748
+ print(f"Token usage: {stats['total_tokens']:,} / {stats['context_limit']:,}")
749
+ print(f"Usage: {stats['usage_percent']}%")
750
+
751
+ # Manually trigger compaction if needed
752
+ if agent.context_manager.needs_compaction(agent.messages):
753
+ agent._perform_auto_compaction()
754
+
755
+ # Track API costs (cumulative token counts across session)
756
+ print(f"Total LLM calls: {agent.total_llm_calls}")
757
+ print(f"Cumulative input tokens: {agent.cumulative_input_tokens:,}")
758
+ print(f"Cumulative output tokens: {agent.cumulative_output_tokens:,}")
759
+ print(f"Total tokens: {agent.cumulative_input_tokens + agent.cumulative_output_tokens:,}")
760
+ ```
761
+
762
+ **Use Cases:**
763
+ - **Interactive debugging**: Use in Jupyter notebooks for hands-on debugging with agent assistance
764
+ - **Automation scripts**: Build scripts that use the agent for complex tasks with human oversight
765
+ - **Custom workflows**: Integrate PatchPal into your own tools and pipelines
766
+ - **Code review assistance**: Programmatic code analysis with permission controls
767
+ - **Batch processing**: Process multiple tasks programmatically while maintaining control
768
+ - **Testing and evaluation**: Test agent behavior with different prompts and configurations
769
+
770
+ **Key Features:**
771
+ - **Human-in-the-loop design**: Permission prompts ensure human oversight (unlike fully autonomous frameworks)
772
+ - **Stateful conversations**: Agent maintains full conversation history
773
+ - **Custom tools**: Add your own Python functions as tools with automatic schema generation
774
+ - **Automatic context management**: Auto-compaction works the same as CLI
775
+ - **All built-in tools available**: File operations, git, web search, skills, etc.
776
+ - **Model flexibility**: Works with any LiteLLM-compatible model
777
+ - **Token tracking**: Monitor API usage and costs in real-time
778
+ - **Environment variables respected**: All `PATCHPAL_*` settings apply
779
+
780
+ **PatchPal vs. Other Agent Frameworks:**
781
+
782
+ Unlike fully autonomous agent frameworks (e.g., smolagents, autogen), PatchPal is explicitly designed for **human-in-the-loop workflows**:
783
+
784
+ | Feature | PatchPal | Autonomous Frameworks |
785
+ |---------|----------|----------------------|
786
+ | **Design Philosophy** | Human oversight & control | Autonomous execution |
787
+ | **Permission System** | Interactive prompts for sensitive operations | Typically no prompts |
788
+ | **Primary Use Case** | Code assistance, debugging, interactive tasks | Automated workflows, batch processing |
789
+ | **Safety Model** | Write boundary protection, command blocking | Varies by framework |
790
+ | **Custom Tools** | Yes, with automatic schema generation | Yes (varies by framework) |
791
+ | **Best For** | Developers who want AI assistance with control | Automation, research, agent benchmarks |
792
+
793
+ 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.
794
+
634
795
  ## Configuration
635
796
 
636
797
  PatchPal can be configured through `PATCHPAL_*` environment variables to customize behavior, security, and performance.
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.5.0"
4
4
 
5
5
  from patchpal.agent import create_agent
6
6
  from patchpal.tools import (
@@ -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)
@@ -105,14 +105,19 @@ class PermissionManager:
105
105
  self.session_grants[tool_name] = True
106
106
 
107
107
  def request_permission(
108
- self, tool_name: str, description: str, pattern: Optional[str] = None
108
+ self,
109
+ tool_name: str,
110
+ description: str,
111
+ pattern: Optional[str] = None,
112
+ context: Optional[str] = None,
109
113
  ) -> bool:
110
114
  """Request permission from user to execute a tool.
111
115
 
112
116
  Args:
113
117
  tool_name: Name of the tool (e.g., 'run_shell', 'apply_patch')
114
118
  description: Human-readable description of what will be executed
115
- pattern: Optional pattern for matching (e.g., 'pytest' for pytest commands)
119
+ pattern: Optional pattern for matching (e.g., 'pytest' for pytest commands, 'python:/tmp' for python in /tmp)
120
+ context: Optional context string for display (e.g., working directory)
116
121
 
117
122
  Returns:
118
123
  True if permission granted, False otherwise
@@ -135,10 +140,43 @@ class PermissionManager:
135
140
  sys.stderr.write("-" * 80 + "\n")
136
141
 
137
142
  # Get user input
143
+ # Get the actual repository root for display (match Claude Code's UX)
144
+ from pathlib import Path
145
+
146
+ repo_root = Path(".").resolve()
147
+
138
148
  sys.stderr.write("\nDo you want to proceed?\n")
139
149
  sys.stderr.write(" 1. Yes\n")
140
150
  if pattern:
141
- sys.stderr.write(f" 2. Yes, and don't ask again this session for '{pattern}'\n")
151
+ # For file operations, pattern is the directory (e.g., "tmp/")
152
+ # For shell commands, pattern is the command name (e.g., "python")
153
+ if tool_name in ("edit_file", "apply_patch"):
154
+ # File operation - show directory context
155
+ if pattern.endswith("/"):
156
+ # Outside repo - directory pattern like "tmp/"
157
+ sys.stderr.write(
158
+ f" 2. Yes, and don't ask again this session for edits in {pattern}\n"
159
+ )
160
+ else:
161
+ # Inside repo - file path pattern
162
+ sys.stderr.write(
163
+ f" 2. Yes, and don't ask again this session for edits to {pattern}\n"
164
+ )
165
+ elif tool_name == "run_shell":
166
+ # Shell command - show working directory context
167
+ # Extract command name from pattern (could be "python" or "python@/tmp")
168
+ # Using @ separator for cross-platform compatibility (: conflicts with Windows paths)
169
+ command_name = pattern.split("@")[0] if "@" in pattern else pattern
170
+
171
+ # Use context (working_dir) if provided, otherwise use repo_root
172
+ display_dir = context if context else str(repo_root)
173
+
174
+ sys.stderr.write(
175
+ f" 2. Yes, and don't ask again this session for '{command_name}' commands in {display_dir}\n"
176
+ )
177
+ else:
178
+ # Other tools
179
+ sys.stderr.write(f" 2. Yes, and don't ask again this session for '{pattern}'\n")
142
180
  else:
143
181
  sys.stderr.write(f" 2. Yes, and don't ask again this session for {tool_name}\n")
144
182
  sys.stderr.write(" 3. No, and tell me what to do differently\n")