tunacode-cli 0.1.21__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,66 @@
1
+ """Prompt templates for agent intervention mechanisms.
2
+
3
+ Extracted from main.py to centralize all prompt strings and formatting logic.
4
+ """
5
+
6
+
7
+ def format_no_progress(
8
+ message: str,
9
+ unproductive_count: int,
10
+ last_productive: int,
11
+ current: int,
12
+ max_iterations: int,
13
+ ) -> str:
14
+ """Format the no-progress alert message.
15
+
16
+ Reference: main.py _force_action_if_unproductive() lines 265-275
17
+ """
18
+ return (
19
+ f"ALERT: No tools executed for {unproductive_count} iterations.\n\n"
20
+ f"Last productive iteration: {last_productive}\n"
21
+ f"Current iteration: {current}/{max_iterations}\n"
22
+ f"Task: {message[:200]}...\n\n"
23
+ "You're describing actions but not executing them. You MUST:\n\n"
24
+ "1. If task is COMPLETE: Start response with TUNACODE DONE:\n"
25
+ "2. If task needs work: Execute a tool RIGHT NOW (grep, read_file, bash, etc.)\n"
26
+ "3. If stuck: Explain the specific blocker\n\n"
27
+ "NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."
28
+ )
29
+
30
+
31
+ def format_clarification(original_query: str, iteration: int, tools_used: str) -> str:
32
+ """Format the clarification request message.
33
+
34
+ Reference: main.py _ask_for_clarification() lines 284-292
35
+ """
36
+ return (
37
+ "I need clarification to continue.\n\n"
38
+ f"Original request: {original_query}\n\n"
39
+ "Progress so far:\n"
40
+ f"- Iterations: {iteration}\n"
41
+ f"- Tools used: {tools_used}\n\n"
42
+ "If the task is complete, I should respond with TUNACODE DONE:\n"
43
+ "Otherwise, please provide specific guidance on what to do next."
44
+ )
45
+
46
+
47
+ def format_iteration_limit(max_iterations: int, iteration: int, tools_used: str) -> str:
48
+ """Format the iteration limit reached message.
49
+
50
+ Reference: main.py process_request() lines 495-501
51
+ """
52
+ if tools_used == "No tools used yet":
53
+ tools_used = "No tools used"
54
+
55
+ return (
56
+ f"I've reached the iteration limit ({max_iterations}).\n\n"
57
+ "Progress summary:\n"
58
+ f"- Tools used: {tools_used}\n"
59
+ f"- Iterations completed: {iteration}\n\n"
60
+ "Please add more context to the task."
61
+ )
62
+
63
+
64
+ # Note: Empty response handling is delegated to agent_components.handle_empty_response()
65
+ # which uses create_empty_response_message() from agent_helpers.py
66
+ # No template needed here as it's already modularized.
@@ -0,0 +1,231 @@
1
+ """Research agent factory for read-only codebase exploration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from httpx import AsyncClient, HTTPStatusError
7
+ from pydantic_ai import Agent, Tool
8
+ from pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_after
9
+ from tenacity import retry_if_exception_type, stop_after_attempt
10
+
11
+ from tunacode.core.prompting import (
12
+ RESEARCH_TEMPLATE,
13
+ SectionLoader,
14
+ SystemPromptSection,
15
+ compose_prompt,
16
+ resolve_prompt,
17
+ )
18
+ from tunacode.core.state import StateManager
19
+ from tunacode.tools.glob import glob
20
+ from tunacode.tools.grep import grep
21
+ from tunacode.tools.list_dir import list_dir
22
+ from tunacode.tools.read_file import read_file
23
+ from tunacode.types import ModelName, ToolProgress, ToolProgressCallback
24
+
25
+ # Maximum wait time in seconds for retry backoff
26
+ MAX_RETRY_WAIT_SECONDS = 60
27
+
28
+
29
+ def _load_research_prompt() -> str:
30
+ """Load research-specific system prompt with section-based composition.
31
+
32
+ Loads individual section files from prompts/research/sections/ and
33
+ composes them using RESEARCH_TEMPLATE.
34
+
35
+ Returns:
36
+ Research system prompt content
37
+
38
+ Raises:
39
+ FileNotFoundError: If prompts/research/sections/ does not exist
40
+ """
41
+ # Navigate from this file: core/agents/research_agent.py -> src/tunacode/prompts/research
42
+ base_path = Path(__file__).parent.parent.parent
43
+ prompts_dir = base_path / "prompts" / "research"
44
+ sections_dir = prompts_dir / "sections"
45
+
46
+ if not sections_dir.exists():
47
+ raise FileNotFoundError(
48
+ f"Required sections directory not found: {sections_dir}. "
49
+ "The prompts/research/sections/ directory must exist."
50
+ )
51
+
52
+ loader = SectionLoader(sections_dir)
53
+ sections = {s.value: loader.load_section(s) for s in SystemPromptSection}
54
+
55
+ # Compose sections into research template, then resolve dynamic placeholders
56
+ prompt = compose_prompt(RESEARCH_TEMPLATE, sections)
57
+ return resolve_prompt(prompt)
58
+
59
+
60
+ def _create_limited_read_file(max_files: int):
61
+ """Create a read_file wrapper that enforces a maximum number of calls.
62
+
63
+ Args:
64
+ max_files: Maximum number of files that can be read
65
+
66
+ Returns:
67
+ Wrapped read_file function with call limit enforcement
68
+ """
69
+ call_count = {"count": 0}
70
+
71
+ async def limited_read_file(file_path: str) -> str:
72
+ """Read file with enforced limit on number of calls.
73
+
74
+ Args:
75
+ file_path: Path to file to read
76
+
77
+ Returns:
78
+ File content dict from read_file tool, or limit message if exceeded
79
+
80
+ Note:
81
+ Returns a warning message instead of raising error when limit reached,
82
+ allowing the agent to complete with partial results.
83
+ """
84
+ if call_count["count"] >= max_files:
85
+ return (
86
+ "<file>\n"
87
+ f"FILE READ LIMIT REACHED ({max_files} files maximum)\n\n"
88
+ f"Cannot read '{file_path}' - you have already read {max_files} files.\n"
89
+ "Please complete your research with the files you have analyzed.\n"
90
+ "</file>"
91
+ )
92
+
93
+ call_count["count"] += 1
94
+ return await read_file(file_path)
95
+
96
+ return limited_read_file
97
+
98
+
99
+ class ProgressTracker:
100
+ """Tracks tool execution progress for subagent feedback.
101
+
102
+ Note: total_operations is always 0 (unknown) since the number of
103
+ tool calls cannot be predicted upfront.
104
+ """
105
+
106
+ def __init__(self, callback: ToolProgressCallback | None, subagent_name: str = "research"):
107
+ self.callback = callback
108
+ self.subagent_name = subagent_name
109
+ self.operation_count = 0
110
+ self.total_operations = 0 # Always 0: total is unknown upfront
111
+
112
+ def emit(self, operation: str) -> None:
113
+ """Emit progress event for current operation."""
114
+ self.operation_count += 1
115
+ if self.callback:
116
+ progress = ToolProgress(
117
+ subagent=self.subagent_name,
118
+ operation=operation,
119
+ current=self.operation_count,
120
+ total=self.total_operations,
121
+ )
122
+ self.callback(progress)
123
+
124
+ def wrap_tool(self, tool_func, tool_name: str):
125
+ """Wrap a tool function to emit progress before execution."""
126
+
127
+ async def wrapped(*args, **kwargs) -> Any:
128
+ # Format operation description
129
+ if args:
130
+ first_arg = str(args[0])[:40]
131
+ operation = f"{tool_name} {first_arg}"
132
+ elif kwargs:
133
+ first_val = str(next(iter(kwargs.values())))[:40]
134
+ operation = f"{tool_name} {first_val}"
135
+ else:
136
+ operation = tool_name
137
+
138
+ self.emit(operation)
139
+ return await tool_func(*args, **kwargs)
140
+
141
+ # Preserve function metadata for pydantic-ai
142
+ wrapped.__name__ = tool_func.__name__
143
+ wrapped.__doc__ = tool_func.__doc__
144
+ wrapped.__annotations__ = getattr(tool_func, "__annotations__", {})
145
+
146
+ return wrapped
147
+
148
+
149
+ def create_research_agent(
150
+ model: ModelName,
151
+ state_manager: StateManager,
152
+ max_files: int = 3,
153
+ progress_callback: ToolProgressCallback | None = None,
154
+ ) -> Agent[dict[str, Any]]:
155
+ """Create research agent with read-only tools and file read limit.
156
+
157
+ IMPORTANT: Uses same model as main agent - do NOT hardcode model selection.
158
+
159
+ Args:
160
+ model: The model name to use (same as main agent)
161
+ state_manager: State manager for session context
162
+ max_files: Maximum number of files the agent can read (hard limit, default: 3)
163
+ progress_callback: Optional callback for tool execution progress updates
164
+
165
+ Returns:
166
+ Agent configured with read-only tools, research system prompt, and file limit
167
+ """
168
+ # Load research-specific system prompt
169
+ system_prompt = _load_research_prompt()
170
+
171
+ # Get configuration from state manager
172
+ max_retries = state_manager.session.user_config.get("settings", {}).get("max_retries", 3)
173
+ tool_strict_validation = state_manager.session.user_config.get("settings", {}).get(
174
+ "tool_strict_validation", False
175
+ )
176
+
177
+ # Import here to avoid circular import with agent_config.py
178
+ # (agent_config imports delegation_tools which imports this module)
179
+ from tunacode.core.agents.agent_components.agent_config import (
180
+ _build_request_hooks,
181
+ _coerce_request_delay,
182
+ _create_model_with_retry,
183
+ )
184
+
185
+ transport = AsyncTenacityTransport(
186
+ config=RetryConfig(
187
+ retry=retry_if_exception_type(HTTPStatusError),
188
+ wait=wait_retry_after(max_wait=MAX_RETRY_WAIT_SECONDS),
189
+ stop=stop_after_attempt(max_retries),
190
+ reraise=True,
191
+ ),
192
+ validate_response=lambda r: r.raise_for_status(),
193
+ )
194
+ request_delay = _coerce_request_delay(state_manager)
195
+ event_hooks = _build_request_hooks(request_delay, state_manager)
196
+ http_client = AsyncClient(transport=transport, event_hooks=event_hooks)
197
+
198
+ model_instance = _create_model_with_retry(model, http_client, state_manager)
199
+
200
+ # Create limited read_file tool that enforces max_files cap
201
+ limited_read_file = _create_limited_read_file(max_files)
202
+
203
+ # Set up progress tracking if callback provided
204
+ tracker = ProgressTracker(progress_callback, "research")
205
+
206
+ # Wrap tools with progress tracking
207
+ if progress_callback:
208
+ tracked_read_file = tracker.wrap_tool(limited_read_file, "read_file")
209
+ tracked_grep = tracker.wrap_tool(grep, "grep")
210
+ tracked_list_dir = tracker.wrap_tool(list_dir, "list_dir")
211
+ tracked_glob = tracker.wrap_tool(glob, "glob")
212
+ else:
213
+ tracked_read_file = limited_read_file
214
+ tracked_grep = grep
215
+ tracked_list_dir = list_dir
216
+ tracked_glob = glob
217
+
218
+ # Create read-only tools list (no write/execute capabilities)
219
+ tools_list = [
220
+ Tool(tracked_read_file, max_retries=max_retries, strict=tool_strict_validation),
221
+ Tool(tracked_grep, max_retries=max_retries, strict=tool_strict_validation),
222
+ Tool(tracked_list_dir, max_retries=max_retries, strict=tool_strict_validation),
223
+ Tool(tracked_glob, max_retries=max_retries, strict=tool_strict_validation),
224
+ ]
225
+
226
+ return Agent(
227
+ model=model_instance,
228
+ system_prompt=system_prompt,
229
+ tools=tools_list,
230
+ output_type=dict, # Structured research output as JSON dict
231
+ )
@@ -0,0 +1,218 @@
1
+ """Tool output pruning for context window management.
2
+
3
+ Implements backward-scanning algorithm to replace old tool outputs with placeholders,
4
+ preserving conversation structure while freeing token budget.
5
+
6
+ Inspired by OpenCode's compaction strategy.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ from tunacode.utils.messaging import estimate_tokens
12
+
13
+ # Symbolic constants for pruning thresholds
14
+ PRUNE_PROTECT_TOKENS: int = 40_000 # Protect last 40k tokens of tool outputs
15
+ PRUNE_MINIMUM_THRESHOLD: int = 20_000 # Only prune if savings exceed 20k
16
+ PRUNE_MIN_USER_TURNS: int = 2 # Require at least 2 user turns before pruning
17
+ PRUNE_PLACEHOLDER: str = "[Old tool result content cleared]"
18
+
19
+ # Message part kind identifiers
20
+ PART_KIND_TOOL_RETURN: str = "tool-return"
21
+ PART_KIND_USER_PROMPT: str = "user-prompt"
22
+
23
+
24
+ def is_tool_return_part(part: Any) -> bool:
25
+ """Check if a message part is a tool return with content.
26
+
27
+ Args:
28
+ part: A message part object
29
+
30
+ Returns:
31
+ True if part has part_kind == "tool-return" and content attribute
32
+ """
33
+ if not hasattr(part, "part_kind"):
34
+ return False
35
+ if part.part_kind != PART_KIND_TOOL_RETURN:
36
+ return False
37
+ if not hasattr(part, "content"): # noqa: SIM103
38
+ return False
39
+ return True
40
+
41
+
42
+ def is_user_prompt_part(part: Any) -> bool:
43
+ """Check if a message part is a user prompt.
44
+
45
+ Args:
46
+ part: A message part object
47
+
48
+ Returns:
49
+ True if part has part_kind == "user-prompt"
50
+ """
51
+ if not hasattr(part, "part_kind"):
52
+ return False
53
+ return part.part_kind == PART_KIND_USER_PROMPT
54
+
55
+
56
+ def count_user_turns(messages: list[Any]) -> int:
57
+ """Count the number of user message turns in history.
58
+
59
+ Counts messages containing UserPromptPart or dict messages with user content.
60
+
61
+ Args:
62
+ messages: Message history list
63
+
64
+ Returns:
65
+ Integer count of user turns
66
+ """
67
+ count = 0
68
+ for message in messages:
69
+ # Check for pydantic-ai message with parts
70
+ if hasattr(message, "parts"):
71
+ for part in message.parts:
72
+ if is_user_prompt_part(part):
73
+ count += 1
74
+ break # Count each message only once
75
+ # Check for dict-style user message
76
+ elif isinstance(message, dict) and "content" in message:
77
+ role = message.get("role", "")
78
+ if role == "user":
79
+ count += 1
80
+ return count
81
+
82
+
83
+ def estimate_part_tokens(part: Any, model_name: str) -> int:
84
+ """Estimate token count for a message part's content.
85
+
86
+ Args:
87
+ part: Message part with content attribute
88
+ model_name: Model for tiktoken encoding selection
89
+
90
+ Returns:
91
+ Estimated token count; 0 if content not extractable
92
+ """
93
+ if not hasattr(part, "content"):
94
+ return 0
95
+
96
+ content = part.content
97
+ if not isinstance(content, str):
98
+ # Non-string content, estimate based on repr
99
+ content = repr(content)
100
+
101
+ return estimate_tokens(content, model_name)
102
+
103
+
104
+ def prune_part_content(part: Any, model_name: str) -> int:
105
+ """Replace a tool return part's content with placeholder.
106
+
107
+ Mutates the part in-place. Returns tokens reclaimed.
108
+
109
+ Args:
110
+ part: Tool return part to prune
111
+ model_name: Model for token estimation
112
+
113
+ Returns:
114
+ Number of tokens reclaimed (original - placeholder); 0 if cannot prune
115
+ """
116
+ if not hasattr(part, "content"):
117
+ return 0
118
+
119
+ content = part.content
120
+
121
+ # Skip already-pruned content
122
+ if content == PRUNE_PLACEHOLDER:
123
+ return 0
124
+
125
+ # Calculate original tokens
126
+ if isinstance(content, str):
127
+ original_tokens = estimate_tokens(content, model_name)
128
+ else:
129
+ original_tokens = estimate_tokens(repr(content), model_name)
130
+
131
+ # Calculate placeholder tokens
132
+ placeholder_tokens = estimate_tokens(PRUNE_PLACEHOLDER, model_name)
133
+
134
+ # Try to replace content
135
+ try:
136
+ part.content = PRUNE_PLACEHOLDER
137
+ except (AttributeError, TypeError):
138
+ # Part is immutable, cannot prune
139
+ return 0
140
+
141
+ return max(0, original_tokens - placeholder_tokens)
142
+
143
+
144
+ def prune_old_tool_outputs(
145
+ messages: list[Any],
146
+ model_name: str,
147
+ ) -> tuple[list[Any], int]:
148
+ """Prune old tool output content from message history.
149
+
150
+ Scans message history backwards, protecting the most recent tool outputs
151
+ up to PRUNE_PROTECT_TOKENS, then replaces older tool output content
152
+ with PRUNE_PLACEHOLDER.
153
+
154
+ Args:
155
+ messages: List of pydantic-ai message objects (ModelRequest, ModelResponse, dict)
156
+ model_name: Model identifier for token estimation (e.g., "anthropic:claude-sonnet")
157
+
158
+ Returns:
159
+ Tuple of:
160
+ - Modified message list (same list, mutated in-place)
161
+ - Number of tokens reclaimed by pruning
162
+ """
163
+ if not messages:
164
+ return (messages, 0)
165
+
166
+ # Early exit: insufficient history
167
+ user_turns = count_user_turns(messages)
168
+ if user_turns < PRUNE_MIN_USER_TURNS:
169
+ return (messages, 0)
170
+
171
+ # Phase 1: Scan backwards, collect tool return parts with token counts
172
+ # Each entry: (message_index, part_index, part, token_count)
173
+ tool_parts: list[tuple[int, int, Any, int]] = []
174
+
175
+ for msg_idx in range(len(messages) - 1, -1, -1):
176
+ message = messages[msg_idx]
177
+ if not hasattr(message, "parts"):
178
+ continue
179
+
180
+ parts = message.parts
181
+ for part_idx in range(len(parts) - 1, -1, -1):
182
+ part = parts[part_idx]
183
+ if is_tool_return_part(part):
184
+ tokens = estimate_part_tokens(part, model_name)
185
+ tool_parts.append((msg_idx, part_idx, part, tokens))
186
+
187
+ if not tool_parts:
188
+ return (messages, 0)
189
+
190
+ # Phase 2: Determine pruning boundary
191
+ accumulated_tokens = 0
192
+ prune_start_index = -1
193
+
194
+ for i, (_, _, _, tokens) in enumerate(tool_parts):
195
+ accumulated_tokens += tokens
196
+ if accumulated_tokens > PRUNE_PROTECT_TOKENS:
197
+ prune_start_index = i
198
+ break
199
+
200
+ # Early exit: nothing old enough to prune
201
+ if prune_start_index < 0:
202
+ return (messages, 0)
203
+
204
+ # Phase 3: Calculate potential savings
205
+ parts_to_prune = tool_parts[prune_start_index:]
206
+ total_prunable_tokens = sum(tokens for _, _, _, tokens in parts_to_prune)
207
+
208
+ # Early exit: savings below threshold
209
+ if total_prunable_tokens < PRUNE_MINIMUM_THRESHOLD:
210
+ return (messages, 0)
211
+
212
+ # Phase 4: Apply pruning
213
+ total_reclaimed = 0
214
+ for _, _, part, _ in parts_to_prune:
215
+ reclaimed = prune_part_content(part, model_name)
216
+ total_reclaimed += reclaimed
217
+
218
+ return (messages, total_reclaimed)
@@ -0,0 +1,27 @@
1
+ """Prompting engine for dynamic placeholder resolution and section composition."""
2
+
3
+ from tunacode.core.prompting.loader import SectionLoader
4
+ from tunacode.core.prompting.prompting_engine import (
5
+ PromptingEngine,
6
+ compose_prompt,
7
+ get_prompting_engine,
8
+ resolve_prompt,
9
+ )
10
+ from tunacode.core.prompting.sections import SystemPromptSection
11
+ from tunacode.core.prompting.templates import (
12
+ MAIN_TEMPLATE,
13
+ RESEARCH_TEMPLATE,
14
+ TEMPLATE_OVERRIDES,
15
+ )
16
+
17
+ __all__ = [
18
+ "PromptingEngine",
19
+ "get_prompting_engine",
20
+ "resolve_prompt",
21
+ "compose_prompt",
22
+ "SystemPromptSection",
23
+ "MAIN_TEMPLATE",
24
+ "RESEARCH_TEMPLATE",
25
+ "TEMPLATE_OVERRIDES",
26
+ "SectionLoader",
27
+ ]
@@ -0,0 +1,66 @@
1
+ """Section loader for prompt templates."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .sections import SystemPromptSection
6
+
7
+
8
+ class SectionLoader:
9
+ """Loads prompt section content from files.
10
+
11
+ Supports .xml, .md, and .txt file extensions.
12
+ Uses mtime-based caching for efficiency.
13
+ """
14
+
15
+ EXTENSIONS = (".xml", ".md", ".txt")
16
+
17
+ def __init__(self, sections_dir: Path) -> None:
18
+ self.sections_dir = sections_dir
19
+ self._cache: dict[str, tuple[str, float]] = {}
20
+
21
+ def load_section(self, section: SystemPromptSection) -> str:
22
+ """Load a section's content from file.
23
+
24
+ Args:
25
+ section: The section to load
26
+
27
+ Returns:
28
+ Section content, or empty string if not found
29
+ """
30
+ filename = section.value.lower()
31
+ for ext in self.EXTENSIONS:
32
+ path = self.sections_dir / f"{filename}{ext}"
33
+ if path.exists():
34
+ return self._read_with_cache(path)
35
+ return ""
36
+
37
+ def load_all(self) -> dict[str, str]:
38
+ """Load all sections into a dict.
39
+
40
+ Returns:
41
+ Dict mapping section name to content
42
+ """
43
+ return {s.value: self.load_section(s) for s in SystemPromptSection}
44
+
45
+ def _read_with_cache(self, path: Path) -> str:
46
+ """Read file with mtime-based cache invalidation.
47
+
48
+ Args:
49
+ path: File path to read
50
+
51
+ Returns:
52
+ File contents
53
+ """
54
+ key = str(path)
55
+ mtime = path.stat().st_mtime
56
+ if key in self._cache:
57
+ cached_content, cached_mtime = self._cache[key]
58
+ if cached_mtime == mtime:
59
+ return cached_content
60
+ content = path.read_text()
61
+ self._cache[key] = (content, mtime)
62
+ return content
63
+
64
+ def clear_cache(self) -> None:
65
+ """Clear the file cache."""
66
+ self._cache.clear()