patchpal 0.25.3__tar.gz → 0.25.4__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 (70) hide show
  1. {patchpal-0.25.3/patchpal.egg-info → patchpal-0.25.4}/PKG-INFO +1 -1
  2. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/__init__.py +1 -1
  3. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/agent/function_calling.py +45 -9
  4. {patchpal-0.25.3 → patchpal-0.25.4/patchpal.egg-info}/PKG-INFO +1 -1
  5. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_agent.py +72 -0
  6. {patchpal-0.25.3 → patchpal-0.25.4}/LICENSE +0 -0
  7. {patchpal-0.25.3 → patchpal-0.25.4}/MANIFEST.in +0 -0
  8. {patchpal-0.25.3 → patchpal-0.25.4}/README.md +0 -0
  9. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/agent/__init__.py +0 -0
  10. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/agent/bedrock_profile_utils.py +0 -0
  11. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/agent/react.py +0 -0
  12. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/__init__.py +0 -0
  13. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/autopilot.py +0 -0
  14. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/interactive.py +0 -0
  15. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/mcp.py +0 -0
  16. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/sandbox.py +0 -0
  17. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/cli/streaming.py +0 -0
  18. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/config.py +0 -0
  19. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/context.py +0 -0
  20. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/permissions.py +0 -0
  21. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/prompts/react_prompt.md +0 -0
  22. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/prompts/system_prompt.md +0 -0
  23. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/skills.py +0 -0
  24. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/__init__.py +0 -0
  25. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/audit.py +0 -0
  26. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/browser_tools.py +0 -0
  27. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/code_analysis.py +0 -0
  28. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/common.py +0 -0
  29. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/definitions.py +0 -0
  30. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/file_reading.py +0 -0
  31. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/file_writing.py +0 -0
  32. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/find_tool.py +0 -0
  33. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/grep_tool.py +0 -0
  34. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/image_handler.py +0 -0
  35. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/mcp.py +0 -0
  36. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/repo_map.py +0 -0
  37. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/shell_tools.py +0 -0
  38. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/todo_tools.py +0 -0
  39. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/tool_schema.py +0 -0
  40. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/user_interaction.py +0 -0
  41. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal/tools/web_tools.py +0 -0
  42. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal.egg-info/SOURCES.txt +0 -0
  43. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal.egg-info/dependency_links.txt +0 -0
  44. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal.egg-info/entry_points.txt +0 -0
  45. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal.egg-info/requires.txt +0 -0
  46. {patchpal-0.25.3 → patchpal-0.25.4}/patchpal.egg-info/top_level.txt +0 -0
  47. {patchpal-0.25.3 → patchpal-0.25.4}/pyproject.toml +0 -0
  48. {patchpal-0.25.3 → patchpal-0.25.4}/setup.cfg +0 -0
  49. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_cli.py +0 -0
  50. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_config_dynamic.py +0 -0
  51. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_context.py +0 -0
  52. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_custom_tools.py +0 -0
  53. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_enabled_tools.py +0 -0
  54. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_find_tool.py +0 -0
  55. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_guardrails.py +0 -0
  56. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_image_blocking.py +0 -0
  57. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_maximum_security.py +0 -0
  58. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_mcp_config.py +0 -0
  59. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_memory.py +0 -0
  60. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_memory_locations.py +0 -0
  61. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_operational_safety.py +0 -0
  62. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_optional_tools.py +0 -0
  63. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_permissions.py +0 -0
  64. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_react.py +0 -0
  65. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_reasoning_content.py +0 -0
  66. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_repo_map.py +0 -0
  67. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_simplified_prompt.py +0 -0
  68. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_skills.py +0 -0
  69. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_streaming.py +0 -0
  70. {patchpal-0.25.3 → patchpal-0.25.4}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.25.3
3
+ Version: 0.25.4
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
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.25.3"
3
+ __version__ = "0.25.4"
4
4
 
5
5
  from patchpal.agent import create_agent, create_react_agent
6
6
  from patchpal.cli.autopilot import autopilot_loop
