tunacode-cli 0.0.76.1__py3-none-any.whl → 0.0.76.3__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.

@@ -3,9 +3,8 @@ import importlib
3
3
  import json
4
4
  import logging
5
5
  import os
6
- import re
7
6
  from collections.abc import Iterator
8
- from datetime import datetime, timezone
7
+ from datetime import datetime
9
8
  from typing import Any
10
9
 
11
10
  from tunacode.constants import (
@@ -16,11 +15,8 @@ from tunacode.constants import (
16
15
  )
17
16
  from tunacode.exceptions import ToolBatchingJSONError
18
17
  from tunacode.types import (
19
- ErrorMessage,
20
18
  StateManager,
21
19
  ToolCallback,
22
- ToolCallId,
23
- ToolName,
24
20
  )
25
21
  from tunacode.ui import console as ui
26
22
  from tunacode.utils.retry import retry_json_parse_async
@@ -267,127 +263,3 @@ async def parse_json_tool_calls(
267
263
  except Exception as e:
268
264
  if state_manager.session.show_thoughts:
269
265
  await ui.error(f"Error executing fallback tool {tool_name}: {e!s}")
270
-
271
-
272
- async def extract_and_execute_tool_calls(
273
- text: str, tool_callback: ToolCallback | None, state_manager: StateManager
274
- ):
275
- """Extract tool calls from text content and execute them.
276
- Supports multiple formats for maximum compatibility.
277
- """
278
- if not tool_callback:
279
- return
280
-
281
- # Format 2: Tool calls in code blocks
282
- code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
283
- code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
284
- remaining_text = re.sub(code_block_pattern, "", text)
285
-
286
- for match in code_matches:
287
- try:
288
- # Use retry logic for JSON parsing in code blocks
289
- tool_data = await retry_json_parse_async(
290
- match,
291
- max_retries=JSON_PARSE_MAX_RETRIES,
292
- base_delay=JSON_PARSE_BASE_DELAY,
293
- max_delay=JSON_PARSE_MAX_DELAY,
294
- )
295
- if "tool" in tool_data and "args" in tool_data:
296
-
297
- class MockToolCall:
298
- def __init__(self, tool_name: str, args: dict):
299
- self.tool_name = tool_name
300
- self.args = args
301
- self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
302
-
303
- class MockNode:
304
- pass
305
-
306
- mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
307
- mock_node = MockNode()
308
-
309
- await tool_callback(mock_call, mock_node)
310
-
311
- if state_manager.session.show_thoughts:
312
- await ui.muted(f"FALLBACK: Executed {tool_data['tool']} from code block")
313
-
314
- except json.JSONDecodeError as e:
315
- # After all retries failed
316
- logger.error(
317
- f"Code block JSON parsing failed after {JSON_PARSE_MAX_RETRIES} retries: {e}"
318
- )
319
- if state_manager.session.show_thoughts:
320
- await ui.error(
321
- f"Failed to parse code block tool JSON after {JSON_PARSE_MAX_RETRIES} retries"
322
- )
323
- # Raise custom exception for better error handling
324
- raise ToolBatchingJSONError(
325
- json_content=match,
326
- retry_count=JSON_PARSE_MAX_RETRIES,
327
- original_error=e,
328
- ) from e
329
- except (KeyError, Exception) as e:
330
- if state_manager.session.show_thoughts:
331
- await ui.error(f"Error parsing code block tool call: {e!s}")
332
-
333
- # Format 1: {"tool": "name", "args": {...}}
334
- await parse_json_tool_calls(remaining_text, tool_callback, state_manager)
335
-
336
-
337
- def patch_tool_messages(
338
- error_message: ErrorMessage = "Tool operation failed",
339
- state_manager: StateManager = None,
340
- ):
341
- """Find any tool calls without responses and add synthetic error responses for them.
342
- Takes an error message to use in the synthesized tool response.
343
-
344
- Ignores tools that have corresponding retry prompts as the model is already
345
- addressing them.
346
- """
347
- if state_manager is None:
348
- raise ValueError("state_manager is required for patch_tool_messages")
349
-
350
- messages = state_manager.session.messages
351
-
352
- if not messages:
353
- return
354
-
355
- # Map tool calls to their tool returns
356
- tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
357
- tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
358
- retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
359
-
360
- for message in messages:
361
- if hasattr(message, "parts"):
362
- for part in message.parts:
363
- if (
364
- hasattr(part, "part_kind")
365
- and hasattr(part, "tool_call_id")
366
- and part.tool_call_id
367
- ):
368
- if part.part_kind == "tool-call":
369
- tool_calls[part.tool_call_id] = part.tool_name
370
- elif part.part_kind == "tool-return":
371
- tool_returns.add(part.tool_call_id)
372
- elif part.part_kind == "retry-prompt":
373
- retry_prompts.add(part.tool_call_id)
374
-
375
- # Identify orphaned tools (those without responses and not being retried)
376
- for tool_call_id, tool_name in list(tool_calls.items()):
377
- if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
378
- # Import ModelRequest and ToolReturnPart lazily
379
- model_request_cls, tool_return_part_cls, _ = get_model_messages()
380
- messages.append(
381
- model_request_cls(
382
- parts=[
383
- tool_return_part_cls(
384
- tool_name=tool_name,
385
- content=error_message,
386
- tool_call_id=tool_call_id,
387
- timestamp=datetime.now(timezone.utc),
388
- part_kind="tool-return",
389
- )
390
- ],
391
- kind="request",
392
- )
393
- )
tunacode/core/state.py CHANGED
@@ -52,6 +52,10 @@ class SessionState:
52
52
  input_sessions: InputSessions = field(default_factory=dict)
53
53
  current_task: Optional[Any] = None
54
54
  todos: list[TodoItem] = field(default_factory=list)
55
+ # CLAUDE_ANCHOR[react-scratchpad]: Session scratchpad for ReAct tooling
56
+ react_scratchpad: dict[str, Any] = field(default_factory=lambda: {"timeline": []})
57
+ react_forced_calls: int = 0
58
+ react_guidance: list[str] = field(default_factory=list)
55
59
  # Operation state tracking
56
60
  operation_cancelled: bool = False
57
61
  # Enhanced tracking for thoughts display
@@ -174,6 +178,17 @@ class StateManager:
174
178
  def clear_todos(self) -> None:
175
179
  self._session.todos = []
176
180
 
181
+ # React scratchpad helpers
182
+ def get_react_scratchpad(self) -> dict[str, Any]:
183
+ return self._session.react_scratchpad
184
+
185
+ def append_react_entry(self, entry: dict[str, Any]) -> None:
186
+ timeline = self._session.react_scratchpad.setdefault("timeline", [])
187
+ timeline.append(entry)
188
+
189
+ def clear_react_scratchpad(self) -> None:
190
+ self._session.react_scratchpad = {"timeline": []}
191
+
177
192
  def reset_session(self) -> None:
178
193
  """Reset the session to a fresh state."""
179
194
  self._session = SessionState()
@@ -0,0 +1,23 @@
1
+ <tool>
2
+ <description>
3
+ Record a ReAct-style think/observe timeline, retrieve it, or clear it for the current session.
4
+ </description>
5
+ <parameters>
6
+ <parameter name="action" required="true">
7
+ <type>string</type>
8
+ <description>One of think, observe, get, clear.</description>
9
+ </parameter>
10
+ <parameter name="thoughts" required="false">
11
+ <type>string</type>
12
+ <description>Reasoning text for think entries.</description>
13
+ </parameter>
14
+ <parameter name="next_action" required="false">
15
+ <type>string</type>
16
+ <description>Planned action to pair with think entries.</description>
17
+ </parameter>
18
+ <parameter name="result" required="false">
19
+ <type>string</type>
20
+ <description>Observation details for observe entries.</description>
21
+ </parameter>
22
+ </parameters>
23
+ </tool>
@@ -0,0 +1,153 @@
1
+ """Lightweight ReAct-style scratchpad tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Literal
7
+
8
+ import defusedxml.ElementTree as ET
9
+ from pydantic_ai.exceptions import ModelRetry
10
+
11
+ from tunacode.core.state import StateManager
12
+ from tunacode.types import ToolResult, UILogger
13
+
14
+ from .base import BaseTool
15
+
16
+
17
+ # CLAUDE_ANCHOR[react-tool]: Minimal ReAct scratchpad tool surface
18
+ class ReactTool(BaseTool):
19
+ """Minimal ReAct scratchpad for tracking think/observe steps."""
20
+
21
+ def __init__(self, state_manager: StateManager, ui_logger: UILogger | None = None):
22
+ super().__init__(ui_logger)
23
+ self.state_manager = state_manager
24
+
25
+ @property
26
+ def tool_name(self) -> str:
27
+ return "react"
28
+
29
+ async def _execute(
30
+ self,
31
+ action: Literal["think", "observe", "get", "clear"],
32
+ thoughts: str | None = None,
33
+ next_action: str | None = None,
34
+ result: str | None = None,
35
+ ) -> ToolResult:
36
+ scratchpad = self._ensure_scratchpad()
37
+
38
+ if action == "think":
39
+ if not thoughts:
40
+ raise ModelRetry("Provide thoughts when using react think action")
41
+ if not next_action:
42
+ raise ModelRetry("Specify next_action when recording react thoughts")
43
+
44
+ entry = {
45
+ "type": "think",
46
+ "thoughts": thoughts,
47
+ "next_action": next_action,
48
+ }
49
+ self.state_manager.append_react_entry(entry)
50
+ return "Recorded think step"
51
+
52
+ if action == "observe":
53
+ if not result:
54
+ raise ModelRetry("Provide result when using react observe action")
55
+
56
+ entry = {
57
+ "type": "observe",
58
+ "result": result,
59
+ }
60
+ self.state_manager.append_react_entry(entry)
61
+ return "Recorded observation"
62
+
63
+ if action == "get":
64
+ timeline = scratchpad.get("timeline", [])
65
+ if not timeline:
66
+ return "React scratchpad is empty"
67
+
68
+ formatted = [
69
+ f"{index + 1}. {item['type']}: {self._format_entry(item)}"
70
+ for index, item in enumerate(timeline)
71
+ ]
72
+ return "\n".join(formatted)
73
+
74
+ if action == "clear":
75
+ self.state_manager.clear_react_scratchpad()
76
+ return "React scratchpad cleared"
77
+
78
+ raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
79
+
80
+ def _format_entry(self, item: Dict[str, Any]) -> str:
81
+ if item["type"] == "think":
82
+ return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
83
+ if item["type"] == "observe":
84
+ return f"result='{item['result']}'"
85
+ return str(item)
86
+
87
+ def _ensure_scratchpad(self) -> dict[str, Any]:
88
+ scratchpad = self.state_manager.get_react_scratchpad()
89
+ scratchpad.setdefault("timeline", [])
90
+ return scratchpad
91
+
92
+ def _get_base_prompt(self) -> str:
93
+ prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
94
+ if prompt_file.exists():
95
+ try:
96
+ tree = ET.parse(prompt_file)
97
+ root = tree.getroot()
98
+ description = root.find("description")
99
+ if description is not None and description.text:
100
+ return description.text.strip()
101
+ except Exception:
102
+ pass
103
+ return "Use this tool to record think/observe notes and manage the react scratchpad"
104
+
105
+ def _get_parameters_schema(self) -> Dict[str, Any]:
106
+ prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
107
+ if prompt_file.exists():
108
+ try:
109
+ tree = ET.parse(prompt_file)
110
+ root = tree.getroot()
111
+ parameters = root.find("parameters")
112
+ if parameters is not None:
113
+ schema: Dict[str, Any] = {
114
+ "type": "object",
115
+ "properties": {},
116
+ "required": ["action"],
117
+ }
118
+ for param in parameters.findall("parameter"):
119
+ name = param.get("name")
120
+ param_type = param.find("type")
121
+ description = param.find("description")
122
+ if name and param_type is not None:
123
+ schema["properties"][name] = {
124
+ "type": param_type.text.strip(),
125
+ "description": description.text.strip()
126
+ if description is not None and description.text
127
+ else "",
128
+ }
129
+ return schema
130
+ except Exception:
131
+ pass
132
+ return {
133
+ "type": "object",
134
+ "properties": {
135
+ "action": {
136
+ "type": "string",
137
+ "description": "react operation to perform",
138
+ },
139
+ "thoughts": {
140
+ "type": "string",
141
+ "description": "Thought content for think action",
142
+ },
143
+ "next_action": {
144
+ "type": "string",
145
+ "description": "Planned next action for think action",
146
+ },
147
+ "result": {
148
+ "type": "string",
149
+ "description": "Observation message for observe action",
150
+ },
151
+ },
152
+ "required": ["action"],
153
+ }
tunacode/ui/completers.py CHANGED
@@ -11,6 +11,8 @@ from prompt_toolkit.completion import (
11
11
  )
12
12
  from prompt_toolkit.document import Document
13
13
 
14
+ from ..utils.fuzzy_utils import find_fuzzy_matches
15
+
14
16
  if TYPE_CHECKING:
15
17
  from ..cli.commands import CommandRegistry
16
18
  from ..utils.models_registry import ModelInfo, ModelsRegistry
@@ -73,7 +75,12 @@ class FileReferenceCompleter(Completer):
73
75
  def get_completions(
74
76
  self, document: Document, _complete_event: CompleteEvent
75
77
  ) -> Iterable[Completion]:
76
- """Get completions for @file references."""
78
+ """Get completions for @file references.
79
+
80
+ Favors file matches before directory matches and supports fuzzy
81
+ matching for near-miss filenames. Order:
82
+ exact files > fuzzy files > exact dirs > fuzzy dirs
83
+ """
77
84
  # Get the word before cursor
78
85
  word_before_cursor = document.get_word_before_cursor(WORD=True)
79
86
 
@@ -94,34 +101,75 @@ class FileReferenceCompleter(Completer):
94
101
  dir_path = "."
95
102
  prefix = path_part
96
103
 
97
- # Get matching files
104
+ # If prefix itself is an existing directory (without trailing slash),
105
+ # treat it as browsing inside that directory
106
+ candidate_dir = os.path.join(dir_path, prefix) if dir_path != "." else prefix
107
+ if prefix and os.path.isdir(candidate_dir) and not path_part.endswith("/"):
108
+ dir_path = candidate_dir
109
+ prefix = ""
110
+
111
+ # Get matching files with fuzzy support
98
112
  try:
99
113
  if os.path.exists(dir_path) and os.path.isdir(dir_path):
100
- for item in sorted(os.listdir(dir_path)):
101
- if item.startswith(prefix):
102
- full_path = os.path.join(dir_path, item) if dir_path != "." else item
103
-
104
- # Skip hidden files unless explicitly requested
105
- if item.startswith(".") and not prefix.startswith("."):
106
- continue
107
-
108
- # Add / for directories
109
- if os.path.isdir(full_path):
110
- display = item + "/"
111
- completion = full_path + "/"
112
- else:
113
- display = item
114
- completion = full_path
115
-
116
- # Calculate how much to replace
117
- start_position = -len(path_part)
118
-
119
- yield Completion(
120
- text=completion,
121
- start_position=start_position,
122
- display=display,
123
- display_meta="dir" if os.path.isdir(full_path) else "file",
124
- )
114
+ items = sorted(os.listdir(dir_path))
115
+
116
+ # Separate files vs dirs; skip hidden unless explicitly requested
117
+ show_hidden = prefix.startswith(".")
118
+ files: List[str] = []
119
+ dirs: List[str] = []
120
+ for item in items:
121
+ if item.startswith(".") and not show_hidden:
122
+ continue
123
+ full_item_path = os.path.join(dir_path, item) if dir_path != "." else item
124
+ if os.path.isdir(full_item_path):
125
+ dirs.append(item)
126
+ else:
127
+ files.append(item)
128
+
129
+ # Exact prefix matches (case-insensitive)
130
+ prefix_lower = prefix.lower()
131
+ exact_files = [f for f in files if f.lower().startswith(prefix_lower)]
132
+ exact_dirs = [d for d in dirs if d.lower().startswith(prefix_lower)]
133
+
134
+ # Fuzzy matches (exclude items already matched exactly)
135
+ fuzzy_file_candidates = [f for f in files if f not in exact_files]
136
+ fuzzy_dir_candidates = [d for d in dirs if d not in exact_dirs]
137
+
138
+ fuzzy_files = (
139
+ find_fuzzy_matches(prefix, fuzzy_file_candidates, n=10, cutoff=0.75)
140
+ if prefix
141
+ else []
142
+ )
143
+ fuzzy_dirs = (
144
+ find_fuzzy_matches(prefix, fuzzy_dir_candidates, n=10, cutoff=0.75)
145
+ if prefix
146
+ else []
147
+ )
148
+
149
+ # Compose ordered results
150
+ ordered: List[tuple[str, str]] = (
151
+ [("file", name) for name in exact_files]
152
+ + [("file", name) for name in fuzzy_files]
153
+ + [("dir", name) for name in exact_dirs]
154
+ + [("dir", name) for name in fuzzy_dirs]
155
+ )
156
+
157
+ start_position = -len(path_part)
158
+ for kind, name in ordered:
159
+ full_path = os.path.join(dir_path, name) if dir_path != "." else name
160
+ if kind == "dir":
161
+ display = name + "/"
162
+ completion_text = full_path + "/"
163
+ else:
164
+ display = name
165
+ completion_text = full_path
166
+
167
+ yield Completion(
168
+ text=completion_text,
169
+ start_position=start_position,
170
+ display=display,
171
+ display_meta="dir" if kind == "dir" else "file",
172
+ )
125
173
  except (OSError, PermissionError):
126
174
  # Silently ignore inaccessible directories
127
175
  pass
@@ -0,0 +1,33 @@
1
+ """Shared fuzzy matching utilities.
2
+
3
+ Minimal helper to provide consistent fuzzy matching behavior across
4
+ components (commands, file completers, etc.) without introducing new
5
+ dependencies. Reuses difflib.get_close_matches with a default cutoff of 0.75
6
+ matching the command registry behavior.
7
+ """
8
+
9
+ from difflib import get_close_matches
10
+ from typing import List, Sequence
11
+
12
+
13
+ def find_fuzzy_matches(
14
+ query: str,
15
+ choices: Sequence[str],
16
+ *,
17
+ n: int = 5,
18
+ cutoff: float = 0.75,
19
+ ) -> List[str]:
20
+ """Return up to ``n`` fuzzy matches from ``choices`` for ``query``.
21
+
22
+ The return order is the order produced by ``difflib.get_close_matches``.
23
+ Matching is case-insensitive; original casing is preserved in the result.
24
+ """
25
+ if not query or not choices:
26
+ return []
27
+
28
+ # Map lower-case to original to do case-insensitive matching while
29
+ # returning original items.
30
+ lower_to_original = {c.lower(): c for c in choices}
31
+ candidates = list(lower_to_original.keys())
32
+ matches_lower = get_close_matches(query.lower(), candidates, n=n, cutoff=cutoff)
33
+ return [lower_to_original[m] for m in matches_lower]