patchpal 0.22.7__tar.gz → 0.23.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 (68) hide show
  1. {patchpal-0.22.7/patchpal.egg-info → patchpal-0.23.0}/PKG-INFO +13 -1
  2. {patchpal-0.22.7 → patchpal-0.23.0}/README.md +12 -0
  3. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/__init__.py +1 -1
  4. patchpal-0.23.0/patchpal/agent/bedrock_profile_utils.py +226 -0
  5. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/agent/function_calling.py +123 -23
  6. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/autopilot.py +6 -0
  7. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/interactive.py +9 -0
  8. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/code_analysis.py +6 -2
  9. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/common.py +34 -0
  10. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/definitions.py +8 -0
  11. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/find_tool.py +48 -15
  12. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/repo_map.py +14 -3
  13. {patchpal-0.22.7 → patchpal-0.23.0/patchpal.egg-info}/PKG-INFO +13 -1
  14. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal.egg-info/SOURCES.txt +1 -0
  15. {patchpal-0.22.7 → patchpal-0.23.0}/LICENSE +0 -0
  16. {patchpal-0.22.7 → patchpal-0.23.0}/MANIFEST.in +0 -0
  17. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/agent/__init__.py +0 -0
  18. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/agent/react.py +0 -0
  19. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/__init__.py +0 -0
  20. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/mcp.py +0 -0
  21. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/sandbox.py +0 -0
  22. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/cli/streaming.py +0 -0
  23. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/config.py +0 -0
  24. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/context.py +0 -0
  25. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/permissions.py +0 -0
  26. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/prompts/react_prompt.md +0 -0
  27. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/prompts/system_prompt.md +0 -0
  28. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/skills.py +0 -0
  29. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/__init__.py +0 -0
  30. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/audit.py +0 -0
  31. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/file_reading.py +0 -0
  32. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/file_writing.py +0 -0
  33. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/grep_tool.py +0 -0
  34. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/image_handler.py +0 -0
  35. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/mcp.py +0 -0
  36. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/shell_tools.py +0 -0
  37. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/todo_tools.py +0 -0
  38. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/tool_schema.py +0 -0
  39. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/user_interaction.py +0 -0
  40. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal/tools/web_tools.py +0 -0
  41. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal.egg-info/dependency_links.txt +0 -0
  42. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal.egg-info/entry_points.txt +0 -0
  43. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal.egg-info/requires.txt +0 -0
  44. {patchpal-0.22.7 → patchpal-0.23.0}/patchpal.egg-info/top_level.txt +0 -0
  45. {patchpal-0.22.7 → patchpal-0.23.0}/pyproject.toml +0 -0
  46. {patchpal-0.22.7 → patchpal-0.23.0}/setup.cfg +0 -0
  47. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_agent.py +0 -0
  48. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_cli.py +0 -0
  49. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_config_dynamic.py +0 -0
  50. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_context.py +0 -0
  51. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_custom_tools.py +0 -0
  52. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_enabled_tools.py +0 -0
  53. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_find_tool.py +0 -0
  54. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_guardrails.py +0 -0
  55. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_image_blocking.py +0 -0
  56. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_maximum_security.py +0 -0
  57. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_mcp_config.py +0 -0
  58. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_memory.py +0 -0
  59. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_operational_safety.py +0 -0
  60. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_optional_tools.py +0 -0
  61. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_permissions.py +0 -0
  62. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_react.py +0 -0
  63. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_reasoning_content.py +0 -0
  64. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_repo_map.py +0 -0
  65. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_simplified_prompt.py +0 -0
  66. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_skills.py +0 -0
  67. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_streaming.py +0 -0
  68. {patchpal-0.22.7 → patchpal-0.23.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.22.7
3
+ Version: 0.23.0
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -177,6 +177,18 @@ While originally designed for software development, PatchPal is also a general-p
177
177
  2. PatchPal includes a [unique guardrails system](https://amaiya.github.io/patchpal/safety/) that is better suited to privacy-conscious use cases involving sensitive data.
178
178
  3. We needed an agent harness that seamlessly works with [both local and cloud models](https://amaiya.github.io/patchpal/models/overview/#supported-models), including AWS GovCloud Bedrock models.
179
179
 
180
+ > On Windows Subsystem for Linux (WSL), why is it stalling intermittently at "Thinking..."?
181
+
182
+ This is a [known issue](https://github.com/microsoft/WSL/issues/6264#issuecomment-762154193) with WSL2.
183
+
184
+ Try examining and then lowering the `mtu`:
185
+
186
+ ```bash
187
+ $ cat /sys/class/net/eth1/mtu
188
+ 1427
189
+
190
+ $ sudo ip link set eth1 mtu 1400
191
+ ```
180
192
 
181
193
  ## Documentation
182
194
 
@@ -129,6 +129,18 @@ While originally designed for software development, PatchPal is also a general-p
129
129
  2. PatchPal includes a [unique guardrails system](https://amaiya.github.io/patchpal/safety/) that is better suited to privacy-conscious use cases involving sensitive data.
130
130
  3. We needed an agent harness that seamlessly works with [both local and cloud models](https://amaiya.github.io/patchpal/models/overview/#supported-models), including AWS GovCloud Bedrock models.
131
131
 
132
+ > On Windows Subsystem for Linux (WSL), why is it stalling intermittently at "Thinking..."?
133
+
134
+ This is a [known issue](https://github.com/microsoft/WSL/issues/6264#issuecomment-762154193) with WSL2.
135
+
136
+ Try examining and then lowering the `mtu`:
137
+
138
+ ```bash
139
+ $ cat /sys/class/net/eth1/mtu
140
+ 1427
141
+
142
+ $ sudo ip link set eth1 mtu 1400
143
+ ```
132
144
 
133
145
  ## Documentation
134
146
 
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.22.7"
3
+ __version__ = "0.23.0"
4
4
 
5
5
  from patchpal.agent import create_agent, create_react_agent
6
6
  from patchpal.cli.autopilot import autopilot_loop
@@ -0,0 +1,226 @@
1
+ """Utilities for AWS Bedrock application inference profiles.
2
+
3
+ Application inference profiles (tagged profiles) don't include model names in their ARNs,
4
+ making it impossible to statically determine model capabilities or pricing. This module
5
+ provides functions to detect the underlying model and its capabilities at runtime.
6
+ """
7
+
8
+ import litellm
9
+
10
+
11
+ def _extract_model_from_arn(arn: str) -> str | None:
12
+ """Try to extract underlying model info from an inference profile ARN using AWS API.
13
+
14
+ Args:
15
+ arn: The inference profile ARN
16
+
17
+ Returns:
18
+ Model name if found, None otherwise
19
+ """
20
+ try:
21
+ import os
22
+
23
+ import boto3
24
+
25
+ # Extract region from ARN (arn:aws-us-gov:bedrock:us-gov-east-1:...)
26
+ parts = arn.split(":")
27
+ if len(parts) >= 4:
28
+ region = parts[3]
29
+ else:
30
+ region = os.getenv("AWS_REGION_NAME") or os.getenv("AWS_REGION") or "us-east-1"
31
+
32
+ # Create bedrock client
33
+ bedrock = boto3.client("bedrock", region_name=region)
34
+
35
+ # Get inference profile details
36
+ response = bedrock.get_inference_profile(inferenceProfileIdentifier=arn)
37
+
38
+ # Extract model info from response
39
+ if "models" in response and response["models"]:
40
+ # Get first model from the profile
41
+ first_model = response["models"][0]
42
+ if "modelArn" in first_model:
43
+ # Extract model ID from ARN
44
+ # e.g., arn:aws-us-gov:bedrock:us-gov-west-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0
45
+ model_arn = first_model["modelArn"]
46
+
47
+ # Try multiple extraction methods
48
+ if "foundation-model" in model_arn:
49
+ # Split on the foundation-model part
50
+ parts = model_arn.split("foundation-model/")
51
+ if len(parts) > 1:
52
+ return parts[1]
53
+
54
+ return None
55
+ except Exception:
56
+ # API call failed or boto3 not available
57
+ return None
58
+
59
+
60
+ def detect_model_capabilities(
61
+ model_id: str, litellm_kwargs: dict = None
62
+ ) -> tuple[bool, str | None]:
63
+ """Detect prompt caching support and underlying model name for application inference profiles.
64
+
65
+ This is useful for application inference profiles (tagged profiles) where the
66
+ underlying model name is not in the ARN, making it impossible to statically
67
+ determine model capabilities or pricing.
68
+
69
+ Args:
70
+ model_id: Full LiteLLM model identifier (e.g., bedrock/converse/arn:...)
71
+ litellm_kwargs: Optional kwargs to pass to litellm.completion
72
+
73
+ Returns:
74
+ Tuple of (caching_supported: bool, model_name: str | None)
75
+ - caching_supported: True if prompt caching works with tools
76
+ - model_name: Detected model name from response metadata (e.g., "claude-3-5-sonnet-20241022")
77
+ """
78
+ if litellm_kwargs is None:
79
+ litellm_kwargs = {}
80
+
81
+ # Create a minimal test message with cache markers
82
+ test_messages = [
83
+ {
84
+ "role": "user",
85
+ "content": [
86
+ {
87
+ "type": "text",
88
+ "text": "What is the capital of France?",
89
+ "cache_control": {"type": "ephemeral"},
90
+ }
91
+ ],
92
+ }
93
+ ]
94
+
95
+ # Include a minimal tool to match real usage (some models only support caching without tools)
96
+ test_tools = [
97
+ {
98
+ "type": "function",
99
+ "function": {
100
+ "name": "get_info",
101
+ "description": "Get information",
102
+ "parameters": {
103
+ "type": "object",
104
+ "properties": {"query": {"type": "string", "description": "The query"}},
105
+ "required": ["query"],
106
+ },
107
+ },
108
+ }
109
+ ]
110
+
111
+ caching_supported = False
112
+ detected_model = None
113
+
114
+ # For ARNs, try to get model info from AWS API first
115
+ if "arn:aws" in model_id and "inference-profile" in model_id:
116
+ # Extract just the ARN (remove bedrock/converse/ prefix if present)
117
+ arn = model_id.replace("bedrock/converse/", "").replace("bedrock/", "")
118
+ detected_model = _extract_model_from_arn(arn)
119
+
120
+ try:
121
+ # Try with Anthropic-style cache_control AND tools (matches real usage)
122
+ response = litellm.completion(
123
+ model=model_id,
124
+ messages=test_messages,
125
+ tools=test_tools,
126
+ tool_choice="auto", # Include tool_choice like the real agent
127
+ max_tokens=10,
128
+ **litellm_kwargs,
129
+ )
130
+ # If we got here without error, caching is supported
131
+ caching_supported = True
132
+
133
+ # Only try to extract model from response if we didn't get it from AWS API
134
+ if not detected_model:
135
+ # Try to extract model name from response metadata
136
+ # Bedrock responses include model info in various places
137
+ if hasattr(response, "_hidden_params") and response._hidden_params:
138
+ # LiteLLM stores raw response data here
139
+ hidden = response._hidden_params
140
+
141
+ # Check optional_params which may contain raw boto3 response
142
+ if "optional_params" in hidden and isinstance(hidden["optional_params"], dict):
143
+ optional = hidden["optional_params"]
144
+ # Bedrock converse API may include model info in the response
145
+ if "model" in optional:
146
+ detected_model = optional["model"]
147
+ elif "modelId" in optional:
148
+ detected_model = optional["modelId"]
149
+
150
+ # Check standard fields
151
+ if not detected_model and "model_id" in hidden and hidden["model_id"]:
152
+ detected_model = hidden["model_id"]
153
+ elif not detected_model and "model" in hidden and hidden["model"]:
154
+ detected_model = hidden["model"]
155
+
156
+ # Check response metadata
157
+ if not detected_model and hasattr(response, "model"):
158
+ model_val = response.model
159
+ # Skip if it's just the ARN we passed in
160
+ if model_val and "application-inference-profile" not in model_val:
161
+ detected_model = model_val
162
+
163
+ # Try to extract from response choices/usage if available
164
+ if not detected_model and hasattr(response, "usage"):
165
+ usage = response.usage
166
+ if hasattr(usage, "model") and usage.model:
167
+ detected_model = usage.model
168
+
169
+ except Exception as e:
170
+ error_msg = str(e).lower()
171
+ # Check for caching-specific errors
172
+ if any(
173
+ phrase in error_msg
174
+ for phrase in [
175
+ "prompt caching",
176
+ "cache_control",
177
+ "cachepoint",
178
+ "unsupported model",
179
+ "did not allow prompt caching",
180
+ ]
181
+ ):
182
+ # Caching not supported, but still try to detect model without caching
183
+ caching_supported = False
184
+
185
+ # Only retry if we don't already have model from AWS API
186
+ if not detected_model:
187
+ try:
188
+ # Retry without cache markers to detect model
189
+ simple_messages = [{"role": "user", "content": "Hi"}]
190
+ response = litellm.completion(
191
+ model=model_id,
192
+ messages=simple_messages,
193
+ tools=test_tools,
194
+ max_tokens=5,
195
+ **litellm_kwargs,
196
+ )
197
+ # Try to extract model from response
198
+ if hasattr(response, "_hidden_params") and response._hidden_params:
199
+ hidden = response._hidden_params
200
+ if "model_id" in hidden:
201
+ detected_model = hidden["model_id"]
202
+ elif "model" in hidden:
203
+ detected_model = hidden["model"]
204
+ if not detected_model and hasattr(response, "model"):
205
+ detected_model = response.model
206
+ except Exception:
207
+ pass # Could not detect model
208
+ else:
209
+ # Different error (auth, network, etc.)
210
+ caching_supported = False
211
+
212
+ return caching_supported, detected_model
213
+
214
+
215
+ def test_prompt_caching_support(model_id: str, litellm_kwargs: dict = None) -> bool:
216
+ """Test if a model supports prompt caching (backward compatibility wrapper).
217
+
218
+ Args:
219
+ model_id: Full LiteLLM model identifier (e.g., bedrock/converse/arn:...)
220
+ litellm_kwargs: Optional kwargs to pass to litellm.completion
221
+
222
+ Returns:
223
+ True if prompt caching is supported, False otherwise
224
+ """
225
+ caching_supported, _ = detect_model_capabilities(model_id, litellm_kwargs)
226
+ return caching_supported
@@ -28,14 +28,36 @@ LLM_TIMEOUT = config.LLM_TIMEOUT
28
28
 
29
29
 
30
30
  def _is_bedrock_arn(model_id: str) -> bool:
31
- """Check if a model ID is a Bedrock ARN."""
31
+ """Check if a model ID is a Bedrock ARN.
32
+
33
+ Supports all Bedrock inference profile ARN formats:
34
+ - arn:aws:bedrock:region:account:inference-profile/profile-id
35
+ - arn:aws-us-gov:bedrock:region:account:inference-profile/profile-id
36
+ - arn:aws:bedrock:region:account:application-inference-profile/app-id
37
+ - arn:aws-us-gov:bedrock:region:account:application-inference-profile/app-id
38
+ """
32
39
  return (
33
40
  model_id.startswith("arn:aws")
34
41
  and ":bedrock:" in model_id
35
- and ":inference-profile/" in model_id
42
+ and "inference-profile/" in model_id
36
43
  )
37
44
 
38
45
 
46
+ def _is_application_inference_profile(model_id: str) -> bool:
47
+ """Check if a model ID is a Bedrock application inference profile (tagged profile).
48
+
49
+ Application inference profiles are tagged profiles that don't include the underlying
50
+ model name in the ARN, making it impossible to statically determine model capabilities.
51
+
52
+ Args:
53
+ model_id: Model identifier (may or may not have bedrock/ prefix)
54
+
55
+ Returns:
56
+ True if this is an application inference profile ARN
57
+ """
58
+ return ":application-inference-profile/" in model_id
59
+
60
+
39
61
  def _normalize_bedrock_model_id(model_id: str) -> str:
40
62
  """Normalize Bedrock model ID to ensure it has the bedrock/ prefix.
41
63
 
@@ -51,7 +73,11 @@ def _normalize_bedrock_model_id(model_id: str) -> str:
51
73
 
52
74
  # If it looks like a Bedrock ARN, add the prefix
53
75
  if _is_bedrock_arn(model_id):
54
- return f"bedrock/{model_id}"
76
+ # Application inference profiles require the converse API
77
+ if ":application-inference-profile/" in model_id:
78
+ return f"bedrock/converse/{model_id}"
79
+ else:
80
+ return f"bedrock/{model_id}"
55
81
 
56
82
  # If it's a standard Bedrock model ID (e.g., anthropic.claude-v2)
57
83
  # Check if it looks like a Bedrock model format
@@ -274,6 +300,10 @@ def _supports_prompt_caching(model_id: str) -> bool:
274
300
  # Bedrock Nova models support caching
275
301
  if model_id.startswith("bedrock/") and "amazon.nova" in model_id.lower():
276
302
  return True
303
+ # Bedrock ARNs (all types): enable caching and let Bedrock handle it
304
+ # If the underlying model doesn't support caching, Bedrock will ignore the markers
305
+ if model_id.startswith("bedrock/") and "inference-profile/" in model_id:
306
+ return True
277
307
  return False
278
308
 
279
309
 
@@ -301,12 +331,14 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
301
331
 
302
332
  # Determine cache marker format based on provider
303
333
  # Anthropic models (direct or via Bedrock) use cache_control
304
- # Other Bedrock models (Nova, etc.) use cachePoint
305
- if model_id.startswith("bedrock/") and "anthropic" not in model_id.lower():
306
- # Non-Anthropic Bedrock models (Nova, etc.) use cachePoint
334
+ # Nova models use cachePoint
335
+ # For Bedrock ARNs without model name, default to cache_control (most common)
336
+ if model_id.startswith("bedrock/") and "amazon.nova" in model_id.lower():
337
+ # Nova models explicitly use cachePoint
307
338
  cache_marker = {"cachePoint": {"type": "default"}}
308
339
  else:
309
340
  # Anthropic models (direct or via Bedrock) use cache_control
341
+ # Also default for Bedrock ARNs (most use Anthropic/Claude)
310
342
  cache_marker = {"cache_control": {"type": "ephemeral"}}
311
343
 
312
344
  # Count existing cache markers across all messages
@@ -509,6 +541,49 @@ class PatchPalAgent:
509
541
  if litellm_kwargs:
510
542
  self.litellm_kwargs.update(litellm_kwargs)
511
543
 
544
+ # Detect capabilities for application inference profiles
545
+ # These ARNs don't include model names, so we test with a minimal request
546
+ self.prompt_caching_supported = None # None = untested, True/False after test
547
+ self.detected_model_name = None # Store detected model for cost tracking
548
+ if _is_application_inference_profile(self.model_id):
549
+ # Test capabilities with a minimal request
550
+ try:
551
+ from patchpal.agent.bedrock_profile_utils import detect_model_capabilities
552
+
553
+ print("\033[2mℹ️ Detecting model capabilities...\033[0m", flush=True)
554
+ self.prompt_caching_supported, self.detected_model_name = detect_model_capabilities(
555
+ self.model_id, self.litellm_kwargs
556
+ )
557
+ if self.prompt_caching_supported:
558
+ print("\033[2m✓ Prompt caching is supported\033[0m", flush=True)
559
+ else:
560
+ print("\033[2m✗ Prompt caching is not supported\033[0m", flush=True)
561
+
562
+ if self.detected_model_name:
563
+ print(f"\033[2m✓ Detected model: {self.detected_model_name}\033[0m", flush=True)
564
+
565
+ # Update context limit based on detected model
566
+ try:
567
+ model_info = litellm.get_model_info(f"bedrock/{self.detected_model_name}")
568
+ max_input = model_info.get("max_input_tokens")
569
+ if max_input and isinstance(max_input, (int, float)) and max_input > 0:
570
+ self.context_manager.context_limit = int(max_input)
571
+ except Exception:
572
+ pass # Keep default limit
573
+ else:
574
+ print(
575
+ "\033[2m⚠ Could not detect underlying model name (cost tracking may be inaccurate)\033[0m",
576
+ flush=True,
577
+ )
578
+ except Exception:
579
+ # If test fails, assume caching not supported and model unknown
580
+ self.prompt_caching_supported = False
581
+ self.detected_model_name = None
582
+ elif _supports_prompt_caching(self.model_id):
583
+ self.prompt_caching_supported = True
584
+ else:
585
+ self.prompt_caching_supported = False
586
+
512
587
  # Load MEMORY.md if it exists and has non-template content
513
588
  self._load_project_memory()
514
589
 
@@ -847,7 +922,17 @@ It's currently empty (just the template). The file is automatically loaded at se
847
922
  float: The calculated cost in dollars
848
923
  """
849
924
  try:
850
- model_info = litellm.get_model_info(self.model_id)
925
+ # For application inference profiles, use detected model name for pricing
926
+ model_for_pricing = self.model_id
927
+ if _is_application_inference_profile(self.model_id) and self.detected_model_name:
928
+ # Map detected model name to a pricing model
929
+ # e.g., "anthropic.claude-3-5-sonnet-20241022-v2:0" -> "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"
930
+ if not self.detected_model_name.startswith("bedrock/"):
931
+ model_for_pricing = f"bedrock/{self.detected_model_name}"
932
+ else:
933
+ model_for_pricing = self.detected_model_name
934
+
935
+ model_info = litellm.get_model_info(model_for_pricing)
851
936
  input_cost_per_token = model_info.get("input_cost_per_token", 0)
852
937
  output_cost_per_token = model_info.get("output_cost_per_token", 0)
853
938
 
@@ -881,19 +966,26 @@ It's currently empty (just the template). The file is automatically loaded at se
881
966
  cost += cache_read_tokens * input_cost_per_token * 0.1
882
967
 
883
968
  # Handle OpenAI cache pricing (prompt_tokens_details.cached_tokens)
969
+ # IMPORTANT: For Bedrock, LiteLLM populates prompt_tokens_details.cached_tokens
970
+ # with cache_read_input_tokens for compatibility, but we already handled those above.
971
+ # Only process this field if we're NOT using Bedrock-style cache fields.
884
972
  openai_cached_tokens = 0
885
- if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None:
886
- prompt_details = usage.prompt_tokens_details
887
- if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens:
888
- # Ensure cached_tokens is a number, not a mock or None
889
- if isinstance(prompt_details.cached_tokens, (int, float)):
890
- openai_cached_tokens = prompt_details.cached_tokens
891
- # Use cached_input_cost_per_token if available, otherwise fallback to 0.5x multiplier
892
- if cached_input_cost_per_token > 0:
893
- cost += openai_cached_tokens * cached_input_cost_per_token
894
- else:
895
- # Fallback: OpenAI cached tokens typically cost 50% of regular input
896
- cost += openai_cached_tokens * input_cost_per_token * 0.5
973
+ if not (cache_creation_tokens or cache_read_tokens): # Only for non-Bedrock models
974
+ if (
975
+ hasattr(usage, "prompt_tokens_details")
976
+ and usage.prompt_tokens_details is not None
977
+ ):
978
+ prompt_details = usage.prompt_tokens_details
979
+ if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens:
980
+ # Ensure cached_tokens is a number, not a mock or None
981
+ if isinstance(prompt_details.cached_tokens, (int, float)):
982
+ openai_cached_tokens = prompt_details.cached_tokens
983
+ # Use cached_input_cost_per_token if available, otherwise fallback to 0.5x multiplier
984
+ if cached_input_cost_per_token > 0:
985
+ cost += openai_cached_tokens * cached_input_cost_per_token
986
+ else:
987
+ # Fallback: OpenAI cached tokens typically cost 50% of regular input
988
+ cost += openai_cached_tokens * input_cost_per_token * 0.5
897
989
 
898
990
  # Regular input tokens (excluding all cache tokens)
899
991
  regular_input = (
@@ -1048,8 +1140,10 @@ It's currently empty (just the template). The file is automatically loaded at se
1048
1140
  # Filter images if BLOCK_IMAGES is enabled (for non-vision models or user preference)
1049
1141
  messages = self.image_handler.filter_images_if_blocked(messages)
1050
1142
 
1051
- # Apply prompt caching for supported models (Anthropic/Claude)
1052
- messages = _apply_prompt_caching(messages, self.model_id)
1143
+ # Apply prompt caching for supported models
1144
+ # Check instance variable for dynamically-tested models (application inference profiles)
1145
+ if self.prompt_caching_supported:
1146
+ messages = _apply_prompt_caching(messages, self.model_id)
1053
1147
 
1054
1148
  # Use LiteLLM for all providers
1055
1149
  try:
@@ -1260,6 +1354,7 @@ It's currently empty (just the template). The file is automatically loaded at se
1260
1354
  )
1261
1355
  elif tool_name == "get_repo_map":
1262
1356
  max_files = tool_args.get("max_files", 100)
1357
+ max_depth = tool_args.get("max_depth")
1263
1358
  patterns = ""
1264
1359
  if tool_args.get("include_patterns"):
1265
1360
  patterns = (
@@ -1269,8 +1364,9 @@ It's currently empty (just the template). The file is automatically loaded at se
1269
1364
  patterns = (
1270
1365
  f" (exclude: {', '.join(tool_args['exclude_patterns'])})"
1271
1366
  )
1367
+ depth_info = f", depth≤{max_depth}" if max_depth is not None else ""
1272
1368
  print(
1273
- f"\033[2m🗺️ Generating repository map (max {max_files} files{patterns})...\033[0m",
1369
+ f"\033[2m🗺️ Generating repository map (max {max_files} files{depth_info}{patterns})...\033[0m",
1274
1370
  flush=True,
1275
1371
  )
1276
1372
  elif tool_name == "get_file_info":
@@ -1295,8 +1391,12 @@ It's currently empty (just the template). The file is automatically loaded at se
1295
1391
  )
1296
1392
  elif tool_name == "find":
1297
1393
  pattern_desc = tool_args.get("pattern", "*")
1394
+ max_depth = tool_args.get("max_depth")
1395
+ depth_info = (
1396
+ f" (depth≤{max_depth})" if max_depth is not None else ""
1397
+ )
1298
1398
  print(
1299
- f"\033[2m📂 Finding files: {pattern_desc}\033[0m",
1399
+ f"\033[2m📂 Finding files: {pattern_desc}{depth_info}\033[0m",
1300
1400
  flush=True,
1301
1401
  )
1302
1402
  elif tool_name == "list_skills":
@@ -125,6 +125,12 @@ def autopilot_loop(
125
125
  # The agent's conversation history accumulates, so it can see all previous work
126
126
  response = agent.run(prompt, max_iterations=100)
127
127
 
128
+ # Reset operation counter after each conversation turn
129
+ # This allows long autopilot sessions without hitting the global limit
130
+ from patchpal.tools.common import reset_operation_counter
131
+
132
+ reset_operation_counter()
133
+
128
134
  # Log agent response to audit log
129
135
  try:
130
136
  from patchpal.tools.audit import log_agent_response
@@ -15,6 +15,7 @@ from rich.markdown import Markdown
15
15
  from patchpal.agent import create_agent, create_react_agent
16
16
  from patchpal.config import config
17
17
  from patchpal.tools import audit_logger, set_require_permission_for_all
18
+ from patchpal.tools.common import reset_operation_counter
18
19
 
19
20
 
20
21
  def _sanitize_for_logging(text: str) -> str:
@@ -1567,6 +1568,10 @@ Supported models: Any LiteLLM-supported model
1567
1568
 
1568
1569
  result = agent.run(prompt, max_iterations=max_iterations)
1569
1570
 
1571
+ # Reset operation counter after each conversation turn
1572
+ # This allows long sessions without hitting the global limit
1573
+ reset_operation_counter()
1574
+
1570
1575
  print("\n" + "=" * 80)
1571
1576
  print("\033[1;32mAgent:\033[0m")
1572
1577
  print("=" * 80)
@@ -1595,6 +1600,10 @@ Supported models: Any LiteLLM-supported model
1595
1600
 
1596
1601
  result = agent.run(user_input, max_iterations=max_iterations)
1597
1602
 
1603
+ # Reset operation counter after each conversation turn
1604
+ # This allows long sessions without hitting the global limit
1605
+ reset_operation_counter()
1606
+
1598
1607
  # Log agent response to audit log with hash-chaining
1599
1608
  try:
1600
1609
  from patchpal.tools.audit import log_agent_response
@@ -76,7 +76,7 @@ CLASS_NODE_TYPES = {
76
76
  }
77
77
 
78
78
 
79
- def code_structure(path: str, max_symbols: int = 50) -> str:
79
+ def code_structure(path: str, max_symbols: int = 50, _internal_call: bool = False) -> str:
80
80
  """
81
81
  Analyze code structure using tree-sitter AST parsing.
82
82
 
@@ -92,6 +92,7 @@ def code_structure(path: str, max_symbols: int = 50) -> str:
92
92
  Args:
93
93
  path: File path to analyze (relative or absolute)
94
94
  max_symbols: Maximum number of symbols to show (default: 50)
95
+ _internal_call: Internal flag - don't count operation if called from get_repo_map
95
96
 
96
97
  Returns:
97
98
  Formatted code structure overview
@@ -107,7 +108,10 @@ def code_structure(path: str, max_symbols: int = 50) -> str:
107
108
 
108
109
  Use read_lines('patchpal/tools.py', start, end) to read specific sections.
109
110
  """
110
- _operation_limiter.check_limit(f"code_structure({path})")
111
+ # Only count operation if not an internal call from get_repo_map
112
+ # This prevents exceeding operation limits when scanning large repos
113
+ if not _internal_call:
114
+ _operation_limiter.check_limit(f"code_structure({path})")
111
115
 
112
116
  if not TREE_SITTER_AVAILABLE:
113
117
  return (
@@ -48,6 +48,40 @@ except ImportError:
48
48
 
49
49
  REPO_ROOT = Path(".").resolve()
50
50
 
51
+
52
+ def depth_limited_walk(root_dir: Path, max_depth: int):
53
+ """Walk directory tree up to max_depth without traversing deeper.
54
+
55
+ This is a shared utility for tools that need depth-limited traversal
56
+ to avoid performance issues in large codebases.
57
+
58
+ Args:
59
+ root_dir: Root directory to start traversal
60
+ max_depth: Maximum depth to traverse (0 = only root_dir level)
61
+
62
+ Yields:
63
+ Path objects found within depth limit (both files and directories)
64
+ """
65
+
66
+ def _walk(current_dir: Path, current_depth: int):
67
+ """Recursively walk directories up to max_depth."""
68
+ if current_depth > max_depth:
69
+ return
70
+
71
+ try:
72
+ for item in current_dir.iterdir():
73
+ yield item
74
+ # Only recurse if we haven't reached max depth and it's a directory
75
+ if item.is_dir() and not any(part.startswith(".") for part in item.parts):
76
+ if current_depth < max_depth: # Check before recursing
77
+ yield from _walk(item, current_depth + 1)
78
+ except (PermissionError, OSError):
79
+ # Skip directories we can't read
80
+ pass
81
+
82
+ yield from _walk(root_dir, 0)
83
+
84
+
51
85
  # Import config for centralized environment variable access
52
86
  from patchpal.config import config # noqa: E402
53
87
 
@@ -139,6 +139,10 @@ Tip: Read README first for context when exploring repositories.""",
139
139
  "items": {"type": "string"},
140
140
  "description": "Files to prioritize in the output (e.g., files mentioned in conversation). These appear first in the map.",
141
141
  },
142
+ "max_depth": {
143
+ "type": "integer",
144
+ "description": "Maximum directory depth to traverse (default: None for unlimited). Example: max_depth=3 traverses up to 3 levels deep from repository root. Useful for large codebases to limit scope.",
145
+ },
142
146
  },
143
147
  "required": [],
144
148
  },
@@ -457,6 +461,10 @@ Tip: Read README first for context when exploring repositories.""",
457
461
  "type": "string",
458
462
  "description": "Directory to search in (default: repository root). Can be relative to repo root or absolute.",
459
463
  },
464
+ "max_depth": {
465
+ "type": "integer",
466
+ "description": "Maximum directory depth to traverse (default: None for unlimited). Example: max_depth=2 searches up to 2 levels deep from the search directory. Useful for limiting scope in large codebases.",
467
+ },
460
468
  },
461
469
  "required": [],
462
470
  },
@@ -12,6 +12,7 @@ from typing import Optional
12
12
  from patchpal.tools.common import (
13
13
  REPO_ROOT,
14
14
  _operation_limiter,
15
+ depth_limited_walk,
15
16
  require_permission_for_read,
16
17
  )
17
18
 
@@ -19,14 +20,38 @@ MAX_RESULTS = 100
19
20
  MAX_OUTPUT_BYTES = 50 * 1024
20
21
 
21
22
 
23
+ def _matches_glob_pattern(file_path: Path, search_dir: Path, pattern: str) -> bool:
24
+ """Check if a file matches a glob pattern.
25
+
26
+ Args:
27
+ file_path: Absolute path to the file
28
+ search_dir: Base search directory
29
+ pattern: Glob pattern (e.g., '*.py', '**/*.js', 'src/*.txt')
30
+
31
+ Returns:
32
+ True if file matches the pattern
33
+ """
34
+ try:
35
+ rel_path = file_path.relative_to(search_dir)
36
+ except ValueError:
37
+ return False
38
+
39
+ # Match against relative path for patterns with directory structure
40
+ if "**" in pattern or "/" in pattern or "\\" in pattern:
41
+ return rel_path.match(pattern)
42
+ else:
43
+ # Match against just the filename for simple patterns
44
+ return Path(file_path.name).match(pattern)
45
+
46
+
22
47
  @require_permission_for_read(
23
48
  "find",
24
- get_description=lambda pattern="**/*", path=None: (
49
+ get_description=lambda pattern="**/*", path=None, max_depth=None: (
25
50
  f" Search for files matching '{pattern}'" + (f" in {path}" if path else "")
26
51
  ),
27
- get_pattern=lambda pattern="**/*", path=None: path,
52
+ get_pattern=lambda pattern="**/*", path=None, max_depth=None: path,
28
53
  )
29
- def find(pattern: str = "**/*", path: Optional[str] = None) -> str:
54
+ def find(pattern: str = "**/*", path: Optional[str] = None, max_depth: Optional[int] = None) -> str:
30
55
  """Search for files by glob pattern.
31
56
 
32
57
  Returns matching file paths relative to the search directory, sorted by
@@ -36,6 +61,8 @@ def find(pattern: str = "**/*", path: Optional[str] = None) -> str:
36
61
  pattern: Glob pattern to match files (default: "**/*" for all files).
37
62
  Examples: '*.py', '**/*.json', 'src/**/*.spec.ts'
38
63
  path: Directory to search in (default: repository root). Can be relative to repo root or absolute.
64
+ max_depth: Maximum directory depth to traverse (default: None for unlimited).
65
+ Example: max_depth=2 searches up to 2 levels deep from the search directory.
39
66
 
40
67
  Returns:
41
68
  Newline-separated list of matching file paths, sorted by modification time
@@ -59,21 +86,27 @@ def find(pattern: str = "**/*", path: Optional[str] = None) -> str:
59
86
  else:
60
87
  search_dir = REPO_ROOT
61
88
 
62
- # Check if pattern requires recursive search
63
- if "**" in pattern:
64
- # Recursive glob
65
- matches = list(search_dir.glob(pattern))
89
+ # Collect candidate files
90
+ if max_depth is not None:
91
+ # Depth-limited: walk tree and filter by pattern
92
+ all_files = [p for p in depth_limited_walk(search_dir, max_depth) if p.is_file()]
93
+ matches = [f for f in all_files if _matches_glob_pattern(f, search_dir, pattern)]
66
94
  else:
67
- # Check if pattern contains path separators
68
- if "/" in pattern or "\\" in pattern:
69
- # Pattern includes directory structure
95
+ # Check if pattern requires recursive search
96
+ if "**" in pattern:
97
+ # Recursive glob
70
98
  matches = list(search_dir.glob(pattern))
71
99
  else:
72
- # Simple filename pattern - search recursively
73
- matches = list(search_dir.glob(f"**/{pattern}"))
74
-
75
- # Filter to only files
76
- matches = [p for p in matches if p.is_file()]
100
+ # Check if pattern contains path separators
101
+ if "/" in pattern or "\\" in pattern:
102
+ # Pattern includes directory structure
103
+ matches = list(search_dir.glob(pattern))
104
+ else:
105
+ # Simple filename pattern - search recursively
106
+ matches = list(search_dir.glob(f"**/{pattern}"))
107
+
108
+ # Filter to only files
109
+ matches = [p for p in matches if p.is_file()]
77
110
 
78
111
  # Load gitignore patterns if .gitignore exists
79
112
  gitignore_patterns = _load_gitignore_patterns(REPO_ROOT)
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Dict, List, Optional, Tuple
13
13
 
14
14
  from patchpal.tools.code_analysis import LANGUAGE_MAP, code_structure
15
- from patchpal.tools.common import REPO_ROOT, _operation_limiter
15
+ from patchpal.tools.common import REPO_ROOT, _operation_limiter, depth_limited_walk
16
16
 
17
17
 
18
18
  class RepoMapCache:
@@ -80,6 +80,7 @@ def get_repo_map(
80
80
  include_patterns: Optional[List[str]] = None,
81
81
  exclude_patterns: Optional[List[str]] = None,
82
82
  focus_files: Optional[List[str]] = None,
83
+ max_depth: Optional[int] = None,
83
84
  ) -> str:
84
85
  """Generate a compact repository map showing code structure across all files.
85
86
 
@@ -95,6 +96,8 @@ def get_repo_map(
95
96
  include_patterns: Glob patterns to include (e.g., ['*.py', '*.js'])
96
97
  exclude_patterns: Glob patterns to exclude (e.g., ['*test*', '*_pb2.py'])
97
98
  focus_files: Files mentioned in conversation (prioritized in output)
99
+ max_depth: Maximum directory depth to traverse (default: None for unlimited).
100
+ Example: max_depth=3 traverses up to 3 levels deep from repository root.
98
101
 
99
102
  Returns:
100
103
  Formatted repository map with file structures
@@ -130,7 +133,13 @@ def get_repo_map(
130
133
  file_structures: Dict[str, str] = {}
131
134
  skipped_count = 0
132
135
 
133
- for path in REPO_ROOT.rglob("*"):
136
+ # Use depth-limited traversal if max_depth is specified
137
+ if max_depth is not None:
138
+ paths_to_check = depth_limited_walk(REPO_ROOT, max_depth)
139
+ else:
140
+ paths_to_check = REPO_ROOT.rglob("*")
141
+
142
+ for path in paths_to_check:
134
143
  # Skip directories, hidden files, and non-code files
135
144
  if not path.is_file():
136
145
  continue
@@ -162,8 +171,10 @@ def get_repo_map(
162
171
 
163
172
  if structure is None:
164
173
  # Generate structure
174
+ # Pass _internal_call=True so code_structure doesn't count as an operation
175
+ # This prevents repo_map from using thousands of operations in large repos
165
176
  try:
166
- structure = code_structure(str(rel_path), max_symbols=20)
177
+ structure = code_structure(str(rel_path), max_symbols=20, _internal_call=True)
167
178
  if structure and not structure.startswith("❌"):
168
179
  # Extract just the essential parts (remove hints and verbose info)
169
180
  lines = structure.split("\n")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.22.7
3
+ Version: 0.23.0
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -177,6 +177,18 @@ While originally designed for software development, PatchPal is also a general-p
177
177
  2. PatchPal includes a [unique guardrails system](https://amaiya.github.io/patchpal/safety/) that is better suited to privacy-conscious use cases involving sensitive data.
178
178
  3. We needed an agent harness that seamlessly works with [both local and cloud models](https://amaiya.github.io/patchpal/models/overview/#supported-models), including AWS GovCloud Bedrock models.
179
179
 
180
+ > On Windows Subsystem for Linux (WSL), why is it stalling intermittently at "Thinking..."?
181
+
182
+ This is a [known issue](https://github.com/microsoft/WSL/issues/6264#issuecomment-762154193) with WSL2.
183
+
184
+ Try examining and then lowering the `mtu`:
185
+
186
+ ```bash
187
+ $ cat /sys/class/net/eth1/mtu
188
+ 1427
189
+
190
+ $ sudo ip link set eth1 mtu 1400
191
+ ```
180
192
 
181
193
  ## Documentation
182
194
 
@@ -14,6 +14,7 @@ patchpal.egg-info/entry_points.txt
14
14
  patchpal.egg-info/requires.txt
15
15
  patchpal.egg-info/top_level.txt
16
16
  patchpal/agent/__init__.py
17
+ patchpal/agent/bedrock_profile_utils.py
17
18
  patchpal/agent/function_calling.py
18
19
  patchpal/agent/react.py
19
20
  patchpal/cli/__init__.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes