patchpal 0.1.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/agent.py ADDED
@@ -0,0 +1,933 @@
1
+ """Custom agent implementation using LiteLLM directly."""
2
+
3
+ import inspect
4
+ import json
5
+ import os
6
+ import platform
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List
9
+
10
+ import litellm
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown
13
+
14
+ from patchpal.context import ContextManager
15
+ from patchpal.tools import (
16
+ apply_patch,
17
+ edit_file,
18
+ find_files,
19
+ get_file_info,
20
+ git_diff,
21
+ git_log,
22
+ git_status,
23
+ grep_code,
24
+ list_files,
25
+ list_skills,
26
+ read_file,
27
+ run_shell,
28
+ tree,
29
+ use_skill,
30
+ web_fetch,
31
+ web_search,
32
+ )
33
+
34
+
35
+ def _is_bedrock_arn(model_id: str) -> bool:
36
+ """Check if a model ID is a Bedrock ARN."""
37
+ return (
38
+ model_id.startswith("arn:aws")
39
+ and ":bedrock:" in model_id
40
+ and ":inference-profile/" in model_id
41
+ )
42
+
43
+
44
+ def _normalize_bedrock_model_id(model_id: str) -> str:
45
+ """Normalize Bedrock model ID to ensure it has the bedrock/ prefix.
46
+
47
+ Args:
48
+ model_id: Model identifier, may or may not have bedrock/ prefix
49
+
50
+ Returns:
51
+ Model ID with bedrock/ prefix if it's a Bedrock model
52
+ """
53
+ # If it already has bedrock/ prefix, return as-is
54
+ if model_id.startswith("bedrock/"):
55
+ return model_id
56
+
57
+ # If it looks like a Bedrock ARN, add the prefix
58
+ if _is_bedrock_arn(model_id):
59
+ return f"bedrock/{model_id}"
60
+
61
+ # If it's a standard Bedrock model ID (e.g., anthropic.claude-v2)
62
+ # Check if it looks like a Bedrock model format
63
+ if "." in model_id and any(
64
+ provider in model_id for provider in ["anthropic", "amazon", "meta", "cohere", "ai21"]
65
+ ):
66
+ return f"bedrock/{model_id}"
67
+
68
+ return model_id
69
+
70
+
71
+ def _setup_bedrock_env():
72
+ """Set up Bedrock-specific environment variables for LiteLLM.
73
+
74
+ Configures custom region and endpoint URL for AWS Bedrock (including GovCloud and VPC endpoints).
75
+ Maps PatchPal's environment variables to LiteLLM's expected format.
76
+ """
77
+ # Set custom region (e.g., us-gov-east-1 for GovCloud)
78
+ bedrock_region = os.getenv("AWS_BEDROCK_REGION")
79
+ if bedrock_region and not os.getenv("AWS_REGION_NAME"):
80
+ os.environ["AWS_REGION_NAME"] = bedrock_region
81
+
82
+ # Set custom endpoint URL (e.g., VPC endpoint or GovCloud endpoint)
83
+ bedrock_endpoint = os.getenv("AWS_BEDROCK_ENDPOINT")
84
+ if bedrock_endpoint and not os.getenv("AWS_BEDROCK_RUNTIME_ENDPOINT"):
85
+ os.environ["AWS_BEDROCK_RUNTIME_ENDPOINT"] = bedrock_endpoint
86
+
87
+
88
+ # Define tools in LiteLLM format
89
+ TOOLS = [
90
+ {
91
+ "type": "function",
92
+ "function": {
93
+ "name": "read_file",
94
+ "description": "Read the contents of a file. Can read files anywhere on the system (repository files, system configs like /etc/fstab, logs, etc.) for automation and debugging. Sensitive files (.env, credentials) are blocked for safety.",
95
+ "parameters": {
96
+ "type": "object",
97
+ "properties": {
98
+ "path": {
99
+ "type": "string",
100
+ "description": "Path to the file - can be relative to repository root or an absolute path (e.g., /etc/fstab, /var/log/app.log)",
101
+ }
102
+ },
103
+ "required": ["path"],
104
+ },
105
+ },
106
+ },
107
+ {
108
+ "type": "function",
109
+ "function": {
110
+ "name": "list_files",
111
+ "description": "List ALL files in the ENTIRE repository - no filtering by directory. This tool shows every file across all folders. To list files in a specific directory, use the 'tree' tool with a path parameter instead.",
112
+ "parameters": {"type": "object", "properties": {}, "required": []},
113
+ },
114
+ },
115
+ {
116
+ "type": "function",
117
+ "function": {
118
+ "name": "get_file_info",
119
+ "description": "Get detailed metadata for file(s) - size, modification time, type. Works with any file on the system. Supports single files, directories, or glob patterns (e.g., 'tests/*.py', '/etc/*.conf').",
120
+ "parameters": {
121
+ "type": "object",
122
+ "properties": {
123
+ "path": {
124
+ "type": "string",
125
+ "description": "Path to file, directory, or glob pattern - can be relative or absolute (e.g., 'tests/*.txt', '/var/log/', '/etc/fstab')",
126
+ }
127
+ },
128
+ "required": ["path"],
129
+ },
130
+ },
131
+ },
132
+ {
133
+ "type": "function",
134
+ "function": {
135
+ "name": "find_files",
136
+ "description": "Find files by name pattern using glob-style wildcards (e.g., '*.py', 'test_*.txt', '**/*.md'). Faster than list_files when searching for specific file names.",
137
+ "parameters": {
138
+ "type": "object",
139
+ "properties": {
140
+ "pattern": {
141
+ "type": "string",
142
+ "description": "Glob pattern to match file names (e.g., '*.py' for Python files, 'test_*.py' for test files)",
143
+ },
144
+ "case_sensitive": {
145
+ "type": "boolean",
146
+ "description": "Whether to match case-sensitively (default: true)",
147
+ },
148
+ },
149
+ "required": ["pattern"],
150
+ },
151
+ },
152
+ },
153
+ {
154
+ "type": "function",
155
+ "function": {
156
+ "name": "tree",
157
+ "description": "Show directory tree structure for a specific directory path. Use this to list files in a particular folder (e.g., './tests', 'src/components'). Works with any directory on the system - repository folders, /etc, /var/log, etc.",
158
+ "parameters": {
159
+ "type": "object",
160
+ "properties": {
161
+ "path": {
162
+ "type": "string",
163
+ "description": "Starting directory path - can be relative or absolute (default: current directory '.', examples: '/etc', '/var/log', 'src')",
164
+ },
165
+ "max_depth": {
166
+ "type": "integer",
167
+ "description": "Maximum depth to traverse (default: 3, max: 10)",
168
+ },
169
+ "show_hidden": {
170
+ "type": "boolean",
171
+ "description": "Include hidden files/directories (default: false)",
172
+ },
173
+ },
174
+ "required": [],
175
+ },
176
+ },
177
+ },
178
+ {
179
+ "type": "function",
180
+ "function": {
181
+ "name": "edit_file",
182
+ "description": "Edit a file by replacing an exact string. More efficient than apply_patch for small changes. Primarily for repository files. Writing outside repository requires explicit user permission. The old_string must match exactly and appear only once.",
183
+ "parameters": {
184
+ "type": "object",
185
+ "properties": {
186
+ "path": {
187
+ "type": "string",
188
+ "description": "Path to the file - relative to repository root or absolute path (note: writes outside repository require permission)",
189
+ },
190
+ "old_string": {
191
+ "type": "string",
192
+ "description": "The exact string to find and replace (must appear exactly once)",
193
+ },
194
+ "new_string": {
195
+ "type": "string",
196
+ "description": "The string to replace it with",
197
+ },
198
+ },
199
+ "required": ["path", "old_string", "new_string"],
200
+ },
201
+ },
202
+ },
203
+ {
204
+ "type": "function",
205
+ "function": {
206
+ "name": "apply_patch",
207
+ "description": "Modify a file by replacing its contents. Primarily for repository files. Writing outside repository requires explicit user permission. Returns a unified diff of changes.",
208
+ "parameters": {
209
+ "type": "object",
210
+ "properties": {
211
+ "path": {
212
+ "type": "string",
213
+ "description": "Path to the file - relative to repository root or absolute path (note: writes outside repository require permission)",
214
+ },
215
+ "new_content": {
216
+ "type": "string",
217
+ "description": "The complete new content for the file",
218
+ },
219
+ },
220
+ "required": ["path", "new_content"],
221
+ },
222
+ },
223
+ },
224
+ {
225
+ "type": "function",
226
+ "function": {
227
+ "name": "git_status",
228
+ "description": "Get git repository status showing modified, staged, and untracked files. No permission required - read-only operation.",
229
+ "parameters": {"type": "object", "properties": {}, "required": []},
230
+ },
231
+ },
232
+ {
233
+ "type": "function",
234
+ "function": {
235
+ "name": "git_diff",
236
+ "description": "Get git diff to see changes. No permission required - read-only operation.",
237
+ "parameters": {
238
+ "type": "object",
239
+ "properties": {
240
+ "path": {
241
+ "type": "string",
242
+ "description": "Optional: specific file path to show diff for",
243
+ },
244
+ "staged": {
245
+ "type": "boolean",
246
+ "description": "If true, show staged changes (--cached), else show unstaged changes",
247
+ },
248
+ },
249
+ "required": [],
250
+ },
251
+ },
252
+ },
253
+ {
254
+ "type": "function",
255
+ "function": {
256
+ "name": "git_log",
257
+ "description": "Get git commit history. No permission required - read-only operation.",
258
+ "parameters": {
259
+ "type": "object",
260
+ "properties": {
261
+ "max_count": {
262
+ "type": "integer",
263
+ "description": "Maximum number of commits to show (default: 10, max: 50)",
264
+ },
265
+ "path": {
266
+ "type": "string",
267
+ "description": "Optional: specific file path to show history for",
268
+ },
269
+ },
270
+ "required": [],
271
+ },
272
+ },
273
+ },
274
+ {
275
+ "type": "function",
276
+ "function": {
277
+ "name": "grep_code",
278
+ "description": "Search for a pattern in repository files. Much faster than run_shell with grep. Returns results in 'file:line:content' format.",
279
+ "parameters": {
280
+ "type": "object",
281
+ "properties": {
282
+ "pattern": {
283
+ "type": "string",
284
+ "description": "Regular expression pattern to search for",
285
+ },
286
+ "file_glob": {
287
+ "type": "string",
288
+ "description": "Optional glob pattern to filter files (e.g., '*.py', 'src/**/*.js')",
289
+ },
290
+ "case_sensitive": {
291
+ "type": "boolean",
292
+ "description": "Whether the search should be case-sensitive (default: true)",
293
+ },
294
+ "max_results": {
295
+ "type": "integer",
296
+ "description": "Maximum number of results to return (default: 100)",
297
+ },
298
+ },
299
+ "required": ["pattern"],
300
+ },
301
+ },
302
+ },
303
+ {
304
+ "type": "function",
305
+ "function": {
306
+ "name": "web_search",
307
+ "description": "Search the web for information. Useful for looking up error messages, documentation, best practices, or current information.",
308
+ "parameters": {
309
+ "type": "object",
310
+ "properties": {
311
+ "query": {"type": "string", "description": "The search query"},
312
+ "max_results": {
313
+ "type": "integer",
314
+ "description": "Maximum number of results to return (default: 5, max: 10)",
315
+ },
316
+ },
317
+ "required": ["query"],
318
+ },
319
+ },
320
+ },
321
+ {
322
+ "type": "function",
323
+ "function": {
324
+ "name": "web_fetch",
325
+ "description": "Fetch and read content from a URL. Useful for reading documentation, error references, or code examples.",
326
+ "parameters": {
327
+ "type": "object",
328
+ "properties": {
329
+ "url": {
330
+ "type": "string",
331
+ "description": "The URL to fetch (must start with http:// or https://)",
332
+ },
333
+ "extract_text": {
334
+ "type": "boolean",
335
+ "description": "If true, extract readable text from HTML (default: true)",
336
+ },
337
+ },
338
+ "required": ["url"],
339
+ },
340
+ },
341
+ },
342
+ {
343
+ "type": "function",
344
+ "function": {
345
+ "name": "list_skills",
346
+ "description": "List all available skills. When telling users about skills, instruct them to use /skillname syntax (e.g., /commit).",
347
+ "parameters": {"type": "object", "properties": {}, "required": []},
348
+ },
349
+ },
350
+ {
351
+ "type": "function",
352
+ "function": {
353
+ "name": "use_skill",
354
+ "description": "Invoke a skill programmatically when it's relevant to the user's request. Note: Users invoke skills via /skillname at the CLI, not by calling tools.",
355
+ "parameters": {
356
+ "type": "object",
357
+ "properties": {
358
+ "skill_name": {
359
+ "type": "string",
360
+ "description": "Name of the skill to invoke (without / prefix)",
361
+ },
362
+ "args": {
363
+ "type": "string",
364
+ "description": "Optional arguments to pass to the skill",
365
+ },
366
+ },
367
+ "required": ["skill_name"],
368
+ },
369
+ },
370
+ },
371
+ {
372
+ "type": "function",
373
+ "function": {
374
+ "name": "run_shell",
375
+ "description": "Run a safe shell command in the repository. Dangerous commands (rm, mv, sudo, etc.) are blocked.",
376
+ "parameters": {
377
+ "type": "object",
378
+ "properties": {
379
+ "cmd": {"type": "string", "description": "The shell command to execute"}
380
+ },
381
+ "required": ["cmd"],
382
+ },
383
+ },
384
+ },
385
+ ]
386
+
387
+ # Map tool names to functions
388
+ TOOL_FUNCTIONS = {
389
+ "read_file": read_file,
390
+ "list_files": list_files,
391
+ "get_file_info": get_file_info,
392
+ "find_files": find_files,
393
+ "tree": tree,
394
+ "edit_file": edit_file,
395
+ "apply_patch": apply_patch,
396
+ "git_status": git_status,
397
+ "git_diff": git_diff,
398
+ "git_log": git_log,
399
+ "grep_code": grep_code,
400
+ "web_search": web_search,
401
+ "web_fetch": web_fetch,
402
+ "list_skills": list_skills,
403
+ "use_skill": use_skill,
404
+ "run_shell": run_shell,
405
+ }
406
+
407
+ # Check if web tools should be disabled (for air-gapped environments)
408
+ WEB_TOOLS_ENABLED = os.getenv("PATCHPAL_ENABLE_WEB", "true").lower() in ("true", "1", "yes")
409
+
410
+ if not WEB_TOOLS_ENABLED:
411
+ # Remove web tools from available tools
412
+ TOOLS = [tool for tool in TOOLS if tool["function"]["name"] not in ("web_search", "web_fetch")]
413
+ TOOL_FUNCTIONS = {
414
+ k: v for k, v in TOOL_FUNCTIONS.items() if k not in ("web_search", "web_fetch")
415
+ }
416
+
417
+
418
+ # Detect platform and generate platform-specific guidance
419
+ os_name = platform.system() # 'Linux', 'Darwin', 'Windows'
420
+
421
+ if os_name == "Windows":
422
+ PLATFORM_INFO = """## Platform: Windows
423
+ When using run_shell, use Windows commands:
424
+ - File operations: `dir`, `type`, `copy`, `move`, `del`, `mkdir`, `rmdir`
425
+ - Search: `where`, `findstr`
426
+ - Path format: Use backslashes `C:\\path\\to\\file.txt`
427
+ - For relative paths: Use `.\\Documents` NOT `./Documents`
428
+ - For current directory: Use `.` or omit the path prefix
429
+ - Chain commands with `&&`
430
+ """
431
+ else: # Linux or macOS
432
+ PLATFORM_INFO = f"""## Platform: {os_name} (Unix-like)
433
+ When using run_shell, use Unix commands:
434
+ - File operations: `ls`, `cat`, `cp`, `mv`, `rm`, `mkdir`, `rmdir`
435
+ - Search: `grep`, `find`, `which`
436
+ - Path format: Forward slashes `/path/to/file.txt`
437
+ - Chain commands with `&&` or `;`
438
+ """
439
+
440
+ # Build web tools description
441
+ WEB_TOOLS_DESC = ""
442
+ WEB_USAGE_DESC = ""
443
+ WEB_TOOLS_SCOPE = ""
444
+ if WEB_TOOLS_ENABLED:
445
+ WEB_TOOLS_DESC = """- **web_search**: Search the web for information (error messages, documentation, best practices)
446
+ - **web_fetch**: Fetch and read content from a URL (documentation, examples, references)
447
+ """
448
+ WEB_USAGE_DESC = """
449
+ - Use web_search when you encounter unfamiliar errors, need documentation, or want to research solutions
450
+ - Use web_fetch to read specific documentation pages or references you find"""
451
+ WEB_TOOLS_SCOPE = """- **Web access**: web_search, web_fetch
452
+ """
453
+
454
+
455
+ def _load_system_prompt() -> str:
456
+ """Load system prompt from markdown file and substitute dynamic values.
457
+
458
+ Checks PATCHPAL_SYSTEM_PROMPT environment variable for a custom prompt file path.
459
+ If not set, uses the default system_prompt.md in the patchpal package directory.
460
+
461
+ Returns:
462
+ The formatted system prompt string
463
+ """
464
+ # Check for custom system prompt path from environment variable
465
+ custom_prompt_path = os.getenv("PATCHPAL_SYSTEM_PROMPT")
466
+
467
+ if custom_prompt_path:
468
+ # Use custom prompt file
469
+ prompt_path = os.path.expanduser(custom_prompt_path)
470
+ if not os.path.isfile(prompt_path):
471
+ print(
472
+ f"\033[1;33m⚠️ Warning: Custom system prompt file not found: {prompt_path}\033[0m"
473
+ )
474
+ print("\033[1;33m Falling back to default system prompt.\033[0m\n")
475
+ # Fall back to default
476
+ prompt_path = os.path.join(os.path.dirname(__file__), "system_prompt.md")
477
+ else:
478
+ # Use default prompt from package directory
479
+ prompt_path = os.path.join(os.path.dirname(__file__), "system_prompt.md")
480
+
481
+ # Read the prompt template
482
+ with open(prompt_path, "r", encoding="utf-8") as f:
483
+ prompt_template = f.read()
484
+
485
+ # Get current date and time
486
+ now = datetime.now()
487
+ current_date = now.strftime("%A, %B %d, %Y") # e.g., "Wednesday, January 15, 2026"
488
+ current_time = now.strftime("%I:%M %p %Z").strip() # e.g., "03:45 PM EST"
489
+ if not current_time.endswith(("EST", "CST", "MST", "PST", "UTC")):
490
+ # If no timezone abbreviation, just show time without timezone
491
+ current_time = now.strftime("%I:%M %p").strip()
492
+
493
+ # Prepare template variables
494
+ template_vars = {
495
+ "platform_info": PLATFORM_INFO,
496
+ "current_date": current_date,
497
+ "current_time": current_time,
498
+ "web_tools": WEB_TOOLS_DESC,
499
+ "web_usage": WEB_USAGE_DESC,
500
+ "web_tools_scope_desc": WEB_TOOLS_SCOPE,
501
+ }
502
+
503
+ # Substitute variables - gracefully handle missing variables
504
+ # This allows custom prompts to omit variables they don't need
505
+ try:
506
+ return prompt_template.format(**template_vars)
507
+ except KeyError as e:
508
+ # Missing variable in template - warn but continue with partial substitution
509
+ print(f"\033[1;33m⚠️ Warning: System prompt references undefined variable: {e}\033[0m")
510
+ print(f"\033[1;33m Available variables: {', '.join(template_vars.keys())}\033[0m")
511
+ print("\033[1;33m Attempting partial substitution...\033[0m\n")
512
+
513
+ # Try to substitute what we can by replacing unmatched placeholders with empty strings
514
+ result = prompt_template
515
+ for key, value in template_vars.items():
516
+ result = result.replace(f"{{{key}}}", str(value))
517
+ return result
518
+ except Exception as e:
519
+ print(f"\033[1;33m⚠️ Warning: Error processing system prompt template: {e}\033[0m")
520
+ print("\033[1;33m Using prompt as-is without variable substitution.\033[0m\n")
521
+ return prompt_template
522
+
523
+
524
+ # Load the system prompt at module initialization
525
+ SYSTEM_PROMPT = _load_system_prompt()
526
+
527
+
528
+ class PatchPalAgent:
529
+ """Simple agent that uses LiteLLM for tool calling."""
530
+
531
+ def __init__(self, model_id: str = "anthropic/claude-sonnet-4-5"):
532
+ """Initialize the agent.
533
+
534
+ Args:
535
+ model_id: LiteLLM model identifier
536
+ """
537
+ # Convert ollama/ to ollama_chat/ for LiteLLM compatibility
538
+ if model_id.startswith("ollama/"):
539
+ model_id = model_id.replace("ollama/", "ollama_chat/", 1)
540
+
541
+ self.model_id = _normalize_bedrock_model_id(model_id)
542
+
543
+ # Set up Bedrock environment if needed
544
+ if self.model_id.startswith("bedrock/"):
545
+ _setup_bedrock_env()
546
+
547
+ # Conversation history (list of message dicts)
548
+ self.messages: List[Dict[str, Any]] = []
549
+
550
+ # Initialize context manager
551
+ self.context_manager = ContextManager(self.model_id, SYSTEM_PROMPT)
552
+
553
+ # Check if auto-compaction is enabled (default: True)
554
+ self.enable_auto_compact = (
555
+ os.getenv("PATCHPAL_DISABLE_AUTOCOMPACT", "false").lower() != "true"
556
+ )
557
+
558
+ # Track last compaction to prevent compaction loops
559
+ self._last_compaction_message_count = 0
560
+
561
+ # LiteLLM settings for models that need parameter dropping
562
+ self.litellm_kwargs = {}
563
+ if self.model_id.startswith("bedrock/"):
564
+ self.litellm_kwargs["drop_params"] = True
565
+ # Configure LiteLLM to handle Bedrock's strict message alternation requirement
566
+ # This must be set globally, not as a completion parameter
567
+ litellm.modify_params = True
568
+ elif self.model_id.startswith("openai/") and os.getenv("OPENAI_API_BASE"):
569
+ # Custom OpenAI-compatible servers (vLLM, etc.) often don't support all parameters
570
+ self.litellm_kwargs["drop_params"] = True
571
+
572
+ def _perform_auto_compaction(self):
573
+ """Perform automatic context window compaction.
574
+
575
+ This method is called when the context window reaches 85% capacity.
576
+ It attempts pruning first, then full compaction if needed.
577
+ """
578
+ # Don't compact if we have very few messages - compaction summary
579
+ # could be longer than the messages being removed
580
+ if len(self.messages) < 5:
581
+ print(
582
+ f"\033[2m Skipping compaction - only {len(self.messages)} messages (need at least 5 for effective compaction)\033[0m"
583
+ )
584
+ return
585
+
586
+ # Prevent compaction loops - don't compact again if we just did
587
+ # and haven't added significant new messages
588
+ messages_since_last_compact = len(self.messages) - self._last_compaction_message_count
589
+ if self._last_compaction_message_count > 0 and messages_since_last_compact < 3:
590
+ # Just compacted recently and haven't added enough new context
591
+ print(
592
+ f"\033[2m Skipping compaction - only {messages_since_last_compact} messages since last compact\033[0m"
593
+ )
594
+ return
595
+
596
+ stats_before = self.context_manager.get_usage_stats(self.messages)
597
+
598
+ print(
599
+ f"\n\033[1;33m⚠️ Context window at {stats_before['usage_percent']}% capacity. Compacting...\033[0m"
600
+ )
601
+ print(
602
+ f"\033[2m Current: {stats_before['total_tokens']:,} / {stats_before['context_limit']:,} tokens "
603
+ f"(system: {stats_before['system_tokens']:,}, messages: {stats_before['message_tokens']:,}, "
604
+ f"output reserve: {stats_before['output_reserve']:,})\033[0m"
605
+ )
606
+ print(
607
+ f"\033[2m Messages: {len(self.messages)} total, last compaction at message {self._last_compaction_message_count}\033[0m"
608
+ )
609
+
610
+ # Phase 1: Try pruning old tool outputs first
611
+ pruned_messages, tokens_saved = self.context_manager.prune_tool_outputs(self.messages)
612
+
613
+ if tokens_saved > 0:
614
+ self.messages = pruned_messages
615
+ print(
616
+ f"\033[2m Pruned old tool outputs (saved ~{tokens_saved:,} tokens)\033[0m",
617
+ flush=True,
618
+ )
619
+
620
+ # Check if pruning was enough
621
+ if not self.context_manager.needs_compaction(self.messages):
622
+ stats_after = self.context_manager.get_usage_stats(self.messages)
623
+ print(
624
+ f"\033[1;32m✓ Context reduced to {stats_after['usage_percent']}% through pruning "
625
+ f"({stats_after['total_tokens']:,} tokens)\033[0m\n"
626
+ )
627
+ return
628
+
629
+ # Phase 2: Full compaction needed
630
+ print("\033[2m Generating conversation summary...\033[0m", flush=True)
631
+
632
+ try:
633
+ # Create compaction using the LLM
634
+ summary_msg, summary_text = self.context_manager.create_compaction(
635
+ self.messages,
636
+ lambda msgs: litellm.completion(
637
+ model=self.model_id,
638
+ messages=[{"role": "system", "content": SYSTEM_PROMPT}] + msgs,
639
+ **self.litellm_kwargs,
640
+ ),
641
+ )
642
+
643
+ # Replace message history with compacted version
644
+ # Strategy: Keep summary + recent complete turns (preserve tool call/result pairs)
645
+ # This ensures Bedrock's strict message structure requirements are met
646
+
647
+ # Find complete assistant turns (assistant message + all its tool results)
648
+ # Walk backwards and keep complete turns
649
+ preserved_messages = []
650
+ i = len(self.messages) - 1
651
+ turns_kept = 0
652
+ max_turns_to_keep = 2 # Keep last 2 complete turns
653
+
654
+ while i >= 0 and turns_kept < max_turns_to_keep:
655
+ msg = self.messages[i]
656
+
657
+ if msg.get("role") == "user":
658
+ # Found start of a turn, keep it and everything after
659
+ preserved_messages = self.messages[i:]
660
+ turns_kept += 1
661
+ i -= 1
662
+ elif msg.get("role") == "assistant":
663
+ # Keep going back to find all tool results for this assistant message
664
+ i -= 1
665
+ elif msg.get("role") == "tool":
666
+ # Part of current turn, keep going back
667
+ i -= 1
668
+ else:
669
+ i -= 1
670
+
671
+ if preserved_messages:
672
+ self.messages = [summary_msg] + preserved_messages
673
+ else:
674
+ # Fallback: keep all messages plus summary
675
+ self.messages = [summary_msg] + self.messages
676
+
677
+ # Show results
678
+ stats_after = self.context_manager.get_usage_stats(self.messages)
679
+ print(
680
+ f"\033[1;32m✓ Compaction complete. Saved {stats_before['total_tokens'] - stats_after['total_tokens']:,} tokens ({stats_before['usage_percent']}% → {stats_after['usage_percent']}%)\033[0m\n"
681
+ )
682
+
683
+ # Update last compaction tracker
684
+ self._last_compaction_message_count = len(self.messages)
685
+
686
+ except Exception as e:
687
+ # Compaction failed - warn but continue
688
+ print(f"\033[1;31m✗ Compaction failed: {e}\033[0m")
689
+ print(
690
+ "\033[1;33m Continuing without compaction. Consider starting a new session.\033[0m\n"
691
+ )
692
+
693
+ def run(self, user_message: str, max_iterations: int = 100) -> str:
694
+ """Run the agent on a user message.
695
+
696
+ Args:
697
+ user_message: The user's request
698
+ max_iterations: Maximum number of agent iterations (default: 100)
699
+
700
+ Returns:
701
+ The agent's final response
702
+ """
703
+ # Add user message to history
704
+ self.messages.append({"role": "user", "content": user_message})
705
+
706
+ # Check for compaction BEFORE starting work
707
+ # This ensures we never compact mid-execution and lose tool results
708
+ if self.enable_auto_compact and self.context_manager.needs_compaction(self.messages):
709
+ self._perform_auto_compaction()
710
+
711
+ # Agent loop
712
+ for iteration in range(max_iterations):
713
+ # Show thinking message
714
+ print("\033[2m🤔 Thinking...\033[0m", flush=True)
715
+
716
+ # Use LiteLLM for all providers
717
+ try:
718
+ response = litellm.completion(
719
+ model=self.model_id,
720
+ messages=[{"role": "system", "content": SYSTEM_PROMPT}] + self.messages,
721
+ tools=TOOLS,
722
+ tool_choice="auto",
723
+ **self.litellm_kwargs,
724
+ )
725
+ except Exception as e:
726
+ return f"Error calling model: {e}"
727
+
728
+ # Get the assistant's response
729
+ assistant_message = response.choices[0].message
730
+
731
+ # Add assistant message to history
732
+ self.messages.append(
733
+ {
734
+ "role": "assistant",
735
+ "content": assistant_message.content or "",
736
+ "tool_calls": assistant_message.tool_calls
737
+ if hasattr(assistant_message, "tool_calls") and assistant_message.tool_calls
738
+ else None,
739
+ }
740
+ )
741
+
742
+ # Check if there are tool calls
743
+ if hasattr(assistant_message, "tool_calls") and assistant_message.tool_calls:
744
+ # Print explanation text before executing tools (render as markdown)
745
+ if assistant_message.content and assistant_message.content.strip():
746
+ console = Console()
747
+ print() # Blank line before markdown
748
+ console.print(Markdown(assistant_message.content))
749
+ print() # Blank line after markdown
750
+
751
+ # Track if any operation was cancelled
752
+ operation_cancelled = False
753
+
754
+ # Execute each tool call
755
+ for tool_call in assistant_message.tool_calls:
756
+ tool_name = tool_call.function.name
757
+ tool_args_str = tool_call.function.arguments
758
+
759
+ # Parse arguments
760
+ try:
761
+ tool_args = json.loads(tool_args_str)
762
+ except json.JSONDecodeError:
763
+ tool_result = f"Error: Invalid JSON arguments for {tool_name}"
764
+ print(f"\033[1;31m✗ {tool_name}: Invalid arguments\033[0m")
765
+ else:
766
+ # Get the tool function
767
+ tool_func = TOOL_FUNCTIONS.get(tool_name)
768
+ if tool_func is None:
769
+ tool_result = f"Error: Unknown tool {tool_name}"
770
+ print(f"\033[1;31m✗ Unknown tool: {tool_name}\033[0m")
771
+ else:
772
+ # Show tool call message
773
+ tool_display = tool_name.replace("_", " ").title()
774
+ if tool_name == "read_file":
775
+ print(
776
+ f"\033[2m📖 Reading: {tool_args.get('path', '')}\033[0m",
777
+ flush=True,
778
+ )
779
+ elif tool_name == "list_files":
780
+ print("\033[2m📁 Listing files...\033[0m", flush=True)
781
+ elif tool_name == "get_file_info":
782
+ print(
783
+ f"\033[2m📊 Getting info: {tool_args.get('path', '')}\033[0m",
784
+ flush=True,
785
+ )
786
+ elif tool_name == "edit_file":
787
+ print(
788
+ f"\033[2m✏️ Editing: {tool_args.get('path', '')}\033[0m",
789
+ flush=True,
790
+ )
791
+ elif tool_name == "apply_patch":
792
+ print(
793
+ f"\033[2m📝 Patching: {tool_args.get('path', '')}\033[0m",
794
+ flush=True,
795
+ )
796
+ elif tool_name == "git_status":
797
+ print("\033[2m🔀 Git status...\033[0m", flush=True)
798
+ elif tool_name == "git_diff":
799
+ print(
800
+ f"\033[2m🔀 Git diff{': ' + tool_args.get('path', '') if tool_args.get('path') else '...'}\033[0m",
801
+ flush=True,
802
+ )
803
+ elif tool_name == "git_log":
804
+ print("\033[2m🔀 Git log...\033[0m", flush=True)
805
+ elif tool_name == "grep_code":
806
+ print(
807
+ f"\033[2m🔍 Searching: {tool_args.get('pattern', '')}\033[0m",
808
+ flush=True,
809
+ )
810
+ elif tool_name == "find_files":
811
+ print(
812
+ f"\033[2m🔍 Finding: {tool_args.get('pattern', '')}\033[0m",
813
+ flush=True,
814
+ )
815
+ elif tool_name == "tree":
816
+ print(
817
+ f"\033[2m🌳 Tree: {tool_args.get('path', '.')}\033[0m",
818
+ flush=True,
819
+ )
820
+ elif tool_name == "list_skills":
821
+ print("\033[2m📋 Listing skills...\033[0m", flush=True)
822
+ elif tool_name == "use_skill":
823
+ print(
824
+ f"\033[2m⚡ Using skill: {tool_args.get('skill_name', '')}\033[0m",
825
+ flush=True,
826
+ )
827
+ elif tool_name == "web_search":
828
+ print(
829
+ f"\033[2m🌐 Searching web: {tool_args.get('query', '')}\033[0m",
830
+ flush=True,
831
+ )
832
+ elif tool_name == "web_fetch":
833
+ print(
834
+ f"\033[2m🌐 Fetching: {tool_args.get('url', '')}\033[0m",
835
+ flush=True,
836
+ )
837
+ elif tool_name == "run_shell":
838
+ print(
839
+ f"\033[2m⚡ Running: {tool_args.get('cmd', '')}\033[0m",
840
+ flush=True,
841
+ )
842
+
843
+ # Execute the tool (permission checks happen inside the tool)
844
+ try:
845
+ # Filter tool_args to only include parameters the function accepts
846
+ sig = inspect.signature(tool_func)
847
+ valid_params = set(sig.parameters.keys())
848
+ filtered_args = {
849
+ k: v for k, v in tool_args.items() if k in valid_params
850
+ }
851
+
852
+ # Coerce types for parameters (Ollama sometimes passes strings)
853
+ for param_name, param in sig.parameters.items():
854
+ if param_name in filtered_args:
855
+ expected_type = param.annotation
856
+ actual_value = filtered_args[param_name]
857
+
858
+ # Convert strings to expected types
859
+ if expected_type is int and isinstance(actual_value, str):
860
+ filtered_args[param_name] = int(actual_value)
861
+ elif expected_type is bool and isinstance(
862
+ actual_value, str
863
+ ):
864
+ filtered_args[param_name] = actual_value.lower() in (
865
+ "true",
866
+ "1",
867
+ "yes",
868
+ )
869
+
870
+ # Silently filter out invalid args (models sometimes hallucinate parameters)
871
+
872
+ tool_result = tool_func(**filtered_args)
873
+ except Exception as e:
874
+ tool_result = f"Error executing {tool_name}: {e}"
875
+ print(f"\033[1;31m✗ {tool_display}: {e}\033[0m")
876
+
877
+ # Add tool result to messages
878
+ self.messages.append(
879
+ {
880
+ "role": "tool",
881
+ "tool_call_id": tool_call.id,
882
+ "name": tool_name,
883
+ "content": str(tool_result),
884
+ }
885
+ )
886
+
887
+ # Check if operation was cancelled by user
888
+ # Use exact match to avoid false positives from file contents
889
+ if str(tool_result).strip() == "Operation cancelled by user.":
890
+ operation_cancelled = True
891
+
892
+ # If any operation was cancelled, return now (after all tool results are added)
893
+ # This ensures Bedrock gets all expected tool results before we exit
894
+ if operation_cancelled:
895
+ return "Operation cancelled by user."
896
+
897
+ # Check if context window needs compaction after tool results are added
898
+ # This prevents context from ballooning within a single turn (e.g., reading large files)
899
+ if self.enable_auto_compact and self.context_manager.needs_compaction(
900
+ self.messages
901
+ ):
902
+ self._perform_auto_compaction()
903
+
904
+ # Continue loop to let agent process tool results
905
+ continue
906
+ else:
907
+ # No tool calls, agent is done
908
+ # Check if we need compaction before returning (final response might be large)
909
+ if self.enable_auto_compact and self.context_manager.needs_compaction(
910
+ self.messages
911
+ ):
912
+ self._perform_auto_compaction()
913
+
914
+ return assistant_message.content or "Task completed"
915
+
916
+ # Max iterations reached
917
+ return (
918
+ f"Maximum iterations ({max_iterations}) reached. Task may be incomplete.\n\n"
919
+ "💡 Tip: Type 'continue' or 'please continue' to resume where I left off, "
920
+ "or set PATCHPAL_MAX_ITERATIONS=<large #> as environment variable."
921
+ )
922
+
923
+
924
+ def create_agent(model_id: str = "anthropic/claude-sonnet-4-5") -> PatchPalAgent:
925
+ """Create and return a PatchPal agent.
926
+
927
+ Args:
928
+ model_id: LiteLLM model identifier (default: anthropic/claude-sonnet-4-5)
929
+
930
+ Returns:
931
+ A configured PatchPalAgent instance
932
+ """
933
+ return PatchPalAgent(model_id=model_id)