@@ -311,7 +311,9 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
311
311
  """Apply prompt caching markers to messages following OpenCode's strategy.
312
312
 
313
313
  Caches:
314
- - System messages (first 1-2 messages with role="system")
314
+ - The FIRST system message only (the static SYSTEM_PROMPT). The volatile
315
+ per-call date/time is appended as a trailing system message and is
316
+ deliberately NOT cached (its content changes every call).
315
317
  - Last 2 non-system messages (recent context, any role except system)
316
318
 
317
319
  IMPORTANT: AWS Bedrock enforces a hard limit of 4 cache control blocks per request.
@@ -389,6 +391,13 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
389
391
  # Find system messages (usually at the start)
390
392
  system_messages = [i for i, msg in enumerate(messages) if msg.get("role") == "system"]
391
393
 
394
+ # Only the FIRST system message (the static SYSTEM_PROMPT) is a stable cache
395
+ # prefix. A trailing system message (the per-call date/time, appended at the
396
+ # end of the list) must NOT be cached: its content changes every call, so a
397
+ # breakpoint there is wasted and can invalidate the prefix. Restrict caching
398
+ # to the first system message only.
399
+ system_messages = system_messages[:1]
400
+
392
401
  # Find last 2 non-system messages (recent context)
393
402
  non_system_messages = [i for i, msg in enumerate(messages) if msg.get("role") != "system"]
394
403
  last_two_indices = (
@@ -401,8 +410,10 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
401
410
  # Build list of candidate indices to cache (prioritize system messages)
402
411
  candidates = []
403
412
 
404
- # Add system messages first (up to 2)
405
- for idx in system_messages[:2]:
413
+ # Add the (single, static) system message first. `system_messages` was
414
+ # already restricted to the first system message above; the trailing
415
+ # per-call datetime is deliberately excluded.
416
+ for idx in system_messages:
406
417
  msg = messages[idx]
407
418
  # Check if this message already has any cache markers
408
419
  has_cache = False
@@ -1176,12 +1187,19 @@ Repository root takes priority if both exist."""
1176
1187
  # Show thinking message
1177
1188
  print("\033[2m🤔 Thinking...\033[0m", flush=True)
1178
1189
 
1179
- # Prepare messages with system prompt and dynamic date/time
1180
- # Date/time is regenerated on each call to prevent staleness in long sessions
1181
- messages = [
1182
- {"role": "system", "content": SYSTEM_PROMPT},
1183
- {"role": "system", "content": _get_current_datetime_message()},
1184
- ] + self.messages
1190
+ # Prepare messages with system prompt and dynamic date/time.
1191
+ # The static SYSTEM_PROMPT is kept FIRST so it (plus the tools block)
1192
+ # forms a stable, byte-identical cacheable prefix across every call.
1193
+ # The volatile date/time is appended at the END instead of in a second
1194
+ # cached system block: placing a per-call-changing string inside the
1195
+ # cached prefix previously invalidated the prefix every call and wasted
1196
+ # a breakpoint (see COST_RECOMMENDATIONS_RANKED.md, Tier 2 fix).
1197
+ # Date/time is regenerated on each call to prevent staleness in long sessions.
1198
+ messages = (
1199
+ [{"role": "system", "content": SYSTEM_PROMPT}]
1200
+ + self.messages
1201
+ + [{"role": "system", "content": _get_current_datetime_message()}]
1202
+ )
1185
1203
 
1186
1204
  # Filter images if BLOCK_IMAGES is enabled (for non-vision models or user preference)
1187
1205
  messages = self.image_handler.filter_images_if_blocked(messages)
