Skip to content

Commit 0e83fbb

Browse files
committed
refactor: unify system prompt handling and add Anthropic prompt caching
Consolidate system prompt composition logic into system_prompt_overrides module with select_base_system_prompt and compose_system_prompt helpers. Add Anthropic prompt caching support with cache-aware message shaping: - Introduce ANTHROPIC_SYSTEM_PROMPT_DYNAMIC_BOUNDARY for segmenting static (cacheable) and dynamic prompt portions - Add build_anthropic_system_blocks() to render cache-controlled blocks - Add cache control markers to messages and tool schemas - Support RIPPERDOC_DISABLE_PROMPT_CACHING and TTL configuration Update CLI, RichUI, and stdio handler to use unified prompt composition. Add tests covering cache boundary insertion and message shaping. Generated with Ripperdoc Co-Authored-By: Ripperdoc
1 parent a92a985 commit 0e83fbb

25 files changed

Lines changed: 550 additions & 93 deletions

ripperdoc/cli/bootstrap_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def _run_stdio_mode_if_requested(
347347
permission_mode: str,
348348
max_turns: Optional[int],
349349
system_prompt: Optional[str],
350+
append_system_prompt: Optional[str],
350351
verbose: bool,
351352
continue_session: bool,
352353
resume_session: Optional[str],
@@ -404,6 +405,7 @@ def _run_stdio_mode_if_requested(
404405
"permission_mode": permission_mode,
405406
"max_turns": max_turns,
406407
"system_prompt": system_prompt,
408+
"append_system_prompt": append_system_prompt,
407409
"verbose": effective_verbose,
408410
}
409411
if allowed_tools is not None:

ripperdoc/cli/cli.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ripperdoc.cli.ui.wizard import check_onboarding
4141
from ripperdoc.core.config import get_effective_model_profile, get_project_config
4242
from ripperdoc.core.plugins import set_runtime_plugin_dirs
43+
from ripperdoc.core.system_prompt_overrides import select_base_system_prompt
4344
from ripperdoc.core.tool_defaults import get_default_tools
4445
from ripperdoc.utils.filesystem.git_utils import get_git_root
4546
from ripperdoc.utils.log import configure_debug_logging, enable_session_file_logging, get_logger
@@ -81,16 +82,15 @@ def _resolve_model_pointer_with_fallback(
8182
return resolved_model
8283

8384

84-
def _merge_append_system_prompt(
85-
append_system_prompt: Optional[str],
85+
def _select_effective_system_prompt(
86+
system_prompt: Optional[str],
8687
session_agent_prompt: Optional[str],
8788
) -> Optional[str]:
88-
"""Compose agent prompt and append-system-prompt with deterministic order."""
89-
if not session_agent_prompt:
90-
return append_system_prompt
91-
if not append_system_prompt:
92-
return session_agent_prompt
93-
return f"{session_agent_prompt}\n\n{append_system_prompt}"
89+
"""Resolve the base system prompt with session agent precedence."""
90+
return select_base_system_prompt(
91+
agent_system_prompt=session_agent_prompt,
92+
custom_system_prompt=system_prompt,
93+
)
9494

9595

9696
def _coerce_session_id(
@@ -642,8 +642,8 @@ def cli(
642642
sdk_url=sdk_url,
643643
session_persistence=session_persistence,
644644
)
645-
effective_append_system_prompt = _merge_append_system_prompt(
646-
append_system_prompt,
645+
effective_system_prompt = _select_effective_system_prompt(
646+
system_prompt,
647647
session_agent_prompt,
648648
)
649649
precreated_worktree = _register_precreated_worktree_from_env()
@@ -695,7 +695,8 @@ def cli(
695695
model=model,
696696
permission_mode=effective_permission_mode,
697697
max_turns=max_turns,
698-
system_prompt=system_prompt,
698+
system_prompt=effective_system_prompt,
699+
append_system_prompt=append_system_prompt,
699700
verbose=verbose,
700701
allowed_tools_csv=allowed_tools_csv,
701702
disallowed_tools_csv=disallowed_tools_csv,
@@ -802,8 +803,8 @@ def cli(
802803
"verbose": verbose,
803804
"allowed_tools": allowed_tools,
804805
"model": model,
805-
"has_system_prompt": system_prompt is not None,
806-
"has_append_system_prompt": effective_append_system_prompt is not None,
806+
"has_system_prompt": effective_system_prompt is not None,
807+
"has_append_system_prompt": append_system_prompt is not None,
807808
"disable_slash_commands": disable_slash_commands,
808809
"debug_mode": bool(debug_mode or debug_file),
809810
"debug_filter": debug_filter,
@@ -823,8 +824,8 @@ def cli(
823824
yolo_mode,
824825
verbose,
825826
session_id=session_id,
826-
custom_system_prompt=system_prompt,
827-
append_system_prompt=effective_append_system_prompt,
827+
custom_system_prompt=effective_system_prompt,
828+
append_system_prompt=append_system_prompt,
828829
model=model,
829830
fallback_model=fallback_model,
830831
max_thinking_tokens=max_thinking_tokens,
@@ -854,8 +855,8 @@ def cli(
854855
session_id=session_id,
855856
log_file_path=log_file,
856857
allowed_tools=allowed_tools,
857-
custom_system_prompt=system_prompt,
858-
append_system_prompt=effective_append_system_prompt,
858+
custom_system_prompt=effective_system_prompt,
859+
append_system_prompt=append_system_prompt,
859860
model=interactive_model,
860861
max_thinking_tokens=max_thinking_tokens,
861862
max_turns=max_turns,

ripperdoc/cli/runtime_cli.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ripperdoc.core.query import QueryContext, query
2222
from ripperdoc.core.skills import build_skill_summary, filter_enabled_skills, load_all_skills
2323
from ripperdoc.core.system_prompt import build_system_prompt
24+
from ripperdoc.core.system_prompt_overrides import compose_system_prompt
2425
from ripperdoc.core.tool_defaults import filter_tools_by_names
2526
from ripperdoc.cli.ui.choice import ChoiceOption, prompt_choice_async
2627
from ripperdoc.tools.background_shell import shutdown_background_shell
@@ -189,24 +190,20 @@ def _build_effective_system_prompt(
189190
output_language: str,
190191
project_path: Path,
191192
) -> str:
192-
if custom_system_prompt:
193-
system_prompt = custom_system_prompt
194-
if append_system_prompt:
195-
system_prompt = f"{system_prompt}\n\n{append_system_prompt}"
196-
return system_prompt
197-
198193
all_instructions = list(additional_instructions) if additional_instructions else []
199-
if append_system_prompt:
200-
all_instructions.append(append_system_prompt)
201-
return build_system_prompt(
202-
tools,
203-
prompt,
204-
context,
205-
additional_instructions=all_instructions or None,
206-
mcp_instructions=mcp_instructions,
207-
output_style=output_style,
208-
output_language=output_language,
209-
project_path=project_path,
194+
return compose_system_prompt(
195+
base_system_prompt=custom_system_prompt,
196+
append_system_prompt=append_system_prompt,
197+
default_prompt_factory=lambda: build_system_prompt(
198+
tools,
199+
prompt,
200+
context,
201+
additional_instructions=all_instructions or None,
202+
mcp_instructions=mcp_instructions,
203+
output_style=output_style,
204+
output_language=output_language,
205+
project_path=project_path,
206+
),
210207
)
211208

212209

ripperdoc/cli/ui/message_display.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,12 @@
1616
from ripperdoc.utils.messaging.messages import (
1717
AssistantMessage,
1818
AttachmentMessage,
19+
ConversationMessage,
1920
ProgressMessage,
2021
UserMessage,
2122
)
2223
from ripperdoc.utils.messaging.message_formatting import format_reasoning_preview
2324

24-
ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage, AttachmentMessage]
25-
2625

2726
class MessageDisplay:
2827
"""Handles message rendering and display operations."""

ripperdoc/cli/ui/rich_ui/session.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ripperdoc.core.tool import ToolProgress, ToolResult, ToolUseContext
3333
from ripperdoc.core.hooks.state import bind_pending_message_queue
3434
from ripperdoc.core.system_prompt import build_system_prompt
35+
from ripperdoc.core.system_prompt_overrides import compose_system_prompt
3536
from ripperdoc.core.skills import build_skill_summary, filter_enabled_skills, load_all_skills
3637
from ripperdoc.core.hooks.manager import hook_manager
3738
from ripperdoc.core.hooks.llm_callback import build_hook_llm_callback
@@ -78,6 +79,7 @@
7879
from ripperdoc.utils.messaging.messages import (
7980
AssistantMessage,
8081
AttachmentMessage,
82+
ConversationMessage,
8183
ProgressMessage,
8284
UserMessage,
8385
create_hook_additional_context_message,
@@ -113,9 +115,6 @@
113115
)
114116

115117

116-
# Type alias for conversation messages
117-
ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage, AttachmentMessage]
118-
119118
console = Console()
120119
logger = get_logger()
121120
_RESUME_REPLAY_LIMIT_ENV = "RIPPERDOC_RESUME_REPLAY_MAX_MESSAGES"
@@ -1312,18 +1311,11 @@ async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str,
13121311
# Build system prompt based on options:
13131312
# - custom_system_prompt: replaces the default entirely
13141313
# - append_system_prompt: appends to the default system prompt
1315-
if self.custom_system_prompt:
1316-
# Complete replacement
1317-
system_prompt = self.custom_system_prompt
1318-
# Still append if both are provided
1319-
if self.append_system_prompt:
1320-
system_prompt = f"{system_prompt}\n\n{self.append_system_prompt}"
1321-
else:
1322-
# Build default with optional append
1323-
all_instructions = list(additional_instructions) if additional_instructions else []
1324-
if self.append_system_prompt:
1325-
all_instructions.append(self.append_system_prompt)
1326-
system_prompt = build_system_prompt(
1314+
all_instructions = list(additional_instructions) if additional_instructions else []
1315+
system_prompt = compose_system_prompt(
1316+
base_system_prompt=self.custom_system_prompt,
1317+
append_system_prompt=self.append_system_prompt,
1318+
default_prompt_factory=lambda: build_system_prompt(
13271319
self.query_context.tools if self.query_context else [],
13281320
user_input,
13291321
context,
@@ -1332,7 +1324,8 @@ async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str,
13321324
output_style=self.output_style,
13331325
output_language=self.output_language,
13341326
project_path=self.project_path,
1335-
)
1327+
),
1328+
)
13361329

13371330
return system_prompt, context
13381331

ripperdoc/core/message_utils.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
import re
78
from typing import Any, Dict, List, Mapping, Optional, Union
89
from uuid import uuid4
@@ -25,6 +26,8 @@
2526

2627
logger = get_logger()
2728

29+
ANTHROPIC_SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
30+
2831

2932
def _safe_int(value: object) -> int:
3033
"""Best-effort int conversion for usage counters."""
@@ -473,18 +476,26 @@ def build_full_system_prompt(
473476
context: Dict[str, str],
474477
tool_mode: str,
475478
tools: List[Tool[Any, Any]],
479+
*,
480+
include_anthropic_cache_boundary: bool = False,
476481
) -> str:
477482
"""Compose the final system prompt including context and tool hints."""
478-
full_prompt = system_prompt
483+
dynamic_segments: List[str] = []
479484
if context:
480485
context_reminder = format_context_as_system_reminder(context)
481486
if context_reminder:
482-
full_prompt = f"{system_prompt}\n\n{context_reminder}"
487+
dynamic_segments.append(context_reminder)
483488
if tool_mode == "text":
484489
tool_hint = _tool_prompt_for_text_mode(tools)
485490
if tool_hint:
486-
full_prompt = f"{full_prompt}\n\n{tool_hint}"
487-
return full_prompt
491+
dynamic_segments.append(tool_hint)
492+
if include_anthropic_cache_boundary and dynamic_segments:
493+
return "\n\n".join(
494+
[system_prompt, ANTHROPIC_SYSTEM_PROMPT_DYNAMIC_BOUNDARY, *dynamic_segments]
495+
)
496+
if dynamic_segments:
497+
return "\n\n".join([system_prompt, *dynamic_segments])
498+
return system_prompt
488499

489500

490501
def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
@@ -523,6 +534,121 @@ async def build_anthropic_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict
523534
return schemas
524535

525536

537+
def anthropic_prompt_caching_enabled() -> bool:
538+
"""Return whether Anthropic prompt caching should be enabled for request shaping."""
539+
return not (
540+
os.getenv("RIPPERDOC_DISABLE_PROMPT_CACHING")
541+
or os.getenv("DISABLE_PROMPT_CACHING")
542+
)
543+
544+
545+
def anthropic_cache_control() -> Dict[str, Any]:
546+
"""Default Anthropic cache control payload matching Claude Code's ephemeral strategy."""
547+
ttl = (os.getenv("RIPPERDOC_PROMPT_CACHE_TTL") or "").strip()
548+
payload: Dict[str, Any] = {"type": "ephemeral"}
549+
if ttl == "1h":
550+
payload["ttl"] = ttl
551+
return payload
552+
553+
554+
def build_anthropic_system_blocks(
555+
system_prompt: str, *, enable_prompt_caching: bool
556+
) -> str | List[Dict[str, Any]]:
557+
"""Render Anthropic system blocks with optional cache-aware segmentation."""
558+
text = (system_prompt or "").strip()
559+
if not text or not enable_prompt_caching:
560+
return text
561+
562+
if ANTHROPIC_SYSTEM_PROMPT_DYNAMIC_BOUNDARY in text:
563+
prefix, suffix = text.split(ANTHROPIC_SYSTEM_PROMPT_DYNAMIC_BOUNDARY, 1)
564+
blocks: List[Dict[str, Any]] = []
565+
prefix = prefix.strip()
566+
suffix = suffix.strip()
567+
if prefix:
568+
blocks.append(
569+
{
570+
"type": "text",
571+
"text": prefix,
572+
"cache_control": anthropic_cache_control(),
573+
}
574+
)
575+
if suffix:
576+
blocks.append({"type": "text", "text": suffix})
577+
return blocks
578+
579+
return [
580+
{
581+
"type": "text",
582+
"text": text,
583+
"cache_control": anthropic_cache_control(),
584+
}
585+
]
586+
587+
588+
def apply_anthropic_prompt_cache_control_to_tool_schemas(
589+
tool_schemas: List[Dict[str, Any]], *, enable_prompt_caching: bool
590+
) -> List[Dict[str, Any]]:
591+
"""Add Anthropic cache markers to tool definitions."""
592+
if not enable_prompt_caching or not tool_schemas:
593+
return list(tool_schemas)
594+
cache_control = anthropic_cache_control()
595+
return [{**schema, "cache_control": dict(cache_control)} for schema in tool_schemas]
596+
597+
598+
def apply_anthropic_prompt_cache_control_to_messages(
599+
messages: List[Dict[str, Any]],
600+
*,
601+
enable_prompt_caching: bool,
602+
recent_messages: int = 2,
603+
) -> List[Dict[str, Any]]:
604+
"""Attach cache markers to the tail of the Anthropic transcript."""
605+
if not enable_prompt_caching or not messages:
606+
return list(messages)
607+
608+
cache_control = anthropic_cache_control()
609+
start_index = max(0, len(messages) - max(recent_messages, 1))
610+
shaped_messages: List[Dict[str, Any]] = []
611+
612+
for index, message in enumerate(messages):
613+
shaped_message = dict(message)
614+
content = message.get("content")
615+
if index < start_index:
616+
shaped_messages.append(shaped_message)
617+
continue
618+
619+
if isinstance(content, str):
620+
shaped_message["content"] = [
621+
{
622+
"type": "text",
623+
"text": content,
624+
"cache_control": dict(cache_control),
625+
}
626+
]
627+
shaped_messages.append(shaped_message)
628+
continue
629+
630+
if not isinstance(content, list):
631+
shaped_messages.append(shaped_message)
632+
continue
633+
634+
copied_content = [dict(item) if isinstance(item, dict) else item for item in content]
635+
for content_index in range(len(copied_content) - 1, -1, -1):
636+
item = copied_content[content_index]
637+
if not isinstance(item, dict):
638+
continue
639+
if item.get("type") in {"thinking", "redacted_thinking"}:
640+
continue
641+
copied_content[content_index] = {
642+
**item,
643+
"cache_control": dict(cache_control),
644+
}
645+
break
646+
shaped_message["content"] = copied_content
647+
shaped_messages.append(shaped_message)
648+
649+
return shaped_messages
650+
651+
526652
async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
527653
"""Render tool schemas in OpenAI function-calling format."""
528654
openai_tools = []

0 commit comments

Comments
 (0)