tunacode-cli 0.0.35__py3-none-any.whl → 0.0.37__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/cli/commands/__init__.py +62 -0
- tunacode/cli/commands/base.py +99 -0
- tunacode/cli/commands/implementations/__init__.py +38 -0
- tunacode/cli/commands/implementations/conversation.py +115 -0
- tunacode/cli/commands/implementations/debug.py +189 -0
- tunacode/cli/commands/implementations/development.py +77 -0
- tunacode/cli/commands/implementations/model.py +61 -0
- tunacode/cli/commands/implementations/system.py +216 -0
- tunacode/cli/commands/registry.py +236 -0
- tunacode/cli/repl.py +91 -30
- tunacode/configuration/settings.py +9 -2
- tunacode/constants.py +1 -1
- tunacode/core/agents/main.py +53 -3
- tunacode/core/agents/utils.py +304 -0
- tunacode/core/setup/config_setup.py +0 -1
- tunacode/core/state.py +13 -2
- tunacode/setup.py +7 -2
- tunacode/tools/read_file.py +8 -2
- tunacode/tools/read_file_async_poc.py +18 -10
- tunacode/tools/run_command.py +11 -4
- tunacode/ui/console.py +31 -4
- tunacode/ui/output.py +7 -2
- tunacode/ui/panels.py +98 -5
- tunacode/ui/utils.py +3 -0
- tunacode/utils/text_utils.py +6 -2
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/METADATA +17 -17
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/RECORD +31 -21
- tunacode/cli/commands.py +0 -893
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.35.dist-info → tunacode_cli-0.0.37.dist-info}/top_level.txt +0 -0
tunacode/core/agents/main.py
CHANGED
|
@@ -12,6 +12,22 @@ from datetime import datetime, timezone
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Iterator, List, Optional, Tuple
|
|
14
14
|
|
|
15
|
+
from pydantic_ai import Agent
|
|
16
|
+
|
|
17
|
+
# Import streaming types with fallback for older versions
|
|
18
|
+
try:
|
|
19
|
+
from pydantic_ai.messages import (
|
|
20
|
+
PartDeltaEvent,
|
|
21
|
+
TextPartDelta,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
STREAMING_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
# Fallback for older pydantic-ai versions
|
|
27
|
+
PartDeltaEvent = None
|
|
28
|
+
TextPartDelta = None
|
|
29
|
+
STREAMING_AVAILABLE = False
|
|
30
|
+
|
|
15
31
|
from tunacode.constants import READ_ONLY_TOOLS
|
|
16
32
|
from tunacode.core.state import StateManager
|
|
17
33
|
from tunacode.services.mcp import get_mcp_servers
|
|
@@ -23,8 +39,18 @@ from tunacode.tools.read_file import read_file
|
|
|
23
39
|
from tunacode.tools.run_command import run_command
|
|
24
40
|
from tunacode.tools.update_file import update_file
|
|
25
41
|
from tunacode.tools.write_file import write_file
|
|
26
|
-
from tunacode.types import (
|
|
27
|
-
|
|
42
|
+
from tunacode.types import (
|
|
43
|
+
AgentRun,
|
|
44
|
+
ErrorMessage,
|
|
45
|
+
FallbackResponse,
|
|
46
|
+
ModelName,
|
|
47
|
+
PydanticAgent,
|
|
48
|
+
ResponseState,
|
|
49
|
+
SimpleResult,
|
|
50
|
+
ToolCallback,
|
|
51
|
+
ToolCallId,
|
|
52
|
+
ToolName,
|
|
53
|
+
)
|
|
28
54
|
|
|
29
55
|
|
|
30
56
|
class ToolBuffer:
|
|
@@ -187,6 +213,7 @@ async def _process_node(
|
|
|
187
213
|
tool_callback: Optional[ToolCallback],
|
|
188
214
|
state_manager: StateManager,
|
|
189
215
|
tool_buffer: Optional[ToolBuffer] = None,
|
|
216
|
+
streaming_callback: Optional[callable] = None,
|
|
190
217
|
):
|
|
191
218
|
from tunacode.ui import console as ui
|
|
192
219
|
from tunacode.utils.token_counter import estimate_tokens
|
|
@@ -206,6 +233,16 @@ async def _process_node(
|
|
|
206
233
|
if hasattr(node, "model_response"):
|
|
207
234
|
state_manager.session.messages.append(node.model_response)
|
|
208
235
|
|
|
236
|
+
# Stream content to callback if provided
|
|
237
|
+
# Use this as fallback when true token streaming is not available
|
|
238
|
+
if streaming_callback and not STREAMING_AVAILABLE:
|
|
239
|
+
for part in node.model_response.parts:
|
|
240
|
+
if hasattr(part, "content") and isinstance(part.content, str):
|
|
241
|
+
content = part.content.strip()
|
|
242
|
+
if content and not content.startswith('{"thought"'):
|
|
243
|
+
# Stream non-JSON content (actual response content)
|
|
244
|
+
await streaming_callback(content)
|
|
245
|
+
|
|
209
246
|
# Enhanced display when thoughts are enabled
|
|
210
247
|
if state_manager.session.show_thoughts:
|
|
211
248
|
# Show raw API response data
|
|
@@ -673,6 +710,7 @@ async def process_request(
|
|
|
673
710
|
message: str,
|
|
674
711
|
state_manager: StateManager,
|
|
675
712
|
tool_callback: Optional[ToolCallback] = None,
|
|
713
|
+
streaming_callback: Optional[callable] = None,
|
|
676
714
|
) -> AgentRun:
|
|
677
715
|
agent = get_or_create_agent(model, state_manager)
|
|
678
716
|
mh = state_manager.session.messages.copy()
|
|
@@ -713,7 +751,19 @@ async def process_request(
|
|
|
713
751
|
i = 0
|
|
714
752
|
async for node in agent_run:
|
|
715
753
|
state_manager.session.current_iteration = i + 1
|
|
716
|
-
|
|
754
|
+
|
|
755
|
+
# Handle token-level streaming for model request nodes
|
|
756
|
+
if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
|
|
757
|
+
async with node.stream(agent_run.ctx) as request_stream:
|
|
758
|
+
async for event in request_stream:
|
|
759
|
+
if isinstance(event, PartDeltaEvent) and isinstance(
|
|
760
|
+
event.delta, TextPartDelta
|
|
761
|
+
):
|
|
762
|
+
# Stream individual token deltas
|
|
763
|
+
if event.delta.content_delta:
|
|
764
|
+
await streaming_callback(event.delta.content_delta)
|
|
765
|
+
|
|
766
|
+
await _process_node(node, tool_callback, state_manager, tool_buffer, streaming_callback)
|
|
717
767
|
if hasattr(node, "result") and node.result and hasattr(node.result, "output"):
|
|
718
768
|
if node.result.output:
|
|
719
769
|
response_state.has_user_response = True
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tunacode.constants import READ_ONLY_TOOLS
|
|
11
|
+
from tunacode.types import (
|
|
12
|
+
ErrorMessage,
|
|
13
|
+
StateManager,
|
|
14
|
+
ToolCallback,
|
|
15
|
+
ToolCallId,
|
|
16
|
+
ToolName,
|
|
17
|
+
)
|
|
18
|
+
from tunacode.ui import console as ui
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Lazy import for Agent and Tool
|
|
22
|
+
def get_agent_tool():
|
|
23
|
+
pydantic_ai = importlib.import_module("pydantic_ai")
|
|
24
|
+
return pydantic_ai.Agent, pydantic_ai.Tool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_model_messages():
|
|
28
|
+
messages = importlib.import_module("pydantic_ai.messages")
|
|
29
|
+
return messages.ModelRequest, messages.ToolReturnPart
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def execute_tools_parallel(
|
|
33
|
+
tool_calls: list[tuple[Any, Any]], callback: ToolCallback, return_exceptions: bool = True
|
|
34
|
+
) -> list[Any]:
|
|
35
|
+
"""Execute multiple tool calls in parallel using asyncio.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
tool_calls: List of (part, node) tuples
|
|
39
|
+
callback: The tool callback function to execute
|
|
40
|
+
return_exceptions: Whether to return exceptions or raise them
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of results in the same order as input, with exceptions for failed calls
|
|
44
|
+
"""
|
|
45
|
+
# Get max parallel from environment or default to CPU count
|
|
46
|
+
max_parallel = int(os.environ.get("TUNACODE_MAX_PARALLEL", os.cpu_count() or 4))
|
|
47
|
+
|
|
48
|
+
async def execute_with_error_handling(part, node):
|
|
49
|
+
try:
|
|
50
|
+
return await callback(part, node)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return e
|
|
53
|
+
|
|
54
|
+
# If we have more tools than max_parallel, execute in batches
|
|
55
|
+
if len(tool_calls) > max_parallel:
|
|
56
|
+
results = []
|
|
57
|
+
for i in range(0, len(tool_calls), max_parallel):
|
|
58
|
+
batch = tool_calls[i : i + max_parallel]
|
|
59
|
+
batch_tasks = [execute_with_error_handling(part, node) for part, node in batch]
|
|
60
|
+
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=return_exceptions)
|
|
61
|
+
results.extend(batch_results)
|
|
62
|
+
return results
|
|
63
|
+
tasks = [execute_with_error_handling(part, node) for part, node in tool_calls]
|
|
64
|
+
return await asyncio.gather(*tasks, return_exceptions=return_exceptions)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def batch_read_only_tools(tool_calls: list[Any]) -> Iterator[list[Any]]:
|
|
68
|
+
"""Batch tool calls so read-only tools can be executed in parallel.
|
|
69
|
+
|
|
70
|
+
Yields batches where:
|
|
71
|
+
- Read-only tools are grouped together
|
|
72
|
+
- Write/execute tools are in their own batch (single item)
|
|
73
|
+
- Order within each batch is preserved
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
tool_calls: List of tool call objects with 'tool' attribute
|
|
77
|
+
|
|
78
|
+
Yields:
|
|
79
|
+
Batches of tool calls
|
|
80
|
+
"""
|
|
81
|
+
if not tool_calls:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
current_batch = []
|
|
85
|
+
|
|
86
|
+
for tool_call in tool_calls:
|
|
87
|
+
tool_name = tool_call.tool_name if hasattr(tool_call, "tool_name") else None
|
|
88
|
+
|
|
89
|
+
if tool_name in READ_ONLY_TOOLS:
|
|
90
|
+
# Add to current batch
|
|
91
|
+
current_batch.append(tool_call)
|
|
92
|
+
else:
|
|
93
|
+
# Yield any pending read-only batch
|
|
94
|
+
if current_batch:
|
|
95
|
+
yield current_batch
|
|
96
|
+
current_batch = []
|
|
97
|
+
|
|
98
|
+
# Yield write/execute tool as single-item batch
|
|
99
|
+
yield [tool_call]
|
|
100
|
+
|
|
101
|
+
# Yield any remaining read-only tools
|
|
102
|
+
if current_batch:
|
|
103
|
+
yield current_batch
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def create_buffering_callback(
|
|
107
|
+
original_callback: ToolCallback, buffer: Any, state_manager: StateManager
|
|
108
|
+
) -> ToolCallback:
|
|
109
|
+
"""Create a callback wrapper that buffers read-only tools for parallel execution.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
original_callback: The original tool callback
|
|
113
|
+
buffer: ToolBuffer instance to store read-only tools
|
|
114
|
+
state_manager: StateManager for UI access
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
A wrapped callback function
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
async def buffering_callback(part, node):
|
|
121
|
+
tool_name = getattr(part, "tool_name", None)
|
|
122
|
+
|
|
123
|
+
if tool_name in READ_ONLY_TOOLS:
|
|
124
|
+
# Buffer read-only tools
|
|
125
|
+
buffer.add(part, node)
|
|
126
|
+
# Don't execute yet - will be executed in parallel batch
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# Non-read-only tool encountered - flush buffer first
|
|
130
|
+
if buffer.has_tasks():
|
|
131
|
+
buffered_tasks = buffer.flush()
|
|
132
|
+
|
|
133
|
+
# Execute buffered read-only tools in parallel
|
|
134
|
+
if state_manager.session.show_thoughts:
|
|
135
|
+
await ui.muted(f"Executing {len(buffered_tasks)} read-only tools in parallel")
|
|
136
|
+
|
|
137
|
+
await execute_tools_parallel(buffered_tasks, original_callback)
|
|
138
|
+
|
|
139
|
+
# Execute the non-read-only tool
|
|
140
|
+
return await original_callback(part, node)
|
|
141
|
+
|
|
142
|
+
return buffering_callback
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def parse_json_tool_calls(
|
|
146
|
+
text: str, tool_callback: ToolCallback | None, state_manager: StateManager
|
|
147
|
+
):
|
|
148
|
+
"""Parse JSON tool calls from text when structured tool calling fails.
|
|
149
|
+
Fallback for when API providers don't support proper tool calling.
|
|
150
|
+
"""
|
|
151
|
+
if not tool_callback:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Pattern for JSON tool calls: {"tool": "tool_name", "args": {...}}
|
|
155
|
+
# Find potential JSON objects and parse them
|
|
156
|
+
potential_jsons = []
|
|
157
|
+
brace_count = 0
|
|
158
|
+
start_pos = -1
|
|
159
|
+
|
|
160
|
+
for i, char in enumerate(text):
|
|
161
|
+
if char == "{":
|
|
162
|
+
if brace_count == 0:
|
|
163
|
+
start_pos = i
|
|
164
|
+
brace_count += 1
|
|
165
|
+
elif char == "}":
|
|
166
|
+
brace_count -= 1
|
|
167
|
+
if brace_count == 0 and start_pos != -1:
|
|
168
|
+
potential_json = text[start_pos : i + 1]
|
|
169
|
+
try:
|
|
170
|
+
parsed = json.loads(potential_json)
|
|
171
|
+
if isinstance(parsed, dict) and "tool" in parsed and "args" in parsed:
|
|
172
|
+
potential_jsons.append((parsed["tool"], parsed["args"]))
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
pass
|
|
175
|
+
start_pos = -1
|
|
176
|
+
|
|
177
|
+
matches = potential_jsons
|
|
178
|
+
|
|
179
|
+
for tool_name, args in matches:
|
|
180
|
+
try:
|
|
181
|
+
# Create a mock tool call object
|
|
182
|
+
class MockToolCall:
|
|
183
|
+
def __init__(self, tool_name: str, args: dict):
|
|
184
|
+
self.tool_name = tool_name
|
|
185
|
+
self.args = args
|
|
186
|
+
self.tool_call_id = f"fallback_{datetime.now().timestamp()}"
|
|
187
|
+
|
|
188
|
+
class MockNode:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# Execute the tool through the callback
|
|
192
|
+
mock_call = MockToolCall(tool_name, args)
|
|
193
|
+
mock_node = MockNode()
|
|
194
|
+
|
|
195
|
+
await tool_callback(mock_call, mock_node)
|
|
196
|
+
|
|
197
|
+
if state_manager.session.show_thoughts:
|
|
198
|
+
await ui.muted(f"FALLBACK: Executed {tool_name} via JSON parsing")
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
if state_manager.session.show_thoughts:
|
|
202
|
+
await ui.error(f"Error executing fallback tool {tool_name}: {e!s}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def extract_and_execute_tool_calls(
|
|
206
|
+
text: str, tool_callback: ToolCallback | None, state_manager: StateManager
|
|
207
|
+
):
|
|
208
|
+
"""Extract tool calls from text content and execute them.
|
|
209
|
+
Supports multiple formats for maximum compatibility.
|
|
210
|
+
"""
|
|
211
|
+
if not tool_callback:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
# Format 1: {"tool": "name", "args": {...}}
|
|
215
|
+
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
216
|
+
|
|
217
|
+
# Format 2: Tool calls in code blocks
|
|
218
|
+
code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
|
|
219
|
+
code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
|
|
220
|
+
|
|
221
|
+
for match in code_matches:
|
|
222
|
+
try:
|
|
223
|
+
tool_data = json.loads(match)
|
|
224
|
+
if "tool" in tool_data and "args" in tool_data:
|
|
225
|
+
|
|
226
|
+
class MockToolCall:
|
|
227
|
+
def __init__(self, tool_name: str, args: dict):
|
|
228
|
+
self.tool_name = tool_name
|
|
229
|
+
self.args = args
|
|
230
|
+
self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
|
|
231
|
+
|
|
232
|
+
class MockNode:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
|
|
236
|
+
mock_node = MockNode()
|
|
237
|
+
|
|
238
|
+
await tool_callback(mock_call, mock_node)
|
|
239
|
+
|
|
240
|
+
if state_manager.session.show_thoughts:
|
|
241
|
+
await ui.muted(f"FALLBACK: Executed {tool_data['tool']} from code block")
|
|
242
|
+
|
|
243
|
+
except (json.JSONDecodeError, KeyError, Exception) as e:
|
|
244
|
+
if state_manager.session.show_thoughts:
|
|
245
|
+
await ui.error(f"Error parsing code block tool call: {e!s}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def patch_tool_messages(
|
|
249
|
+
error_message: ErrorMessage = "Tool operation failed",
|
|
250
|
+
state_manager: StateManager = None,
|
|
251
|
+
):
|
|
252
|
+
"""Find any tool calls without responses and add synthetic error responses for them.
|
|
253
|
+
Takes an error message to use in the synthesized tool response.
|
|
254
|
+
|
|
255
|
+
Ignores tools that have corresponding retry prompts as the model is already
|
|
256
|
+
addressing them.
|
|
257
|
+
"""
|
|
258
|
+
if state_manager is None:
|
|
259
|
+
raise ValueError("state_manager is required for patch_tool_messages")
|
|
260
|
+
|
|
261
|
+
messages = state_manager.session.messages
|
|
262
|
+
|
|
263
|
+
if not messages:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Map tool calls to their tool returns
|
|
267
|
+
tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
|
|
268
|
+
tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
|
|
269
|
+
retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
|
|
270
|
+
|
|
271
|
+
for message in messages:
|
|
272
|
+
if hasattr(message, "parts"):
|
|
273
|
+
for part in message.parts:
|
|
274
|
+
if (
|
|
275
|
+
hasattr(part, "part_kind")
|
|
276
|
+
and hasattr(part, "tool_call_id")
|
|
277
|
+
and part.tool_call_id
|
|
278
|
+
):
|
|
279
|
+
if part.part_kind == "tool-call":
|
|
280
|
+
tool_calls[part.tool_call_id] = part.tool_name
|
|
281
|
+
elif part.part_kind == "tool-return":
|
|
282
|
+
tool_returns.add(part.tool_call_id)
|
|
283
|
+
elif part.part_kind == "retry-prompt":
|
|
284
|
+
retry_prompts.add(part.tool_call_id)
|
|
285
|
+
|
|
286
|
+
# Identify orphaned tools (those without responses and not being retried)
|
|
287
|
+
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
288
|
+
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
289
|
+
# Import ModelRequest and ToolReturnPart lazily
|
|
290
|
+
model_request_cls, tool_return_part_cls = get_model_messages()
|
|
291
|
+
messages.append(
|
|
292
|
+
model_request_cls(
|
|
293
|
+
parts=[
|
|
294
|
+
tool_return_part_cls(
|
|
295
|
+
tool_name=tool_name,
|
|
296
|
+
content=error_message,
|
|
297
|
+
tool_call_id=tool_call_id,
|
|
298
|
+
timestamp=datetime.now(timezone.utc),
|
|
299
|
+
part_kind="tool-return",
|
|
300
|
+
)
|
|
301
|
+
],
|
|
302
|
+
kind="request",
|
|
303
|
+
)
|
|
304
|
+
)
|
|
@@ -107,7 +107,6 @@ class ConfigSetup(BaseSetup):
|
|
|
107
107
|
"--key 'your-key' --baseurl 'https://openrouter.ai/api/v1'[/green]"
|
|
108
108
|
)
|
|
109
109
|
console.print("\n[yellow]Run 'tunacode --help' for more options[/yellow]\n")
|
|
110
|
-
from tunacode.exceptions import ConfigurationError
|
|
111
110
|
|
|
112
111
|
raise ConfigurationError(
|
|
113
112
|
"No configuration found. Please use CLI flags to configure."
|
tunacode/core/state.py
CHANGED
|
@@ -8,8 +8,15 @@ import uuid
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from typing import Any, Optional
|
|
10
10
|
|
|
11
|
-
from tunacode.types import (
|
|
12
|
-
|
|
11
|
+
from tunacode.types import (
|
|
12
|
+
DeviceId,
|
|
13
|
+
InputSessions,
|
|
14
|
+
MessageHistory,
|
|
15
|
+
ModelName,
|
|
16
|
+
SessionId,
|
|
17
|
+
ToolName,
|
|
18
|
+
UserConfig,
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
@dataclass
|
|
@@ -35,6 +42,10 @@ class SessionState:
|
|
|
35
42
|
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
36
43
|
iteration_count: int = 0
|
|
37
44
|
current_iteration: int = 0
|
|
45
|
+
# Track streaming state to prevent spinner conflicts
|
|
46
|
+
is_streaming_active: bool = False
|
|
47
|
+
# Track streaming panel reference for tool handler access
|
|
48
|
+
streaming_panel: Optional[Any] = None
|
|
38
49
|
|
|
39
50
|
|
|
40
51
|
class StateManager:
|
tunacode/setup.py
CHANGED
|
@@ -7,8 +7,13 @@ Provides high-level setup functions for initializing the application and its age
|
|
|
7
7
|
|
|
8
8
|
from typing import Any, Optional
|
|
9
9
|
|
|
10
|
-
from tunacode.core.setup import (
|
|
11
|
-
|
|
10
|
+
from tunacode.core.setup import (
|
|
11
|
+
AgentSetup,
|
|
12
|
+
ConfigSetup,
|
|
13
|
+
EnvironmentSetup,
|
|
14
|
+
GitSafetySetup,
|
|
15
|
+
SetupCoordinator,
|
|
16
|
+
)
|
|
12
17
|
from tunacode.core.state import StateManager
|
|
13
18
|
|
|
14
19
|
|
tunacode/tools/read_file.py
CHANGED
|
@@ -8,8 +8,14 @@ Provides safe file reading with size limits and proper error handling.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import os
|
|
10
10
|
|
|
11
|
-
from tunacode.constants import (
|
|
12
|
-
|
|
11
|
+
from tunacode.constants import (
|
|
12
|
+
ERROR_FILE_DECODE,
|
|
13
|
+
ERROR_FILE_DECODE_DETAILS,
|
|
14
|
+
ERROR_FILE_NOT_FOUND,
|
|
15
|
+
ERROR_FILE_TOO_LARGE,
|
|
16
|
+
MAX_FILE_SIZE,
|
|
17
|
+
MSG_FILE_SIZE_LIMIT,
|
|
18
|
+
)
|
|
13
19
|
from tunacode.exceptions import ToolExecutionError
|
|
14
20
|
from tunacode.tools.base import FileBasedTool
|
|
15
21
|
from tunacode.types import ToolResult
|
|
@@ -11,8 +11,14 @@ import sys
|
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from typing import Optional
|
|
13
13
|
|
|
14
|
-
from tunacode.constants import (
|
|
15
|
-
|
|
14
|
+
from tunacode.constants import (
|
|
15
|
+
ERROR_FILE_DECODE,
|
|
16
|
+
ERROR_FILE_DECODE_DETAILS,
|
|
17
|
+
ERROR_FILE_NOT_FOUND,
|
|
18
|
+
ERROR_FILE_TOO_LARGE,
|
|
19
|
+
MAX_FILE_SIZE,
|
|
20
|
+
MSG_FILE_SIZE_LIMIT,
|
|
21
|
+
)
|
|
16
22
|
from tunacode.exceptions import ToolExecutionError
|
|
17
23
|
from tunacode.tools.base import FileBasedTool
|
|
18
24
|
from tunacode.types import ToolResult
|
|
@@ -150,17 +156,18 @@ async def read_file_async(filepath: str) -> str:
|
|
|
150
156
|
# Benchmarking utilities for testing
|
|
151
157
|
async def benchmark_read_performance():
|
|
152
158
|
"""Benchmark the performance difference between sync and async reads."""
|
|
159
|
+
import contextlib
|
|
160
|
+
import tempfile
|
|
153
161
|
import time
|
|
154
162
|
|
|
155
163
|
from tunacode.tools.read_file import read_file as read_file_sync
|
|
156
164
|
|
|
157
|
-
# Create some test files
|
|
165
|
+
# Create some test files using tempfile for secure temporary file creation
|
|
158
166
|
test_files = []
|
|
159
|
-
for
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
test_files.append(filepath)
|
|
167
|
+
for _ in range(10):
|
|
168
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as temp_file:
|
|
169
|
+
temp_file.write("x" * 10000) # 10KB file
|
|
170
|
+
test_files.append(temp_file.name)
|
|
164
171
|
|
|
165
172
|
# Test synchronous reads (sequential)
|
|
166
173
|
start_time = time.time()
|
|
@@ -174,9 +181,10 @@ async def benchmark_read_performance():
|
|
|
174
181
|
await asyncio.gather(*tasks)
|
|
175
182
|
async_time = time.time() - start_time
|
|
176
183
|
|
|
177
|
-
# Cleanup
|
|
184
|
+
# Cleanup using safe file removal
|
|
178
185
|
for filepath in test_files:
|
|
179
|
-
|
|
186
|
+
with contextlib.suppress(OSError):
|
|
187
|
+
os.unlink(filepath)
|
|
180
188
|
|
|
181
189
|
print(f"Synchronous reads: {sync_time:.3f}s")
|
|
182
190
|
print(f"Async reads: {async_time:.3f}s")
|
tunacode/tools/run_command.py
CHANGED
|
@@ -7,10 +7,17 @@ Provides controlled shell command execution with output capture and truncation.
|
|
|
7
7
|
|
|
8
8
|
import subprocess
|
|
9
9
|
|
|
10
|
-
from tunacode.constants import (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
from tunacode.constants import (
|
|
11
|
+
CMD_OUTPUT_FORMAT,
|
|
12
|
+
CMD_OUTPUT_NO_ERRORS,
|
|
13
|
+
CMD_OUTPUT_NO_OUTPUT,
|
|
14
|
+
CMD_OUTPUT_TRUNCATED,
|
|
15
|
+
COMMAND_OUTPUT_END_SIZE,
|
|
16
|
+
COMMAND_OUTPUT_START_INDEX,
|
|
17
|
+
COMMAND_OUTPUT_THRESHOLD,
|
|
18
|
+
ERROR_COMMAND_EXECUTION,
|
|
19
|
+
MAX_COMMAND_OUTPUT,
|
|
20
|
+
)
|
|
14
21
|
from tunacode.exceptions import ToolExecutionError
|
|
15
22
|
from tunacode.tools.base import BaseTool
|
|
16
23
|
from tunacode.types import ToolResult
|
tunacode/ui/console.py
CHANGED
|
@@ -9,11 +9,36 @@ from rich.markdown import Markdown
|
|
|
9
9
|
# Import and re-export all functions from specialized modules
|
|
10
10
|
from .input import formatted_text, input, multiline_input
|
|
11
11
|
from .keybindings import create_key_bindings
|
|
12
|
-
from .output import (
|
|
13
|
-
|
|
12
|
+
from .output import (
|
|
13
|
+
banner,
|
|
14
|
+
clear,
|
|
15
|
+
info,
|
|
16
|
+
line,
|
|
17
|
+
muted,
|
|
18
|
+
print,
|
|
19
|
+
spinner,
|
|
20
|
+
success,
|
|
21
|
+
sync_print,
|
|
22
|
+
update_available,
|
|
23
|
+
usage,
|
|
24
|
+
version,
|
|
25
|
+
warning,
|
|
26
|
+
)
|
|
27
|
+
|
|
14
28
|
# Patch banner to use sync fast version
|
|
15
|
-
from .panels import (
|
|
16
|
-
|
|
29
|
+
from .panels import (
|
|
30
|
+
StreamingAgentPanel,
|
|
31
|
+
agent,
|
|
32
|
+
agent_streaming,
|
|
33
|
+
dump_messages,
|
|
34
|
+
error,
|
|
35
|
+
help,
|
|
36
|
+
models,
|
|
37
|
+
panel,
|
|
38
|
+
sync_panel,
|
|
39
|
+
sync_tool_confirm,
|
|
40
|
+
tool_confirm,
|
|
41
|
+
)
|
|
17
42
|
from .prompt_manager import PromptConfig, PromptManager
|
|
18
43
|
from .validators import ModelValidator
|
|
19
44
|
|
|
@@ -56,11 +81,13 @@ __all__ = [
|
|
|
56
81
|
"warning",
|
|
57
82
|
# From panels module
|
|
58
83
|
"agent",
|
|
84
|
+
"agent_streaming",
|
|
59
85
|
"dump_messages",
|
|
60
86
|
"error",
|
|
61
87
|
"help",
|
|
62
88
|
"models",
|
|
63
89
|
"panel",
|
|
90
|
+
"StreamingAgentPanel",
|
|
64
91
|
"sync_panel",
|
|
65
92
|
"sync_tool_confirm",
|
|
66
93
|
"tool_confirm",
|
tunacode/ui/output.py
CHANGED
|
@@ -5,8 +5,13 @@ from rich.console import Console
|
|
|
5
5
|
from rich.padding import Padding
|
|
6
6
|
|
|
7
7
|
from tunacode.configuration.settings import ApplicationSettings
|
|
8
|
-
from tunacode.constants import (
|
|
9
|
-
|
|
8
|
+
from tunacode.constants import (
|
|
9
|
+
MSG_UPDATE_AVAILABLE,
|
|
10
|
+
MSG_UPDATE_INSTRUCTION,
|
|
11
|
+
MSG_VERSION_DISPLAY,
|
|
12
|
+
UI_COLORS,
|
|
13
|
+
UI_THINKING_MESSAGE,
|
|
14
|
+
)
|
|
10
15
|
from tunacode.core.state import StateManager
|
|
11
16
|
from tunacode.utils.file_utils import DotDict
|
|
12
17
|
|