@@ -1268,6 +1286,24 @@ Repository root takes priority if both exist."""
1268
1286
  ):
1269
1287
  self.cumulative_cache_read_tokens += response.usage.cache_read_input_tokens
1270
1288
 
1289
+ # Per-call cache instrumentation (Tier 2 #6): logs this call's
1290
+ # write/read split so the suffix-rewrite / datetime-breakpoint
1291
+ # hypotheses can be confirmed with data instead of inference.
1292
+ # Zero overhead unless PATCHPAL_DEBUG_CACHE=true.
1293
+ if os.environ.get("PATCHPAL_DEBUG_CACHE") == "true":
1294
+ _call_writes = (
1295
+ getattr(response.usage, "cache_creation_input_tokens", 0) or 0
1296
+ )
1297
+ _call_reads = getattr(response.usage, "cache_read_input_tokens", 0) or 0
1298
+ _call_prompt = getattr(response.usage, "prompt_tokens", 0) or 0
1299
+ _denom = _call_writes + _call_reads
1300
+ _hit = (_call_reads / _denom * 100) if _denom else 0.0
1301
+ print(
1302
+ f"[CACHE DEBUG] call #{self.total_llm_calls}: "
1303
+ f"prompt={_call_prompt} write={_call_writes} "
1304
+ f"read={_call_reads} this-call-hit={_hit:.1f}%"
1305
+ )
1306
+
1271
1307
  # Track OpenAI cache tokens (prompt_tokens_details.cached_tokens)
1272
1308
  # Only track for OpenAI models to avoid LiteLLM cross-contamination
1273
1309
  if self._is_openai_model() and hasattr(response.usage, "prompt_tokens_details"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.25.3
3
+ Version: 0.25.4
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
@@ -645,6 +645,78 @@ def test_prompt_caching_idempotent():
645
645
  assert len([k for k in cached_twice[1]["content"][0].keys() if "cache" in k]) == 1
646
646
 
647
647
 
648
+ def _has_cache_marker(message):
649
+ """Return True if a message's content carries a cache_control/cachePoint marker."""
650
+ content = message.get("content")
651
+ if not isinstance(content, list):
652
+ return False
653
+ return any(
654
+ isinstance(block, dict) and ("cache_control" in block or "cachePoint" in block)
655
+ for block in content
656
+ )
657
+
658
+
659
+ def test_prompt_caching_excludes_trailing_datetime_system_message():
660
+ """Regression test for the prompt-caching cost fix.
661
+
662
+ The volatile per-call date/time is appended as a TRAILING system message
663
+ (after the conversation), while the static SYSTEM_PROMPT is the FIRST
664
+ system message. Only the first system message forms a stable, cacheable
665
+ prefix; a breakpoint on the trailing date/time would change every call,
666
+ invalidating the cached prefix and wasting a (Bedrock-limited) breakpoint.
667
+
668
+ This test locks in that invariant: _apply_prompt_caching must mark the
669
+ first system message but must NOT mark a trailing system message.
670
+ See DATETIME_CACHE_PREFIX_FIX.md / COST_RECOMMENDATIONS_RANKED.md (Tier 2).
671
+ """
672
+ from patchpal.agent.function_calling import _apply_prompt_caching
673
+
674
+ # Mirrors the message assembly in PatchPalAgent.run(): static SYSTEM_PROMPT
675
+ # first, conversation in the middle, volatile date/time appended last.
676
+ messages = [
677
+ {"role": "system", "content": "STATIC SYSTEM PROMPT (cacheable)"},
678
+ {"role": "user", "content": "Hello"},
679
+ {"role": "assistant", "content": "Hi there!"},
680
+ {"role": "user", "content": "How are you?"},
681
+ {"role": "system", "content": "## Current Date and Time\nToday is ..."},
682
+ ]
683
+
684
+ cached = _apply_prompt_caching(messages.copy(), "anthropic/claude-sonnet-4-5")
685
+
686
+ # First (static) system message MUST be cached.
687
+ assert cached[0]["role"] == "system"
688
+ assert _has_cache_marker(cached[0]), "static SYSTEM_PROMPT should be cached"
689
+
690
+ # Trailing (volatile) date/time system message MUST NOT be cached.
691
+ assert cached[-1]["role"] == "system"
692
+ assert not _has_cache_marker(cached[-1]), (
693
+ "trailing per-call date/time must never receive a cache breakpoint"
694
+ )
695
+
696
+ # Exactly one system message should carry a marker (the static prefix).
697
+ cached_system_count = sum(
698
+ 1 for m in cached if m.get("role") == "system" and _has_cache_marker(m)
699
+ )
700
+ assert cached_system_count == 1
701
+
702
+
703
+ def test_datetime_message_distinct_from_system_prompt():
704
+ """The date/time message must be a separate string from SYSTEM_PROMPT.
705
+
706
+ The fix depends on keeping the volatile date/time OUT of the static
707
+ SYSTEM_PROMPT so the latter stays byte-identical (and cacheable) across
708
+ calls. This guards against a future refactor folding the date/time back
709
+ into the system prompt.
710
+ """
711
+ from patchpal.agent.function_calling import SYSTEM_PROMPT, _get_current_datetime_message
712
+
713
+ datetime_msg = _get_current_datetime_message()
714
+
715
+ assert "Current Date and Time" in datetime_msg
716
+ # The static prompt must not embed the volatile date/time header.
717
+ assert "Current Date and Time" not in SYSTEM_PROMPT
718
+
719
+
648
720
  def test_agent_handles_keyboard_interrupt_during_tool_execution(monkeypatch):
649
721
  """Test that agent properly cleans up conversation state when interrupted during tool execution."""
650
722
  from patchpal.agent import create_agent
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