tunacode-cli 0.0.76__py3-none-any.whl → 0.0.76.2__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
- )
@@ -6,6 +6,7 @@ and keep responsibilities focused.
6
6
 
7
7
  import json
8
8
  from pathlib import Path
9
+ from typing import Dict, Optional
9
10
 
10
11
  from tunacode.constants import UI_COLORS
11
12
  from tunacode.exceptions import ConfigurationError
@@ -20,7 +21,7 @@ class ConfigWizard:
20
21
  self.state_manager = state_manager
21
22
  self.model_registry = model_registry
22
23
  self.config_file = config_file
23
- self._wizard_selected_provider = None
24
+ self._wizard_selected_provider: Optional[Dict[str, str]] = None
24
25
 
25
26
  async def run_onboarding(self) -> None:
26
27
  """Run enhanced wizard-style onboarding process for new users."""
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
+ }