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
tunacode/tools/react.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Lightweight ReAct-style scratchpad tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
9
|
+
|
|
10
|
+
from tunacode.core.state import StateManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_react_tool(state_manager: StateManager) -> Callable:
|
|
14
|
+
"""Factory to create a react tool bound to a state manager.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
state_manager: The state manager instance to use.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
An async function that implements the react tool.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def react(
|
|
24
|
+
action: Literal["think", "observe", "get", "clear"],
|
|
25
|
+
thoughts: str | None = None,
|
|
26
|
+
next_action: str | None = None,
|
|
27
|
+
result: str | None = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""ReAct scratchpad for tracking think/observe steps.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
action: The action to perform (think/observe/get/clear).
|
|
33
|
+
thoughts: Thought content for think action.
|
|
34
|
+
next_action: Planned next action for think action.
|
|
35
|
+
result: Observation message for observe action.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Status message or scratchpad contents.
|
|
39
|
+
"""
|
|
40
|
+
scratchpad = state_manager.get_react_scratchpad()
|
|
41
|
+
scratchpad.setdefault("timeline", [])
|
|
42
|
+
|
|
43
|
+
if action == "think":
|
|
44
|
+
if not thoughts:
|
|
45
|
+
raise ModelRetry("Provide thoughts when using react think action")
|
|
46
|
+
if not next_action:
|
|
47
|
+
raise ModelRetry("Specify next_action when recording react thoughts")
|
|
48
|
+
|
|
49
|
+
entry = {"type": "think", "thoughts": thoughts, "next_action": next_action}
|
|
50
|
+
state_manager.append_react_entry(entry)
|
|
51
|
+
return "Recorded think step"
|
|
52
|
+
|
|
53
|
+
if action == "observe":
|
|
54
|
+
if not result:
|
|
55
|
+
raise ModelRetry("Provide result when using react observe action")
|
|
56
|
+
|
|
57
|
+
entry = {"type": "observe", "result": result}
|
|
58
|
+
state_manager.append_react_entry(entry)
|
|
59
|
+
return "Recorded observation"
|
|
60
|
+
|
|
61
|
+
if action == "get":
|
|
62
|
+
timeline = scratchpad.get("timeline", [])
|
|
63
|
+
if not timeline:
|
|
64
|
+
return "React scratchpad is empty"
|
|
65
|
+
|
|
66
|
+
formatted = [
|
|
67
|
+
f"{i + 1}. {item['type']}: {_format_entry(item)}" for i, item in enumerate(timeline)
|
|
68
|
+
]
|
|
69
|
+
return "\n".join(formatted)
|
|
70
|
+
|
|
71
|
+
if action == "clear":
|
|
72
|
+
state_manager.clear_react_scratchpad()
|
|
73
|
+
return "React scratchpad cleared"
|
|
74
|
+
|
|
75
|
+
raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
|
|
76
|
+
|
|
77
|
+
return react
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _format_entry(item: dict[str, Any]) -> str:
|
|
81
|
+
"""Format a scratchpad entry for display."""
|
|
82
|
+
if item["type"] == "think":
|
|
83
|
+
return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
|
|
84
|
+
if item["type"] == "observe":
|
|
85
|
+
return f"result='{item['result']}'"
|
|
86
|
+
return str(item)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Backwards compatibility: ReactTool class wrapper
|
|
90
|
+
class ReactTool:
|
|
91
|
+
"""Wrapper class for backwards compatibility with existing code."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, state_manager: StateManager) -> None:
|
|
94
|
+
self.state_manager = state_manager
|
|
95
|
+
self._react = create_react_tool(state_manager)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def tool_name(self) -> str:
|
|
99
|
+
return "react"
|
|
100
|
+
|
|
101
|
+
async def execute(
|
|
102
|
+
self,
|
|
103
|
+
action: Literal["think", "observe", "get", "clear"],
|
|
104
|
+
thoughts: str | None = None,
|
|
105
|
+
next_action: str | None = None,
|
|
106
|
+
result: str | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Execute the react tool."""
|
|
109
|
+
return await self._react(
|
|
110
|
+
action=action, thoughts=thoughts, next_action=next_action, result=result
|
|
111
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""File reading tool for agent operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from tunacode.constants import (
|
|
7
|
+
DEFAULT_READ_LIMIT,
|
|
8
|
+
ERROR_FILE_TOO_LARGE,
|
|
9
|
+
MAX_FILE_SIZE,
|
|
10
|
+
MAX_LINE_LENGTH,
|
|
11
|
+
MSG_FILE_SIZE_LIMIT,
|
|
12
|
+
)
|
|
13
|
+
from tunacode.exceptions import ToolExecutionError
|
|
14
|
+
from tunacode.tools.decorators import file_tool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@file_tool
|
|
18
|
+
async def read_file(
|
|
19
|
+
filepath: str,
|
|
20
|
+
offset: int = 0,
|
|
21
|
+
limit: int | None = None,
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Read the contents of a file with line limiting and truncation.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
filepath: The absolute path to the file to read.
|
|
27
|
+
offset: The line number to start reading from (0-based). Defaults to 0.
|
|
28
|
+
limit: The number of lines to read. Defaults to DEFAULT_READ_LIMIT (2000).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The formatted file contents with line numbers.
|
|
32
|
+
"""
|
|
33
|
+
if os.path.getsize(filepath) > MAX_FILE_SIZE:
|
|
34
|
+
raise ToolExecutionError(
|
|
35
|
+
tool_name="read_file",
|
|
36
|
+
message=ERROR_FILE_TOO_LARGE.format(filepath=filepath) + MSG_FILE_SIZE_LIMIT,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
effective_limit = limit if limit is not None else DEFAULT_READ_LIMIT
|
|
40
|
+
|
|
41
|
+
def _read_sync(path: str) -> str:
|
|
42
|
+
with open(path, encoding="utf-8") as f:
|
|
43
|
+
lines = f.readlines()
|
|
44
|
+
|
|
45
|
+
total_lines = len(lines)
|
|
46
|
+
raw = lines[offset : offset + effective_limit]
|
|
47
|
+
|
|
48
|
+
content_lines = []
|
|
49
|
+
for i, line in enumerate(raw):
|
|
50
|
+
line = line.rstrip("\n")
|
|
51
|
+
if len(line) > MAX_LINE_LENGTH:
|
|
52
|
+
line = line[:MAX_LINE_LENGTH] + "..."
|
|
53
|
+
line_num = str(i + offset + 1).zfill(5)
|
|
54
|
+
content_lines.append(f"{line_num}| {line}")
|
|
55
|
+
|
|
56
|
+
output = "<file>\n"
|
|
57
|
+
output += "\n".join(content_lines)
|
|
58
|
+
|
|
59
|
+
last_line = offset + len(content_lines)
|
|
60
|
+
if total_lines > last_line:
|
|
61
|
+
output += f"\n\n(File has more lines. Use 'offset' to read beyond line {last_line})"
|
|
62
|
+
else:
|
|
63
|
+
output += f"\n\n(End of file - total {total_lines} lines)"
|
|
64
|
+
output += "\n</file>"
|
|
65
|
+
|
|
66
|
+
return output
|
|
67
|
+
|
|
68
|
+
return await asyncio.to_thread(_read_sync, filepath)
|
tunacode/tools/todo.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Todo list tools for tracking task progress during complex operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
9
|
+
|
|
10
|
+
from tunacode.core.state import StateManager
|
|
11
|
+
from tunacode.tools.xml_helper import load_prompt_from_xml
|
|
12
|
+
|
|
13
|
+
# Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
|
|
14
|
+
# and adapted for python.
|
|
15
|
+
|
|
16
|
+
TODO_FIELD_ACTIVE_FORM = "activeForm"
|
|
17
|
+
TODO_FIELD_CONTENT = "content"
|
|
18
|
+
TODO_FIELD_STATUS = "status"
|
|
19
|
+
|
|
20
|
+
TODO_STATUS_COMPLETED = "completed"
|
|
21
|
+
TODO_STATUS_IN_PROGRESS = "in_progress"
|
|
22
|
+
TODO_STATUS_PENDING = "pending"
|
|
23
|
+
|
|
24
|
+
MAX_IN_PROGRESS_TODOS = 1
|
|
25
|
+
NO_TODOS_MESSAGE = "No todos in list."
|
|
26
|
+
TODO_LIST_CLEARED_MESSAGE = "Todo list cleared."
|
|
27
|
+
|
|
28
|
+
# Valid status values
|
|
29
|
+
VALID_STATUSES = frozenset({TODO_STATUS_PENDING, TODO_STATUS_IN_PROGRESS, TODO_STATUS_COMPLETED})
|
|
30
|
+
|
|
31
|
+
# Status display symbols
|
|
32
|
+
STATUS_SYMBOLS = {
|
|
33
|
+
TODO_STATUS_PENDING: "[ ]",
|
|
34
|
+
TODO_STATUS_IN_PROGRESS: "[>]",
|
|
35
|
+
TODO_STATUS_COMPLETED: "[x]",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_todo(todo: Any, index: int) -> dict[str, Any]:
|
|
40
|
+
"""Validate a single todo item has required fields.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
todo: The todo dictionary to validate.
|
|
44
|
+
index: The index of the todo in the list (for error messages).
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ModelRetry: If validation fails.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(todo, dict):
|
|
50
|
+
raise ModelRetry(f"Todo at index {index} must be a dictionary, got {type(todo).__name__}")
|
|
51
|
+
|
|
52
|
+
required_fields = (TODO_FIELD_CONTENT, TODO_FIELD_STATUS, TODO_FIELD_ACTIVE_FORM)
|
|
53
|
+
missing = [f for f in required_fields if f not in todo]
|
|
54
|
+
if missing:
|
|
55
|
+
raise ModelRetry(f"Todo at index {index} missing required fields: {', '.join(missing)}")
|
|
56
|
+
|
|
57
|
+
status = todo[TODO_FIELD_STATUS]
|
|
58
|
+
if not isinstance(status, str) or not status:
|
|
59
|
+
raise ModelRetry(f"Todo at index {index} must have non-empty string '{TODO_FIELD_STATUS}'")
|
|
60
|
+
if status not in VALID_STATUSES:
|
|
61
|
+
raise ModelRetry(
|
|
62
|
+
f"Todo at index {index} has invalid status '{status}'. "
|
|
63
|
+
f"Must be one of: {', '.join(sorted(VALID_STATUSES))}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
content = todo[TODO_FIELD_CONTENT]
|
|
67
|
+
if not isinstance(content, str) or not content:
|
|
68
|
+
raise ModelRetry(f"Todo at index {index} must have non-empty string '{TODO_FIELD_CONTENT}'")
|
|
69
|
+
|
|
70
|
+
active_form = todo[TODO_FIELD_ACTIVE_FORM]
|
|
71
|
+
if not isinstance(active_form, str) or not active_form:
|
|
72
|
+
raise ModelRetry(
|
|
73
|
+
f"Todo at index {index} must have non-empty string '{TODO_FIELD_ACTIVE_FORM}'"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return todo
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_todos(todos: Any) -> list[dict[str, Any]]:
|
|
80
|
+
if not isinstance(todos, list):
|
|
81
|
+
raise ModelRetry(f"todos must be a list, got {type(todos).__name__}")
|
|
82
|
+
|
|
83
|
+
validated = [_validate_todo(todo, index) for index, todo in enumerate(todos)]
|
|
84
|
+
|
|
85
|
+
in_progress_count = sum(
|
|
86
|
+
1 for todo in validated if todo[TODO_FIELD_STATUS] == TODO_STATUS_IN_PROGRESS
|
|
87
|
+
)
|
|
88
|
+
if in_progress_count > MAX_IN_PROGRESS_TODOS:
|
|
89
|
+
raise ModelRetry(
|
|
90
|
+
f"Only {MAX_IN_PROGRESS_TODOS} todo may be '{TODO_STATUS_IN_PROGRESS}' at a time, "
|
|
91
|
+
f"got {in_progress_count}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return validated
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _format_todos(todos: list[dict[str, Any]]) -> str:
|
|
98
|
+
"""Format todos for display output.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
todos: List of todo dictionaries.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Formatted string representation of the todo list.
|
|
105
|
+
"""
|
|
106
|
+
if not todos:
|
|
107
|
+
return NO_TODOS_MESSAGE
|
|
108
|
+
|
|
109
|
+
lines = []
|
|
110
|
+
for i, todo in enumerate(todos, 1):
|
|
111
|
+
status = todo[TODO_FIELD_STATUS]
|
|
112
|
+
symbol = STATUS_SYMBOLS[status]
|
|
113
|
+
content = todo[TODO_FIELD_CONTENT]
|
|
114
|
+
active_form = todo[TODO_FIELD_ACTIVE_FORM]
|
|
115
|
+
|
|
116
|
+
if status == TODO_STATUS_IN_PROGRESS:
|
|
117
|
+
lines.append(f"{i}. {symbol} {content} ({active_form})")
|
|
118
|
+
else:
|
|
119
|
+
lines.append(f"{i}. {symbol} {content}")
|
|
120
|
+
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def create_todowrite_tool(state_manager: StateManager) -> Callable:
|
|
125
|
+
# Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
|
|
126
|
+
# and adapted for python.
|
|
127
|
+
"""Factory to create a todowrite tool bound to a state manager.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
state_manager: The state manager instance to use.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
An async function that implements the todowrite tool.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
async def todowrite(todos: list[dict[str, Any]]) -> str:
|
|
137
|
+
"""Create or update the todo list for tracking task progress.
|
|
138
|
+
|
|
139
|
+
Use this tool to manage and display tasks during complex multi-step operations.
|
|
140
|
+
The entire todo list is replaced with each call.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
todos: List of todo items. Each item must have:
|
|
144
|
+
- content: Task description in imperative form (e.g., "Fix the bug")
|
|
145
|
+
- status: One of "pending", "in_progress", or "completed"
|
|
146
|
+
- activeForm: Present continuous form for display (e.g., "Fixing the bug")
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Formatted display of the updated todo list.
|
|
150
|
+
"""
|
|
151
|
+
validated = _validate_todos(todos)
|
|
152
|
+
|
|
153
|
+
state_manager.set_todos(validated)
|
|
154
|
+
return _format_todos(validated)
|
|
155
|
+
|
|
156
|
+
# Load prompt from XML if available
|
|
157
|
+
prompt = load_prompt_from_xml("todowrite")
|
|
158
|
+
if prompt:
|
|
159
|
+
todowrite.__doc__ = prompt
|
|
160
|
+
|
|
161
|
+
return todowrite
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_todoread_tool(state_manager: StateManager) -> Callable:
|
|
165
|
+
# Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
|
|
166
|
+
# and adapted for python.
|
|
167
|
+
"""Factory to create a todoread tool bound to a state manager.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
state_manager: The state manager instance to use.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
An async function that implements the todoread tool.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
async def todoread() -> str:
|
|
177
|
+
"""Read the current todo list.
|
|
178
|
+
|
|
179
|
+
Use this tool to check the current state of all tasks.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Formatted display of the current todo list, or a message if empty.
|
|
183
|
+
"""
|
|
184
|
+
todos = state_manager.get_todos()
|
|
185
|
+
validated = _validate_todos(todos)
|
|
186
|
+
return _format_todos(validated)
|
|
187
|
+
|
|
188
|
+
# Load prompt from XML if available
|
|
189
|
+
prompt = load_prompt_from_xml("todoread")
|
|
190
|
+
if prompt:
|
|
191
|
+
todoread.__doc__ = prompt
|
|
192
|
+
|
|
193
|
+
return todoread
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def create_todoclear_tool(state_manager: StateManager) -> Callable:
|
|
197
|
+
"""Factory to create a todoclear tool bound to a state manager.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
state_manager: The state manager instance to use.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
An async function that implements the todoclear tool.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
async def todoclear() -> str:
|
|
207
|
+
"""Clear the entire todo list.
|
|
208
|
+
|
|
209
|
+
Use this tool when starting fresh or when all tasks are complete.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Confirmation message.
|
|
213
|
+
"""
|
|
214
|
+
state_manager.clear_todos()
|
|
215
|
+
return TODO_LIST_CLEARED_MESSAGE
|
|
216
|
+
|
|
217
|
+
# Load prompt from XML if available
|
|
218
|
+
prompt = load_prompt_from_xml("todoclear")
|
|
219
|
+
if prompt:
|
|
220
|
+
todoclear.__doc__ = prompt
|
|
221
|
+
|
|
222
|
+
return todoclear
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""File update tool for agent operations."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
7
|
+
|
|
8
|
+
from tunacode.tools.decorators import file_tool
|
|
9
|
+
from tunacode.tools.utils.text_match import replace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@file_tool(writes=True)
|
|
13
|
+
async def update_file(filepath: str, old_text: str, new_text: str) -> str:
|
|
14
|
+
"""Update an existing file by replacing old_text with new_text.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
filepath: The path to the file to update.
|
|
18
|
+
old_text: The entire, exact block of text to be replaced.
|
|
19
|
+
new_text: The new block of text to insert.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A message indicating success and the diff of changes.
|
|
23
|
+
"""
|
|
24
|
+
if not os.path.exists(filepath):
|
|
25
|
+
raise ModelRetry(
|
|
26
|
+
f"File '{filepath}' not found. Cannot update. "
|
|
27
|
+
"Verify the filepath or use `write_file` if it's a new file."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
with open(filepath, encoding="utf-8") as f:
|
|
31
|
+
original = f.read()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
new_content = replace(original, old_text, new_text, replace_all=False)
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
lines = original.splitlines()
|
|
37
|
+
preview_lines = min(20, len(lines))
|
|
38
|
+
snippet = "\n".join(lines[:preview_lines])
|
|
39
|
+
raise ModelRetry(
|
|
40
|
+
f"{e}\n\nFile '{filepath}' preview ({preview_lines} lines):\n---\n{snippet}\n---"
|
|
41
|
+
) from e
|
|
42
|
+
|
|
43
|
+
if original == new_content:
|
|
44
|
+
raise ModelRetry(
|
|
45
|
+
f"Update old_text found, but replacement resulted in no changes to '{filepath}'. "
|
|
46
|
+
"Was the `old_text` identical to the `new_text`? Please check the file content."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(new_content)
|
|
51
|
+
|
|
52
|
+
diff_lines = list(
|
|
53
|
+
difflib.unified_diff(
|
|
54
|
+
original.splitlines(keepends=True),
|
|
55
|
+
new_content.splitlines(keepends=True),
|
|
56
|
+
fromfile=f"a/{filepath}",
|
|
57
|
+
tofile=f"b/{filepath}",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
diff_text = "".join(diff_lines)
|
|
61
|
+
|
|
62
|
+
return f"File '{filepath}' updated successfully.\n\n{diff_text}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for tools."""
|