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.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- 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()
|