patchpal 0.25.2__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.
- {patchpal-0.25.2/patchpal.egg-info → patchpal-0.25.4}/PKG-INFO +1 -1
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/__init__.py +1 -1
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/agent/function_calling.py +45 -9
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/sandbox.py +21 -1
- {patchpal-0.25.2 → patchpal-0.25.4/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_agent.py +72 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/LICENSE +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/MANIFEST.in +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/README.md +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/agent/__init__.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/agent/bedrock_profile_utils.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/agent/react.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/__init__.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/autopilot.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/interactive.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/mcp.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/cli/streaming.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/config.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/context.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/permissions.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/prompts/react_prompt.md +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/prompts/system_prompt.md +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/skills.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/__init__.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/audit.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/browser_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/code_analysis.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/common.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/definitions.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/file_reading.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/file_writing.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/find_tool.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/grep_tool.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/image_handler.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/mcp.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/repo_map.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/shell_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/todo_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/tool_schema.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/user_interaction.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal/tools/web_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal.egg-info/SOURCES.txt +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/pyproject.toml +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/setup.cfg +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_cli.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_config_dynamic.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_context.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_custom_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_enabled_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_find_tool.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_guardrails.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_image_blocking.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_maximum_security.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_mcp_config.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_memory.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_memory_locations.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_operational_safety.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_optional_tools.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_permissions.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_react.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_reasoning_content.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_repo_map.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_simplified_prompt.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_skills.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_streaming.py +0 -0
- {patchpal-0.25.2 → patchpal-0.25.4}/tests/test_tools.py +0 -0
|
@@ -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
|
-
-
|
|
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
|
|
405
|
-
|
|
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
|
-
#
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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"):
|
|
@@ -198,7 +198,14 @@ def detect_llm_endpoints_from_env():
|
|
|
198
198
|
or os.environ.get("AWS_REGION_NAME") # LiteLLM-specific
|
|
199
199
|
)
|
|
200
200
|
if aws_region and (
|
|
201
|
-
os.environ.get("AWS_ACCESS_KEY_ID")
|
|
201
|
+
os.environ.get("AWS_ACCESS_KEY_ID")
|
|
202
|
+
or os.environ.get("AWS_SECRET_ACCESS_KEY")
|
|
203
|
+
# SSO/ADFS profiles (created via `aws configure sso`) authenticate via a
|
|
204
|
+
# named profile and a cached SSO token instead of static access keys.
|
|
205
|
+
# In that case AWS_PROFILE is set but AWS_ACCESS_KEY_ID is not, so detect
|
|
206
|
+
# it here too — otherwise Bedrock endpoints would be omitted from the
|
|
207
|
+
# firewall allowlist and traffic would be blocked.
|
|
208
|
+
or os.environ.get("AWS_PROFILE")
|
|
202
209
|
):
|
|
203
210
|
# Add Bedrock endpoints for the region
|
|
204
211
|
# Support both GovCloud and standard regions
|
|
@@ -659,6 +666,19 @@ def build_container_args(sandbox_args, patchpal_args, env_file_keys=None):
|
|
|
659
666
|
container_args.extend(["-v", f"{patchpal_dir}:/root/.patchpal"])
|
|
660
667
|
mounted_paths.add(patchpal_dir)
|
|
661
668
|
|
|
669
|
+
# Mount ~/.aws if it exists (read-only).
|
|
670
|
+
# Required for AWS SSO/ADFS authentication: credentials for a named profile
|
|
671
|
+
# (created via `aws configure sso`) are resolved from ~/.aws/config, and the
|
|
672
|
+
# short-lived SSO token is cached under ~/.aws/sso/cache/. Mounting this
|
|
673
|
+
# directory lets boto3/LiteLLM inside the container resolve AWS_PROFILE just
|
|
674
|
+
# as it would on the host. It also covers static shared-credential setups
|
|
675
|
+
# (~/.aws/credentials). Mounted read-only; refresh the token on the host with
|
|
676
|
+
# `aws sso login --profile <name>` when it expires.
|
|
677
|
+
aws_dir = os.path.expanduser("~/.aws")
|
|
678
|
+
if os.path.isdir(aws_dir) and aws_dir not in mounted_paths:
|
|
679
|
+
container_args.extend(["-v", f"{aws_dir}:/root/.aws:ro"])
|
|
680
|
+
mounted_paths.add(aws_dir)
|
|
681
|
+
|
|
662
682
|
# Development mode: mount local patchpal source code
|
|
663
683
|
dev_mode = False
|
|
664
684
|
if sandbox_args.dev:
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|