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,146 @@
|
|
|
1
|
+
"""Retry utilities for handling transient failures."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def retry_on_json_error(
|
|
12
|
+
max_retries: int = 10,
|
|
13
|
+
base_delay: float = 0.1,
|
|
14
|
+
max_delay: float = 5.0,
|
|
15
|
+
) -> Callable:
|
|
16
|
+
"""Decorator to retry function calls that fail with JSON parsing errors.
|
|
17
|
+
|
|
18
|
+
Implements exponential backoff with configurable parameters.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
max_retries: Maximum number of retry attempts (default: 10)
|
|
22
|
+
base_delay: Initial delay between retries in seconds (default: 0.1)
|
|
23
|
+
max_delay: Maximum delay between retries in seconds (default: 5.0)
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Decorated function that retries on JSONDecodeError
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def decorator(func: Callable) -> Callable:
|
|
30
|
+
@functools.wraps(func)
|
|
31
|
+
async def async_wrapper(*args, **kwargs) -> Any:
|
|
32
|
+
last_exception = None
|
|
33
|
+
|
|
34
|
+
for attempt in range(max_retries + 1):
|
|
35
|
+
try:
|
|
36
|
+
return await func(*args, **kwargs)
|
|
37
|
+
except json.JSONDecodeError as e:
|
|
38
|
+
last_exception = e
|
|
39
|
+
|
|
40
|
+
if attempt == max_retries:
|
|
41
|
+
# Final attempt failed
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
# Calculate delay with exponential backoff
|
|
45
|
+
delay = min(base_delay * (2**attempt), max_delay)
|
|
46
|
+
|
|
47
|
+
await asyncio.sleep(delay)
|
|
48
|
+
|
|
49
|
+
# Should never reach here, but just in case
|
|
50
|
+
if last_exception:
|
|
51
|
+
raise last_exception
|
|
52
|
+
|
|
53
|
+
@functools.wraps(func)
|
|
54
|
+
def sync_wrapper(*args, **kwargs) -> Any:
|
|
55
|
+
last_exception = None
|
|
56
|
+
|
|
57
|
+
for attempt in range(max_retries + 1):
|
|
58
|
+
try:
|
|
59
|
+
return func(*args, **kwargs)
|
|
60
|
+
except json.JSONDecodeError as e:
|
|
61
|
+
last_exception = e
|
|
62
|
+
|
|
63
|
+
if attempt == max_retries:
|
|
64
|
+
# Final attempt failed
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
# Calculate delay with exponential backoff
|
|
68
|
+
delay = min(base_delay * (2**attempt), max_delay)
|
|
69
|
+
|
|
70
|
+
time.sleep(delay)
|
|
71
|
+
|
|
72
|
+
# Should never reach here, but just in case
|
|
73
|
+
if last_exception:
|
|
74
|
+
raise last_exception
|
|
75
|
+
|
|
76
|
+
# Return appropriate wrapper based on function type
|
|
77
|
+
if asyncio.iscoroutinefunction(func):
|
|
78
|
+
return async_wrapper
|
|
79
|
+
else:
|
|
80
|
+
return sync_wrapper
|
|
81
|
+
|
|
82
|
+
return decorator
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def retry_json_parse(
|
|
86
|
+
json_string: str,
|
|
87
|
+
max_retries: int = 10,
|
|
88
|
+
base_delay: float = 0.1,
|
|
89
|
+
max_delay: float = 5.0,
|
|
90
|
+
) -> Any:
|
|
91
|
+
"""Parse JSON with automatic retry on failure.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
json_string: JSON string to parse
|
|
95
|
+
max_retries: Maximum number of retry attempts
|
|
96
|
+
base_delay: Initial delay between retries in seconds
|
|
97
|
+
max_delay: Maximum delay between retries in seconds
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Parsed JSON object
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
json.JSONDecodeError: If parsing fails after all retries
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@retry_on_json_error(
|
|
107
|
+
max_retries=max_retries,
|
|
108
|
+
base_delay=base_delay,
|
|
109
|
+
max_delay=max_delay,
|
|
110
|
+
)
|
|
111
|
+
def _parse():
|
|
112
|
+
return json.loads(json_string)
|
|
113
|
+
|
|
114
|
+
return _parse()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def retry_json_parse_async(
|
|
118
|
+
json_string: str,
|
|
119
|
+
max_retries: int = 10,
|
|
120
|
+
base_delay: float = 0.1,
|
|
121
|
+
max_delay: float = 5.0,
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Asynchronously parse JSON with automatic retry on failure.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
json_string: JSON string to parse
|
|
127
|
+
max_retries: Maximum number of retry attempts
|
|
128
|
+
base_delay: Initial delay between retries in seconds
|
|
129
|
+
max_delay: Maximum delay between retries in seconds
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Parsed JSON object
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
json.JSONDecodeError: If parsing fails after all retries
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
@retry_on_json_error(
|
|
139
|
+
max_retries=max_retries,
|
|
140
|
+
base_delay=base_delay,
|
|
141
|
+
max_delay=max_delay,
|
|
142
|
+
)
|
|
143
|
+
async def _parse():
|
|
144
|
+
return json.loads(json_string)
|
|
145
|
+
|
|
146
|
+
return await _parse()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.utils.parsing.tool_parser
|
|
3
|
+
|
|
4
|
+
Multi-strategy fallback parser for extracting tool calls from text responses.
|
|
5
|
+
Handles non-standard tool calling formats from models like Qwen2.5-Coder.
|
|
6
|
+
|
|
7
|
+
Supported formats:
|
|
8
|
+
- Qwen2-style XML: <tool_call>{"name": "...", "arguments": {...}}</tool_call>
|
|
9
|
+
- Hermes-style: <function=name>{...}</function>
|
|
10
|
+
- Code fences: ```json {"name": "...", ...} ```
|
|
11
|
+
- Raw JSON: {"name": "...", "arguments": {...}}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from tunacode.utils.parsing.json_utils import split_concatenated_json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class ParsedToolCall:
|
|
25
|
+
"""Immutable representation of a parsed tool call.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
tool_name: Name of the tool to call
|
|
29
|
+
args: Arguments dictionary for the tool
|
|
30
|
+
tool_call_id: Unique identifier for this tool call
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
tool_name: str
|
|
34
|
+
args: dict[str, Any]
|
|
35
|
+
tool_call_id: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _generate_tool_call_id() -> str:
|
|
39
|
+
"""Generate a unique tool call ID consistent with pydantic_ai format."""
|
|
40
|
+
return str(uuid.uuid4())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
QWEN2_PATTERN = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_qwen2_xml(text: str) -> list[ParsedToolCall] | None:
|
|
47
|
+
"""Parse Qwen2-style XML tool calls.
|
|
48
|
+
|
|
49
|
+
Format: <tool_call>{"name": "read_file", "arguments": {"filepath": "..."}}</tool_call>
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: Raw text potentially containing tool calls
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of ParsedToolCall if matches found, None otherwise
|
|
56
|
+
"""
|
|
57
|
+
matches = QWEN2_PATTERN.findall(text)
|
|
58
|
+
if not matches:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
results: list[ParsedToolCall] = []
|
|
62
|
+
for json_str in matches:
|
|
63
|
+
parsed = _parse_tool_json(json_str)
|
|
64
|
+
if parsed:
|
|
65
|
+
results.append(parsed)
|
|
66
|
+
|
|
67
|
+
return results if results else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
HERMES_PATTERN = re.compile(r"<function=(\w+)>\s*(\{.*?\})\s*</function>", re.DOTALL)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_hermes_style(text: str) -> list[ParsedToolCall] | None:
|
|
74
|
+
"""Parse Hermes-style function call format.
|
|
75
|
+
|
|
76
|
+
Format: <function=read_file>{"filepath": "/path/to/file"}</function>
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
text: Raw text potentially containing tool calls
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of ParsedToolCall if matches found, None otherwise
|
|
83
|
+
"""
|
|
84
|
+
matches = HERMES_PATTERN.findall(text)
|
|
85
|
+
if not matches:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
results: list[ParsedToolCall] = []
|
|
89
|
+
for tool_name, args_json in matches:
|
|
90
|
+
try:
|
|
91
|
+
args = json.loads(args_json)
|
|
92
|
+
if isinstance(args, dict):
|
|
93
|
+
results.append(
|
|
94
|
+
ParsedToolCall(
|
|
95
|
+
tool_name=tool_name,
|
|
96
|
+
args=args,
|
|
97
|
+
tool_call_id=_generate_tool_call_id(),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
return results if results else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
CODE_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_code_fence(text: str) -> list[ParsedToolCall] | None:
|
|
110
|
+
"""Parse JSON tool calls inside code fences.
|
|
111
|
+
|
|
112
|
+
Format: ```json {"name": "read_file", "arguments": {...}} ```
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
text: Raw text potentially containing tool calls
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of ParsedToolCall if matches found, None otherwise
|
|
119
|
+
"""
|
|
120
|
+
matches = CODE_FENCE_PATTERN.findall(text)
|
|
121
|
+
if not matches:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
results: list[ParsedToolCall] = []
|
|
125
|
+
for json_str in matches:
|
|
126
|
+
parsed = _parse_tool_json(json_str)
|
|
127
|
+
if parsed:
|
|
128
|
+
results.append(parsed)
|
|
129
|
+
|
|
130
|
+
return results if results else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_raw_json(text: str) -> list[ParsedToolCall] | None:
|
|
134
|
+
"""Parse raw JSON tool calls embedded in text.
|
|
135
|
+
|
|
136
|
+
Formats supported:
|
|
137
|
+
- {"name": "tool_name", "arguments": {...}}
|
|
138
|
+
- {"tool": "tool_name", "args": {...}}
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
text: Raw text potentially containing tool calls
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List of ParsedToolCall if matches found, None otherwise
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
objects = split_concatenated_json(text)
|
|
148
|
+
except (json.JSONDecodeError, ValueError):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
results: list[ParsedToolCall] = []
|
|
152
|
+
for obj in objects:
|
|
153
|
+
parsed = _normalize_tool_object(obj)
|
|
154
|
+
if parsed:
|
|
155
|
+
results.append(parsed)
|
|
156
|
+
|
|
157
|
+
return results if results else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_tool_json(json_str: str) -> ParsedToolCall | None:
|
|
161
|
+
"""Parse a JSON string into a ParsedToolCall.
|
|
162
|
+
|
|
163
|
+
Handles both {"name": ..., "arguments": ...} and {"tool": ..., "args": ...} formats.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
obj = json.loads(json_str)
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return _normalize_tool_object(obj)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _normalize_tool_object(obj: Any) -> ParsedToolCall | None:
|
|
174
|
+
"""Normalize various tool call object formats into ParsedToolCall.
|
|
175
|
+
|
|
176
|
+
Supported formats:
|
|
177
|
+
- {"name": "...", "arguments": {...}}
|
|
178
|
+
- {"tool": "...", "args": {...}}
|
|
179
|
+
- {"function": "...", "parameters": {...}}
|
|
180
|
+
"""
|
|
181
|
+
if not isinstance(obj, dict):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Extract tool name
|
|
185
|
+
tool_name = obj.get("name") or obj.get("tool") or obj.get("function")
|
|
186
|
+
if not tool_name or not isinstance(tool_name, str):
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
# Extract arguments
|
|
190
|
+
args = obj.get("arguments") or obj.get("args") or obj.get("parameters") or {}
|
|
191
|
+
if not isinstance(args, dict):
|
|
192
|
+
# Try to parse if string
|
|
193
|
+
if isinstance(args, str):
|
|
194
|
+
try:
|
|
195
|
+
args = json.loads(args)
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
args = {}
|
|
198
|
+
else:
|
|
199
|
+
args = {}
|
|
200
|
+
|
|
201
|
+
return ParsedToolCall(
|
|
202
|
+
tool_name=tool_name,
|
|
203
|
+
args=args,
|
|
204
|
+
tool_call_id=_generate_tool_call_id(),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
PARSING_STRATEGIES: list[tuple[str, callable]] = [
|
|
209
|
+
("qwen2_xml", parse_qwen2_xml),
|
|
210
|
+
("hermes_style", parse_hermes_style),
|
|
211
|
+
("code_fence", parse_code_fence),
|
|
212
|
+
("raw_json", parse_raw_json),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_tool_calls_from_text(text: str) -> list[ParsedToolCall]:
|
|
217
|
+
"""Parse tool calls from text using multi-strategy fallback.
|
|
218
|
+
|
|
219
|
+
Tries each parsing strategy in order until one succeeds.
|
|
220
|
+
Strategies are ordered from most specific to most general.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
text: Raw text potentially containing embedded tool calls
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of ParsedToolCall objects. Empty list if no tool calls found.
|
|
227
|
+
|
|
228
|
+
Note:
|
|
229
|
+
Does NOT raise on failure - returns empty list per fail-fast-but-graceful design.
|
|
230
|
+
The caller should decide how to handle empty results.
|
|
231
|
+
"""
|
|
232
|
+
if not text or not text.strip():
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
for _strategy_name, strategy_func in PARSING_STRATEGIES:
|
|
236
|
+
result = strategy_func(text)
|
|
237
|
+
if result:
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def has_potential_tool_call(text: str) -> bool:
|
|
244
|
+
"""Quick check if text might contain a tool call.
|
|
245
|
+
|
|
246
|
+
This is a fast pre-filter before expensive parsing.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
text: Text to check
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if text appears to contain tool call patterns
|
|
253
|
+
"""
|
|
254
|
+
if not text:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Quick pattern indicators
|
|
258
|
+
indicators = [
|
|
259
|
+
"<tool_call>",
|
|
260
|
+
"<function=",
|
|
261
|
+
'"name":',
|
|
262
|
+
'"tool":',
|
|
263
|
+
"```json",
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
text_lower = text.lower()
|
|
267
|
+
return any(ind.lower() in text_lower for ind in indicators)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Security utilities: command validation and safe execution."""
|
|
2
|
+
|
|
3
|
+
from tunacode.utils.security.command import (
|
|
4
|
+
CommandSecurityError,
|
|
5
|
+
safe_subprocess_popen,
|
|
6
|
+
sanitize_command_args,
|
|
7
|
+
validate_command_safety,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CommandSecurityError",
|
|
12
|
+
"safe_subprocess_popen",
|
|
13
|
+
"sanitize_command_args",
|
|
14
|
+
"validate_command_safety",
|
|
15
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security utilities for safe command execution and input validation.
|
|
3
|
+
Provides defensive measures against command injection attacks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CommandSecurityError(Exception):
|
|
12
|
+
"""Raised when a command fails security validation."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_command_safety(command: str, allow_shell_features: bool = False) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Validate that a command is safe to execute.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
command: The command string to validate
|
|
23
|
+
allow_shell_features: If True, allows some shell features like pipes
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
CommandSecurityError: If the command contains potentially dangerous patterns
|
|
27
|
+
"""
|
|
28
|
+
if not command or not command.strip():
|
|
29
|
+
raise CommandSecurityError("Empty command not allowed")
|
|
30
|
+
|
|
31
|
+
# Always check for the most dangerous patterns regardless of shell features
|
|
32
|
+
dangerous_patterns = [
|
|
33
|
+
r"rm\s+-rf\s+/", # Dangerous rm commands
|
|
34
|
+
r"sudo\s+rm", # Sudo rm commands
|
|
35
|
+
r">\s*/dev/sd[a-z]", # Writing to disk devices
|
|
36
|
+
r"dd\s+.*of=/dev/", # DD to devices
|
|
37
|
+
r"mkfs\.", # Format filesystem
|
|
38
|
+
r"fdisk", # Partition manipulation
|
|
39
|
+
r":\(\)\{.*\}\;", # Fork bomb pattern
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
for pattern in dangerous_patterns:
|
|
43
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
44
|
+
raise CommandSecurityError("Command contains dangerous pattern and is blocked")
|
|
45
|
+
|
|
46
|
+
if not allow_shell_features:
|
|
47
|
+
# Check for dangerous characters (but allow some for CLI tools)
|
|
48
|
+
restricted_chars = [";", "&", "`", "$", "{", "}"] # More permissive for CLI
|
|
49
|
+
for char in restricted_chars:
|
|
50
|
+
if char in command:
|
|
51
|
+
raise CommandSecurityError(f"Potentially unsafe character '{char}' in command")
|
|
52
|
+
|
|
53
|
+
# Check for injection patterns (more selective)
|
|
54
|
+
strict_patterns = [
|
|
55
|
+
r";\s*rm\s+", # Command chaining to rm
|
|
56
|
+
r"&&\s*rm\s+", # Command chaining to rm
|
|
57
|
+
r"`[^`]*rm[^`]*`", # Command substitution with rm
|
|
58
|
+
r"\$\([^)]*rm[^)]*\)", # Command substitution with rm
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
for pattern in strict_patterns:
|
|
62
|
+
if re.search(pattern, command):
|
|
63
|
+
raise CommandSecurityError("Potentially unsafe pattern detected in command")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sanitize_command_args(args: list[str]) -> list[str]:
|
|
67
|
+
"""
|
|
68
|
+
Sanitize command arguments by shell-quoting them.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
args: List of command arguments
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of sanitized arguments
|
|
75
|
+
"""
|
|
76
|
+
return [shlex.quote(arg) for arg in args]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def safe_subprocess_popen(
|
|
80
|
+
command: str, shell: bool = False, validate: bool = True, **kwargs
|
|
81
|
+
) -> subprocess.Popen:
|
|
82
|
+
"""
|
|
83
|
+
Safely create a subprocess.Popen with security validation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
command: Command to execute
|
|
87
|
+
shell: Whether to use shell execution (discouraged)
|
|
88
|
+
validate: Whether to validate command safety
|
|
89
|
+
**kwargs: Additional Popen arguments
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Popen process object
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
CommandSecurityError: If command fails security validation
|
|
96
|
+
"""
|
|
97
|
+
if validate and shell and isinstance(command, str):
|
|
98
|
+
validate_command_safety(command, allow_shell_features=shell)
|
|
99
|
+
|
|
100
|
+
if shell:
|
|
101
|
+
# When using shell=True, command should be a string
|
|
102
|
+
return subprocess.Popen(command, shell=True, **kwargs)
|
|
103
|
+
else:
|
|
104
|
+
# When shell=False, command should be a list
|
|
105
|
+
command_list = shlex.split(command) if isinstance(command, str) else command
|
|
106
|
+
return subprocess.Popen(command_list, shell=False, **kwargs)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""System utilities: paths, sessions, and file listing."""
|
|
2
|
+
|
|
3
|
+
from tunacode.utils.system.gitignore import (
|
|
4
|
+
DEFAULT_IGNORE_PATTERNS,
|
|
5
|
+
list_cwd,
|
|
6
|
+
)
|
|
7
|
+
from tunacode.utils.system.paths import (
|
|
8
|
+
check_for_updates,
|
|
9
|
+
cleanup_session,
|
|
10
|
+
get_cwd,
|
|
11
|
+
get_device_id,
|
|
12
|
+
get_session_dir,
|
|
13
|
+
get_tunacode_home,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_IGNORE_PATTERNS",
|
|
18
|
+
"list_cwd",
|
|
19
|
+
"check_for_updates",
|
|
20
|
+
"cleanup_session",
|
|
21
|
+
"get_cwd",
|
|
22
|
+
"get_device_id",
|
|
23
|
+
"get_session_dir",
|
|
24
|
+
"get_tunacode_home",
|
|
25
|
+
]
|