tunacode-cli 0.0.71__py3-none-any.whl → 0.0.73__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/implementations/model.py +332 -32
- tunacode/cli/repl.py +2 -1
- tunacode/constants.py +1 -1
- tunacode/core/agents/agent_components/agent_config.py +32 -19
- tunacode/core/agents/agent_components/agent_helpers.py +1 -1
- tunacode/core/agents/agent_components/node_processor.py +35 -3
- tunacode/core/agents/agent_components/response_state.py +109 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/task_completion.py +10 -6
- tunacode/core/agents/main.py +4 -4
- tunacode/prompts/system.md +11 -1
- tunacode/types.py +9 -0
- tunacode/ui/completers.py +211 -9
- tunacode/ui/input.py +7 -1
- tunacode/ui/model_selector.py +394 -0
- tunacode/utils/models_registry.py +563 -0
- {tunacode_cli-0.0.71.dist-info → tunacode_cli-0.0.73.dist-info}/METADATA +1 -1
- {tunacode_cli-0.0.71.dist-info → tunacode_cli-0.0.73.dist-info}/RECORD +21 -18
- {tunacode_cli-0.0.71.dist-info → tunacode_cli-0.0.73.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.71.dist-info → tunacode_cli-0.0.73.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.71.dist-info → tunacode_cli-0.0.73.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,61 +1,361 @@
|
|
|
1
1
|
"""Model management commands for TunaCode CLI."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
4
|
|
|
5
|
-
from .... import utils
|
|
6
5
|
from ....exceptions import ConfigurationError
|
|
7
6
|
from ....types import CommandArgs, CommandContext
|
|
8
7
|
from ....ui import console as ui
|
|
8
|
+
from ....ui.model_selector import select_model_interactive
|
|
9
|
+
from ....utils import user_configuration
|
|
10
|
+
from ....utils.models_registry import ModelInfo, ModelsRegistry
|
|
9
11
|
from ..base import CommandCategory, CommandSpec, SimpleCommand
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class ModelCommand(SimpleCommand):
|
|
13
|
-
"""Manage model selection."""
|
|
15
|
+
"""Manage model selection with models.dev integration."""
|
|
14
16
|
|
|
15
17
|
spec = CommandSpec(
|
|
16
18
|
name="model",
|
|
17
19
|
aliases=["/model"],
|
|
18
|
-
description="Switch model
|
|
20
|
+
description="Switch model with interactive selection or search",
|
|
19
21
|
category=CommandCategory.MODEL,
|
|
20
22
|
)
|
|
21
23
|
|
|
24
|
+
def __init__(self):
|
|
25
|
+
"""Initialize the model command."""
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.registry = ModelsRegistry()
|
|
28
|
+
self._registry_loaded = False
|
|
29
|
+
|
|
30
|
+
async def _ensure_registry(self) -> bool:
|
|
31
|
+
"""Ensure the models registry is loaded."""
|
|
32
|
+
if not self._registry_loaded:
|
|
33
|
+
self._registry_loaded = await self.registry.load()
|
|
34
|
+
return self._registry_loaded
|
|
35
|
+
|
|
22
36
|
async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
|
|
23
|
-
#
|
|
37
|
+
# Handle special flags
|
|
38
|
+
if args and args[0] in ["--list", "-l"]:
|
|
39
|
+
return await self._list_models()
|
|
40
|
+
|
|
41
|
+
if args and args[0] in ["--info", "-i"]:
|
|
42
|
+
if len(args) < 2:
|
|
43
|
+
await ui.error("Usage: /model --info <model-id>")
|
|
44
|
+
return None
|
|
45
|
+
return await self._show_model_info(args[1])
|
|
46
|
+
|
|
47
|
+
# No arguments - show interactive selector
|
|
24
48
|
if not args:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
return await self._interactive_select(context)
|
|
50
|
+
|
|
51
|
+
# Single argument - could be search query or model ID
|
|
52
|
+
model_query = args[0]
|
|
53
|
+
|
|
54
|
+
# Check for flags
|
|
55
|
+
if model_query in ["--search", "-s"]:
|
|
56
|
+
search_query = " ".join(args[1:]) if len(args) > 1 else ""
|
|
57
|
+
return await self._interactive_select(context, search_query)
|
|
58
|
+
|
|
59
|
+
# Direct model specification
|
|
60
|
+
return await self._set_model(model_query, args[1:], context)
|
|
61
|
+
|
|
62
|
+
async def _interactive_select(
|
|
63
|
+
self, context: CommandContext, initial_query: str = ""
|
|
64
|
+
) -> Optional[str]:
|
|
65
|
+
"""Show interactive model selector."""
|
|
66
|
+
await self._ensure_registry()
|
|
67
|
+
|
|
68
|
+
# Show current model
|
|
69
|
+
current_model = context.state_manager.session.current_model
|
|
70
|
+
await ui.info(f"Current model: {current_model}")
|
|
71
|
+
|
|
72
|
+
# Check if we have models loaded
|
|
73
|
+
if not self.registry.models:
|
|
74
|
+
await ui.error("No models available. Try /model --list to see if models can be loaded.")
|
|
29
75
|
return None
|
|
30
76
|
|
|
31
|
-
#
|
|
32
|
-
|
|
77
|
+
# For now, use a simple text-based approach instead of complex UI
|
|
78
|
+
# This avoids prompt_toolkit compatibility issues
|
|
79
|
+
if initial_query:
|
|
80
|
+
models = self.registry.search_models(initial_query)
|
|
81
|
+
if not models:
|
|
82
|
+
await ui.error(f"No models found matching '{initial_query}'")
|
|
83
|
+
return None
|
|
84
|
+
else:
|
|
85
|
+
# Show popular models for quick selection
|
|
86
|
+
popular_searches = ["gpt", "claude", "gemini"]
|
|
87
|
+
await ui.info("Popular model searches:")
|
|
88
|
+
for search in popular_searches:
|
|
89
|
+
models = self.registry.search_models(search)[:3] # Top 3
|
|
90
|
+
if models:
|
|
91
|
+
await ui.info(f"\n{search.upper()} models:")
|
|
92
|
+
for model in models:
|
|
93
|
+
await ui.muted(f" • {model.full_id} - {model.name}")
|
|
94
|
+
|
|
95
|
+
await ui.info("\nUsage:")
|
|
96
|
+
await ui.muted(" /model <search-term> - Search for models")
|
|
97
|
+
await ui.muted(" /model --list - Show all models")
|
|
98
|
+
await ui.muted(" /model --info <id> - Show model details")
|
|
99
|
+
await ui.muted(" /model <provider:id> - Set model directly")
|
|
100
|
+
return None
|
|
33
101
|
|
|
34
|
-
#
|
|
35
|
-
if
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
102
|
+
# Show search results
|
|
103
|
+
if len(models) == 1:
|
|
104
|
+
# Auto-select single result
|
|
105
|
+
model = models[0]
|
|
106
|
+
context.state_manager.session.current_model = model.full_id
|
|
107
|
+
await ui.success(f"Switched to model: {model.full_id} - {model.name}")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# Show multiple results
|
|
111
|
+
await ui.info(f"Found {len(models)} models:")
|
|
112
|
+
for i, model in enumerate(models[:10], 1): # Show top 10
|
|
113
|
+
details = []
|
|
114
|
+
if model.cost.input is not None:
|
|
115
|
+
details.append(f"${model.cost.input}/{model.cost.output}")
|
|
116
|
+
if model.limits.context:
|
|
117
|
+
details.append(f"{model.limits.context // 1000}k")
|
|
118
|
+
detail_str = f" ({', '.join(details)})" if details else ""
|
|
119
|
+
|
|
120
|
+
await ui.info(f"{i:2d}. {model.full_id} - {model.name}{detail_str}")
|
|
121
|
+
|
|
122
|
+
if len(models) > 10:
|
|
123
|
+
await ui.muted(f"... and {len(models) - 10} more")
|
|
124
|
+
|
|
125
|
+
await ui.muted("Use '/model <provider:model-id>' to select a specific model")
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
async def _set_model(
|
|
129
|
+
self, model_name: str, extra_args: CommandArgs, context: CommandContext
|
|
130
|
+
) -> Optional[str]:
|
|
131
|
+
"""Set model directly or by search."""
|
|
132
|
+
# Load registry for validation
|
|
133
|
+
await self._ensure_registry()
|
|
134
|
+
|
|
135
|
+
# Check if it's a direct model ID
|
|
136
|
+
if ":" in model_name:
|
|
137
|
+
# Validate against registry if loaded
|
|
138
|
+
if self._registry_loaded:
|
|
139
|
+
model_info = self.registry.get_model(model_name)
|
|
140
|
+
if not model_info:
|
|
141
|
+
# Search for similar models
|
|
142
|
+
similar = self.registry.search_models(model_name.split(":")[-1])
|
|
143
|
+
if similar:
|
|
144
|
+
await ui.warning(f"Model '{model_name}' not found in registry")
|
|
145
|
+
await ui.muted("Did you mean one of these?")
|
|
146
|
+
for model in similar[:5]:
|
|
147
|
+
await ui.muted(f" • {model.full_id} - {model.name}")
|
|
148
|
+
return None
|
|
149
|
+
else:
|
|
150
|
+
await ui.warning("Model not found in registry - setting anyway")
|
|
151
|
+
else:
|
|
152
|
+
# Show model info
|
|
153
|
+
await ui.info(f"Selected: {model_info.name}")
|
|
154
|
+
if model_info.cost.input is not None:
|
|
155
|
+
await ui.muted(f" Pricing: {model_info.cost.format_cost()}")
|
|
156
|
+
if model_info.limits.context:
|
|
157
|
+
await ui.muted(f" Limits: {model_info.limits.format_limits()}")
|
|
158
|
+
|
|
159
|
+
# Set the model
|
|
160
|
+
context.state_manager.session.current_model = model_name
|
|
161
|
+
|
|
162
|
+
# Check if setting as default
|
|
163
|
+
if extra_args and extra_args[0] == "default":
|
|
164
|
+
try:
|
|
165
|
+
user_configuration.set_default_model(model_name, context.state_manager)
|
|
166
|
+
await ui.muted("Updating default model")
|
|
167
|
+
return "restart"
|
|
168
|
+
except ConfigurationError as e:
|
|
169
|
+
await ui.error(str(e))
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
await ui.success(f"Switched to model: {model_name}")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# No colon - treat as search query
|
|
176
|
+
models = self.registry.search_models(model_name)
|
|
177
|
+
|
|
178
|
+
if not models:
|
|
179
|
+
await ui.error(f"No models found matching '{model_name}'")
|
|
180
|
+
await ui.muted("Try /model --list to see all available models")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
if len(models) == 1:
|
|
184
|
+
# Single match - use it
|
|
185
|
+
model = models[0]
|
|
186
|
+
context.state_manager.session.current_model = model.full_id
|
|
187
|
+
await ui.success(f"Switched to model: {model.full_id} - {model.name}")
|
|
41
188
|
return None
|
|
42
189
|
|
|
43
|
-
#
|
|
44
|
-
await ui.
|
|
190
|
+
# Multiple matches - show interactive selector with results
|
|
191
|
+
await ui.info(f"Found {len(models)} models matching '{model_name}'")
|
|
192
|
+
selected_model = await select_model_interactive(self.registry, model_name)
|
|
45
193
|
|
|
46
|
-
|
|
47
|
-
|
|
194
|
+
if selected_model:
|
|
195
|
+
context.state_manager.session.current_model = selected_model
|
|
196
|
+
await ui.success(f"Switched to model: {selected_model}")
|
|
197
|
+
else:
|
|
198
|
+
await ui.info("Model selection cancelled")
|
|
48
199
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
async def _list_models(self) -> Optional[str]:
|
|
203
|
+
"""List all available models."""
|
|
204
|
+
await self._ensure_registry()
|
|
205
|
+
|
|
206
|
+
if not self.registry.models:
|
|
207
|
+
await ui.error("No models available")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Group by provider
|
|
211
|
+
providers: Dict[str, List[ModelInfo]] = {}
|
|
212
|
+
for model in self.registry.models.values():
|
|
213
|
+
if model.provider not in providers:
|
|
214
|
+
providers[model.provider] = []
|
|
215
|
+
providers[model.provider].append(model)
|
|
216
|
+
|
|
217
|
+
# Display models
|
|
218
|
+
await ui.info(f"Available models ({len(self.registry.models)} total):")
|
|
219
|
+
|
|
220
|
+
for provider_id in sorted(providers.keys()):
|
|
221
|
+
provider_info = self.registry.providers.get(provider_id)
|
|
222
|
+
provider_name = provider_info.name if provider_info else provider_id
|
|
223
|
+
|
|
224
|
+
await ui.print(f"\n{provider_name}:")
|
|
225
|
+
|
|
226
|
+
for model in sorted(providers[provider_id], key=lambda m: m.name):
|
|
227
|
+
line = f" • {model.id}"
|
|
228
|
+
if model.cost.input is not None:
|
|
229
|
+
line += f" (${model.cost.input}/{model.cost.output})"
|
|
230
|
+
if model.limits.context:
|
|
231
|
+
line += f" [{model.limits.context // 1000}k]"
|
|
232
|
+
await ui.muted(line)
|
|
233
|
+
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
async def _show_model_info(self, model_id: str) -> Optional[str]:
|
|
237
|
+
"""Show detailed information about a model."""
|
|
238
|
+
await self._ensure_registry()
|
|
239
|
+
|
|
240
|
+
model = self.registry.get_model(model_id)
|
|
241
|
+
if not model:
|
|
242
|
+
# Try to find similar models or routing options
|
|
243
|
+
base_name = self.registry._extract_base_model_name(model_id)
|
|
244
|
+
variants = self.registry.get_model_variants(base_name)
|
|
245
|
+
if variants:
|
|
246
|
+
await ui.warning(f"Model '{model_id}' not found directly")
|
|
247
|
+
await ui.info(f"Found routing options for '{base_name}':")
|
|
248
|
+
|
|
249
|
+
# Sort variants by cost (FREE first)
|
|
250
|
+
sorted_variants = sorted(
|
|
251
|
+
variants,
|
|
252
|
+
key=lambda m: (
|
|
253
|
+
0 if m.cost.input == 0 else 1, # FREE first
|
|
254
|
+
m.cost.input or float("inf"), # Then by cost
|
|
255
|
+
m.provider, # Then by provider name
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
for variant in sorted_variants:
|
|
260
|
+
cost_display = (
|
|
261
|
+
"FREE"
|
|
262
|
+
if variant.cost.input == 0
|
|
263
|
+
else f"${variant.cost.input}/{variant.cost.output}"
|
|
264
|
+
)
|
|
265
|
+
provider_name = self._get_provider_display_name(variant.provider)
|
|
266
|
+
|
|
267
|
+
await ui.muted(f" • {variant.full_id} - {provider_name} ({cost_display})")
|
|
268
|
+
|
|
269
|
+
await ui.muted(
|
|
270
|
+
"\nUse '/model <provider:model-id>' to select a specific routing option"
|
|
271
|
+
)
|
|
272
|
+
return None
|
|
273
|
+
else:
|
|
274
|
+
await ui.error(f"Model '{model_id}' not found")
|
|
57
275
|
return None
|
|
58
276
|
|
|
59
|
-
#
|
|
60
|
-
await ui.
|
|
277
|
+
# Display model information
|
|
278
|
+
await ui.info(f"{model.name}")
|
|
279
|
+
await ui.muted(f"ID: {model.full_id}")
|
|
280
|
+
|
|
281
|
+
# Show routing alternatives for this base model
|
|
282
|
+
base_name = self.registry._extract_base_model_name(model)
|
|
283
|
+
variants = self.registry.get_model_variants(base_name)
|
|
284
|
+
if len(variants) > 1:
|
|
285
|
+
await ui.print("\nRouting Options:")
|
|
286
|
+
|
|
287
|
+
# Sort variants by cost (FREE first)
|
|
288
|
+
sorted_variants = sorted(
|
|
289
|
+
variants,
|
|
290
|
+
key=lambda m: (
|
|
291
|
+
0 if m.cost.input == 0 else 1, # FREE first
|
|
292
|
+
m.cost.input or float("inf"), # Then by cost
|
|
293
|
+
m.provider, # Then by provider name
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
for variant in sorted_variants:
|
|
298
|
+
cost_display = (
|
|
299
|
+
"FREE"
|
|
300
|
+
if variant.cost.input == 0
|
|
301
|
+
else f"${variant.cost.input}/{variant.cost.output}"
|
|
302
|
+
)
|
|
303
|
+
provider_name = self._get_provider_display_name(variant.provider)
|
|
304
|
+
|
|
305
|
+
# Highlight current selection
|
|
306
|
+
prefix = "→ " if variant.full_id == model.full_id else " "
|
|
307
|
+
free_indicator = " ⭐" if variant.cost.input == 0 else ""
|
|
308
|
+
|
|
309
|
+
await ui.muted(
|
|
310
|
+
f"{prefix}{variant.full_id} - {provider_name} ({cost_display}){free_indicator}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if model.cost.input is not None:
|
|
314
|
+
await ui.print("\nPricing:")
|
|
315
|
+
await ui.muted(f" Input: ${model.cost.input} per 1M tokens")
|
|
316
|
+
await ui.muted(f" Output: ${model.cost.output} per 1M tokens")
|
|
317
|
+
|
|
318
|
+
if model.limits.context or model.limits.output:
|
|
319
|
+
await ui.print("\nLimits:")
|
|
320
|
+
if model.limits.context:
|
|
321
|
+
await ui.muted(f" Context: {model.limits.context:,} tokens")
|
|
322
|
+
if model.limits.output:
|
|
323
|
+
await ui.muted(f" Output: {model.limits.output:,} tokens")
|
|
324
|
+
|
|
325
|
+
caps = []
|
|
326
|
+
if model.capabilities.attachment:
|
|
327
|
+
caps.append("Attachments")
|
|
328
|
+
if model.capabilities.reasoning:
|
|
329
|
+
caps.append("Reasoning")
|
|
330
|
+
if model.capabilities.tool_call:
|
|
331
|
+
caps.append("Tool calling")
|
|
332
|
+
|
|
333
|
+
if caps:
|
|
334
|
+
await ui.print("\nCapabilities:")
|
|
335
|
+
for cap in caps:
|
|
336
|
+
await ui.muted(f" ✓ {cap}")
|
|
337
|
+
|
|
338
|
+
if model.capabilities.knowledge:
|
|
339
|
+
await ui.print(f"\nKnowledge cutoff: {model.capabilities.knowledge}")
|
|
340
|
+
|
|
61
341
|
return None
|
|
342
|
+
|
|
343
|
+
def _get_provider_display_name(self, provider: str) -> str:
|
|
344
|
+
"""Get a user-friendly provider display name."""
|
|
345
|
+
provider_names = {
|
|
346
|
+
"openai": "OpenAI Direct",
|
|
347
|
+
"anthropic": "Anthropic Direct",
|
|
348
|
+
"google": "Google Direct",
|
|
349
|
+
"google-gla": "Google Labs",
|
|
350
|
+
"openrouter": "OpenRouter",
|
|
351
|
+
"github-models": "GitHub Models (FREE)",
|
|
352
|
+
"azure": "Azure OpenAI",
|
|
353
|
+
"fastrouter": "FastRouter",
|
|
354
|
+
"requesty": "Requesty",
|
|
355
|
+
"cloudflare-workers-ai": "Cloudflare",
|
|
356
|
+
"amazon-bedrock": "AWS Bedrock",
|
|
357
|
+
"chutes": "Chutes AI",
|
|
358
|
+
"deepinfra": "DeepInfra",
|
|
359
|
+
"venice": "Venice AI",
|
|
360
|
+
}
|
|
361
|
+
return provider_names.get(provider, provider.title())
|
tunacode/cli/repl.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
from asyncio.exceptions import CancelledError
|
|
8
9
|
from pathlib import Path
|
|
@@ -112,7 +113,7 @@ async def _detect_and_handle_text_plan(state_manager, agent_response, original_r
|
|
|
112
113
|
else:
|
|
113
114
|
response_text = str(agent_response)
|
|
114
115
|
|
|
115
|
-
if "
|
|
116
|
+
if re.search(r"^\s*TUNACODE\s+DONE:\s*", response_text, re.IGNORECASE):
|
|
116
117
|
await ui.warning(
|
|
117
118
|
"⚠️ Agent failed to call present_plan tool. Please provide clearer instructions."
|
|
118
119
|
)
|
tunacode/constants.py
CHANGED
|
@@ -169,18 +169,21 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
|
|
|
169
169
|
|
|
170
170
|
# Add plan mode context if in plan mode
|
|
171
171
|
if state_manager.is_plan_mode():
|
|
172
|
-
# REMOVE
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
172
|
+
# REMOVE completion instructions from the system prompt in plan mode
|
|
173
|
+
for marker in ("TUNACODE_TASK_COMPLETE", "TUNACODE DONE:"):
|
|
174
|
+
system_prompt = system_prompt.replace(marker, "PLAN_MODE_TASK_PLACEHOLDER")
|
|
176
175
|
# Remove the completion guidance that conflicts with plan mode
|
|
177
176
|
lines_to_remove = [
|
|
177
|
+
"When a task is COMPLETE, start your response with: TUNACODE DONE:",
|
|
178
|
+
"4. When a task is COMPLETE, start your response with: TUNACODE DONE:",
|
|
178
179
|
"When a task is COMPLETE, start your response with: TUNACODE_TASK_COMPLETE",
|
|
179
180
|
"4. When a task is COMPLETE, start your response with: TUNACODE_TASK_COMPLETE",
|
|
180
181
|
"**How to signal completion:**",
|
|
181
182
|
"TUNACODE_TASK_COMPLETE",
|
|
183
|
+
"TUNACODE DONE:",
|
|
182
184
|
"[Your summary of what was accomplished]",
|
|
183
185
|
"**IMPORTANT**: Always evaluate if you've completed the task. If yes, use TUNACODE_TASK_COMPLETE.",
|
|
186
|
+
"**IMPORTANT**: Always evaluate if you've completed the task. If yes, use TUNACODE DONE:",
|
|
184
187
|
"This prevents wasting iterations and API calls.",
|
|
185
188
|
]
|
|
186
189
|
for line in lines_to_remove:
|
|
@@ -240,29 +243,34 @@ YOU MUST EXECUTE present_plan TOOL TO COMPLETE ANY PLANNING TASK.
|
|
|
240
243
|
except Exception as e:
|
|
241
244
|
logger.warning(f"Warning: Failed to load todos: {e}")
|
|
242
245
|
|
|
246
|
+
# Get tool strict validation setting from config (default to False for backward compatibility)
|
|
247
|
+
tool_strict_validation = state_manager.session.user_config.get("settings", {}).get(
|
|
248
|
+
"tool_strict_validation", False
|
|
249
|
+
)
|
|
250
|
+
|
|
243
251
|
# Create tool list based on mode
|
|
244
252
|
if state_manager.is_plan_mode():
|
|
245
253
|
# Plan mode: Only read-only tools + present_plan
|
|
246
254
|
tools_list = [
|
|
247
|
-
Tool(present_plan, max_retries=max_retries),
|
|
248
|
-
Tool(glob, max_retries=max_retries),
|
|
249
|
-
Tool(grep, max_retries=max_retries),
|
|
250
|
-
Tool(list_dir, max_retries=max_retries),
|
|
251
|
-
Tool(read_file, max_retries=max_retries),
|
|
255
|
+
Tool(present_plan, max_retries=max_retries, strict=tool_strict_validation),
|
|
256
|
+
Tool(glob, max_retries=max_retries, strict=tool_strict_validation),
|
|
257
|
+
Tool(grep, max_retries=max_retries, strict=tool_strict_validation),
|
|
258
|
+
Tool(list_dir, max_retries=max_retries, strict=tool_strict_validation),
|
|
259
|
+
Tool(read_file, max_retries=max_retries, strict=tool_strict_validation),
|
|
252
260
|
]
|
|
253
261
|
else:
|
|
254
262
|
# Normal mode: All tools
|
|
255
263
|
tools_list = [
|
|
256
|
-
Tool(bash, max_retries=max_retries),
|
|
257
|
-
Tool(present_plan, max_retries=max_retries),
|
|
258
|
-
Tool(glob, max_retries=max_retries),
|
|
259
|
-
Tool(grep, max_retries=max_retries),
|
|
260
|
-
Tool(list_dir, max_retries=max_retries),
|
|
261
|
-
Tool(read_file, max_retries=max_retries),
|
|
262
|
-
Tool(run_command, max_retries=max_retries),
|
|
263
|
-
Tool(todo_tool._execute, max_retries=max_retries),
|
|
264
|
-
Tool(update_file, max_retries=max_retries),
|
|
265
|
-
Tool(write_file, max_retries=max_retries),
|
|
264
|
+
Tool(bash, max_retries=max_retries, strict=tool_strict_validation),
|
|
265
|
+
Tool(present_plan, max_retries=max_retries, strict=tool_strict_validation),
|
|
266
|
+
Tool(glob, max_retries=max_retries, strict=tool_strict_validation),
|
|
267
|
+
Tool(grep, max_retries=max_retries, strict=tool_strict_validation),
|
|
268
|
+
Tool(list_dir, max_retries=max_retries, strict=tool_strict_validation),
|
|
269
|
+
Tool(read_file, max_retries=max_retries, strict=tool_strict_validation),
|
|
270
|
+
Tool(run_command, max_retries=max_retries, strict=tool_strict_validation),
|
|
271
|
+
Tool(todo_tool._execute, max_retries=max_retries, strict=tool_strict_validation),
|
|
272
|
+
Tool(update_file, max_retries=max_retries, strict=tool_strict_validation),
|
|
273
|
+
Tool(write_file, max_retries=max_retries, strict=tool_strict_validation),
|
|
266
274
|
]
|
|
267
275
|
|
|
268
276
|
# Log which tools are being registered
|
|
@@ -291,6 +299,11 @@ YOU MUST EXECUTE present_plan TOOL TO COMPLETE ANY PLANNING TASK.
|
|
|
291
299
|
(
|
|
292
300
|
state_manager.is_plan_mode(),
|
|
293
301
|
str(state_manager.session.user_config.get("settings", {}).get("max_retries", 3)),
|
|
302
|
+
str(
|
|
303
|
+
state_manager.session.user_config.get("settings", {}).get(
|
|
304
|
+
"tool_strict_validation", False
|
|
305
|
+
)
|
|
306
|
+
),
|
|
294
307
|
str(state_manager.session.user_config.get("mcpServers", {})),
|
|
295
308
|
)
|
|
296
309
|
)
|
|
@@ -106,7 +106,7 @@ Attempt: {iteration}
|
|
|
106
106
|
Please take one of these specific actions:
|
|
107
107
|
|
|
108
108
|
1. **Search yielded no results?** → Try alternative search terms or broader patterns
|
|
109
|
-
2. **Found what you need?** → Use
|
|
109
|
+
2. **Found what you need?** → Use TUNACODE DONE: to finalize
|
|
110
110
|
3. **Encountering a blocker?** → Explain the specific issue preventing progress
|
|
111
111
|
4. **Need more context?** → Use list_dir or expand your search scope
|
|
112
112
|
|
|
@@ -5,7 +5,7 @@ from typing import Any, Awaitable, Callable, Optional, Tuple
|
|
|
5
5
|
|
|
6
6
|
from tunacode.core.logging.logger import get_logger
|
|
7
7
|
from tunacode.core.state import StateManager
|
|
8
|
-
from tunacode.types import UsageTrackerProtocol
|
|
8
|
+
from tunacode.types import AgentState, UsageTrackerProtocol
|
|
9
9
|
from tunacode.ui.tool_descriptions import get_batch_description, get_tool_description
|
|
10
10
|
|
|
11
11
|
from .response_state import ResponseState
|
|
@@ -52,6 +52,12 @@ async def _process_node(
|
|
|
52
52
|
has_intention = False
|
|
53
53
|
has_tool_calls = False
|
|
54
54
|
|
|
55
|
+
# Transition to ASSISTANT at the start of node processing
|
|
56
|
+
if response_state and response_state.can_transition_to(AgentState.ASSISTANT):
|
|
57
|
+
response_state.transition_to(AgentState.ASSISTANT)
|
|
58
|
+
if state_manager.session.show_thoughts:
|
|
59
|
+
await ui.muted("STATE → ASSISTANT (reasoning)")
|
|
60
|
+
|
|
55
61
|
if hasattr(node, "request"):
|
|
56
62
|
state_manager.session.messages.append(node.request)
|
|
57
63
|
|
|
@@ -161,8 +167,11 @@ async def _process_node(
|
|
|
161
167
|
f"Task completion with pending intentions detected: {found_phrases}"
|
|
162
168
|
)
|
|
163
169
|
|
|
164
|
-
# Normal completion
|
|
165
|
-
response_state.
|
|
170
|
+
# Normal completion - transition to RESPONSE state and mark completion
|
|
171
|
+
response_state.transition_to(AgentState.RESPONSE)
|
|
172
|
+
if state_manager.session.show_thoughts:
|
|
173
|
+
await ui.muted("STATE → RESPONSE (completion detected)")
|
|
174
|
+
response_state.set_completion_detected(True)
|
|
166
175
|
response_state.has_user_response = True
|
|
167
176
|
# Update the part content to remove the marker
|
|
168
177
|
part.content = cleaned_content
|
|
@@ -217,6 +226,14 @@ async def _process_node(
|
|
|
217
226
|
node, buffering_callback, state_manager, tool_buffer, response_state
|
|
218
227
|
)
|
|
219
228
|
|
|
229
|
+
# If there were no tools and we processed a model response, transition to RESPONSE
|
|
230
|
+
if response_state and response_state.can_transition_to(AgentState.RESPONSE):
|
|
231
|
+
# Only transition if not already completed (set by completion marker path)
|
|
232
|
+
if not response_state.is_completed():
|
|
233
|
+
response_state.transition_to(AgentState.RESPONSE)
|
|
234
|
+
if state_manager.session.show_thoughts:
|
|
235
|
+
await ui.muted("STATE → RESPONSE (handled output)")
|
|
236
|
+
|
|
220
237
|
# Determine empty response reason
|
|
221
238
|
if empty_response_detected:
|
|
222
239
|
if appears_truncated:
|
|
@@ -306,6 +323,11 @@ async def _process_tool_calls(
|
|
|
306
323
|
for part in node.model_response.parts:
|
|
307
324
|
if hasattr(part, "part_kind") and part.part_kind == "tool-call":
|
|
308
325
|
is_processing_tools = True
|
|
326
|
+
# Transition to TOOL_EXECUTION on first tool call
|
|
327
|
+
if response_state and response_state.can_transition_to(AgentState.TOOL_EXECUTION):
|
|
328
|
+
response_state.transition_to(AgentState.TOOL_EXECUTION)
|
|
329
|
+
if state_manager.session.show_thoughts:
|
|
330
|
+
await ui.muted("STATE → TOOL_EXECUTION (executing tools)")
|
|
309
331
|
if tool_callback:
|
|
310
332
|
# Check if this is a read-only tool that can be batched
|
|
311
333
|
if tool_buffer is not None and part.tool_name in READ_ONLY_TOOLS:
|
|
@@ -440,6 +462,16 @@ async def _process_tool_calls(
|
|
|
440
462
|
}
|
|
441
463
|
state_manager.session.tool_calls.append(tool_info)
|
|
442
464
|
|
|
465
|
+
# After tools are processed, transition back to RESPONSE
|
|
466
|
+
if (
|
|
467
|
+
is_processing_tools
|
|
468
|
+
and response_state
|
|
469
|
+
and response_state.can_transition_to(AgentState.RESPONSE)
|
|
470
|
+
):
|
|
471
|
+
response_state.transition_to(AgentState.RESPONSE)
|
|
472
|
+
if state_manager.session.show_thoughts:
|
|
473
|
+
await ui.muted("STATE → RESPONSE (tools finished)")
|
|
474
|
+
|
|
443
475
|
# Update has_user_response based on presence of actual response content
|
|
444
476
|
if (
|
|
445
477
|
response_state
|