tunacode-cli 0.0.76__py3-none-any.whl → 0.0.76.1__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/main.py +10 -0
- tunacode/cli/repl.py +17 -1
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +275 -0
- tunacode/constants.py +1 -1
- tunacode/core/agents/agent_components/node_processor.py +24 -3
- tunacode/core/agents/agent_components/streaming.py +268 -0
- tunacode/core/agents/main.py +15 -46
- tunacode/core/setup/config_wizard.py +2 -1
- tunacode/ui/config_dashboard.py +567 -0
- tunacode/ui/panels.py +92 -9
- tunacode/utils/config_comparator.py +340 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.1.dist-info}/METADATA +63 -6
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.1.dist-info}/RECORD +17 -13
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.1.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.1.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.76.dist-info → tunacode_cli-0.0.76.1.dist-info}/licenses/LICENSE +0 -0
tunacode/cli/main.py
CHANGED
|
@@ -30,6 +30,9 @@ def main(
|
|
|
30
30
|
wizard: bool = typer.Option(
|
|
31
31
|
False, "--wizard", help="Run interactive setup wizard for guided configuration."
|
|
32
32
|
),
|
|
33
|
+
show_config: bool = typer.Option(
|
|
34
|
+
False, "--show-config", help="Show configuration dashboard and exit."
|
|
35
|
+
),
|
|
33
36
|
baseurl: str = typer.Option(
|
|
34
37
|
None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"
|
|
35
38
|
),
|
|
@@ -49,6 +52,13 @@ def main(
|
|
|
49
52
|
await ui.version()
|
|
50
53
|
return
|
|
51
54
|
|
|
55
|
+
if show_config:
|
|
56
|
+
from tunacode.ui.config_dashboard import show_config_dashboard
|
|
57
|
+
|
|
58
|
+
await ui.banner()
|
|
59
|
+
show_config_dashboard()
|
|
60
|
+
return
|
|
61
|
+
|
|
52
62
|
await ui.banner()
|
|
53
63
|
|
|
54
64
|
# Start update check in background
|
tunacode/cli/repl.py
CHANGED
|
@@ -320,7 +320,9 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
320
320
|
if enable_streaming:
|
|
321
321
|
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
322
322
|
state_manager.session.is_streaming_active = True
|
|
323
|
-
streaming_panel = ui.StreamingAgentPanel(
|
|
323
|
+
streaming_panel = ui.StreamingAgentPanel(
|
|
324
|
+
debug=bool(state_manager.session.show_thoughts)
|
|
325
|
+
)
|
|
324
326
|
await streaming_panel.start()
|
|
325
327
|
state_manager.session.streaming_panel = streaming_panel
|
|
326
328
|
|
|
@@ -337,6 +339,20 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
337
339
|
await streaming_panel.stop()
|
|
338
340
|
state_manager.session.streaming_panel = None
|
|
339
341
|
state_manager.session.is_streaming_active = False
|
|
342
|
+
# Emit source-side streaming diagnostics if thoughts are enabled
|
|
343
|
+
if state_manager.session.show_thoughts:
|
|
344
|
+
try:
|
|
345
|
+
raw = getattr(state_manager.session, "_debug_raw_stream_accum", "") or ""
|
|
346
|
+
events = getattr(state_manager.session, "_debug_events", []) or []
|
|
347
|
+
raw_first5 = repr(raw[:5])
|
|
348
|
+
await ui.muted(
|
|
349
|
+
f"[debug] raw_stream_first5={raw_first5} total_len={len(raw)}"
|
|
350
|
+
)
|
|
351
|
+
for line in events:
|
|
352
|
+
await ui.muted(line)
|
|
353
|
+
except Exception:
|
|
354
|
+
# Don't let diagnostics break normal flow
|
|
355
|
+
pass
|
|
340
356
|
else:
|
|
341
357
|
res = await agent.process_request(
|
|
342
358
|
text,
|
|
@@ -5,7 +5,7 @@ Default configuration values for the TunaCode CLI.
|
|
|
5
5
|
Provides sensible defaults for user configuration and environment variables.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from tunacode.constants import GUIDE_FILE_NAME
|
|
8
|
+
from tunacode.constants import GUIDE_FILE_NAME
|
|
9
9
|
from tunacode.types import UserConfig
|
|
10
10
|
|
|
11
11
|
DEFAULT_USER_CONFIG: UserConfig = {
|
|
@@ -19,7 +19,7 @@ DEFAULT_USER_CONFIG: UserConfig = {
|
|
|
19
19
|
"settings": {
|
|
20
20
|
"max_retries": 10,
|
|
21
21
|
"max_iterations": 40,
|
|
22
|
-
"tool_ignore": [
|
|
22
|
+
"tool_ignore": [],
|
|
23
23
|
"guide_file": GUIDE_FILE_NAME,
|
|
24
24
|
"fallback_response": True,
|
|
25
25
|
"fallback_verbosity": "normal", # Options: minimal, normal, detailed
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.configuration.key_descriptions
|
|
3
|
+
|
|
4
|
+
Educational descriptions and examples for configuration keys to help users
|
|
5
|
+
understand what each setting does and how to configure it properly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class KeyDescription:
|
|
14
|
+
"""Description of a configuration key with examples and help text."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
example: Any
|
|
19
|
+
help_text: str
|
|
20
|
+
category: str
|
|
21
|
+
is_sensitive: bool = False
|
|
22
|
+
service_type: Optional[str] = None # For API keys: "openai", "anthropic", etc.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Configuration key descriptions organized by category
|
|
26
|
+
CONFIG_KEY_DESCRIPTIONS: Dict[str, KeyDescription] = {
|
|
27
|
+
# Root level keys
|
|
28
|
+
"default_model": KeyDescription(
|
|
29
|
+
name="default_model",
|
|
30
|
+
description="Which AI model TunaCode uses by default",
|
|
31
|
+
example="openrouter:openai/gpt-4.1",
|
|
32
|
+
help_text="Format: provider:model-name. Examples: openai:gpt-4, anthropic:claude-3-sonnet, google:gemini-pro",
|
|
33
|
+
category="AI Models",
|
|
34
|
+
),
|
|
35
|
+
"skip_git_safety": KeyDescription(
|
|
36
|
+
name="skip_git_safety",
|
|
37
|
+
description="Skip Git safety checks when making changes",
|
|
38
|
+
example=True,
|
|
39
|
+
help_text="When true, TunaCode won't create safety branches before making changes. Use with caution!",
|
|
40
|
+
category="Safety Settings",
|
|
41
|
+
),
|
|
42
|
+
# Environment variables (API Keys)
|
|
43
|
+
"env.OPENAI_API_KEY": KeyDescription(
|
|
44
|
+
name="OPENAI_API_KEY",
|
|
45
|
+
description="Your OpenAI API key for GPT models",
|
|
46
|
+
example="sk-proj-abc123...",
|
|
47
|
+
help_text="Get this from https://platform.openai.com/api-keys. Required for OpenAI models like GPT-4.",
|
|
48
|
+
category="API Keys",
|
|
49
|
+
is_sensitive=True,
|
|
50
|
+
service_type="openai",
|
|
51
|
+
),
|
|
52
|
+
"env.ANTHROPIC_API_KEY": KeyDescription(
|
|
53
|
+
name="ANTHROPIC_API_KEY",
|
|
54
|
+
description="Your Anthropic API key for Claude models",
|
|
55
|
+
example="sk-ant-api03-abc123...",
|
|
56
|
+
help_text="Get this from https://console.anthropic.com/. Required for Claude models.",
|
|
57
|
+
category="API Keys",
|
|
58
|
+
is_sensitive=True,
|
|
59
|
+
service_type="anthropic",
|
|
60
|
+
),
|
|
61
|
+
"env.OPENROUTER_API_KEY": KeyDescription(
|
|
62
|
+
name="OPENROUTER_API_KEY",
|
|
63
|
+
description="Your OpenRouter API key for accessing multiple models",
|
|
64
|
+
example="sk-or-v1-abc123...",
|
|
65
|
+
help_text="Get this from https://openrouter.ai/keys. Gives access to many different AI models.",
|
|
66
|
+
category="API Keys",
|
|
67
|
+
is_sensitive=True,
|
|
68
|
+
service_type="openrouter",
|
|
69
|
+
),
|
|
70
|
+
"env.GEMINI_API_KEY": KeyDescription(
|
|
71
|
+
name="GEMINI_API_KEY",
|
|
72
|
+
description="Your Google Gemini API key",
|
|
73
|
+
example="AIza123...",
|
|
74
|
+
help_text="Get this from Google AI Studio. Required for Gemini models.",
|
|
75
|
+
category="API Keys",
|
|
76
|
+
is_sensitive=True,
|
|
77
|
+
service_type="google",
|
|
78
|
+
),
|
|
79
|
+
"env.OPENAI_BASE_URL": KeyDescription(
|
|
80
|
+
name="OPENAI_BASE_URL",
|
|
81
|
+
description="Custom API endpoint for OpenAI-compatible services",
|
|
82
|
+
example="https://api.cerebras.ai/v1",
|
|
83
|
+
help_text="Use this to connect to local models (LM Studio, Ollama) or alternative providers like Cerebras.",
|
|
84
|
+
category="API Configuration",
|
|
85
|
+
),
|
|
86
|
+
# Settings
|
|
87
|
+
"settings.max_retries": KeyDescription(
|
|
88
|
+
name="max_retries",
|
|
89
|
+
description="How many times to retry failed API calls",
|
|
90
|
+
example=10,
|
|
91
|
+
help_text="Higher values = more resilient to temporary API issues, but slower when APIs are down.",
|
|
92
|
+
category="Behavior Settings",
|
|
93
|
+
),
|
|
94
|
+
"settings.max_iterations": KeyDescription(
|
|
95
|
+
name="max_iterations",
|
|
96
|
+
description="Maximum conversation turns before stopping",
|
|
97
|
+
example=40,
|
|
98
|
+
help_text="Prevents infinite loops. TunaCode will stop after this many back-and-forth exchanges.",
|
|
99
|
+
category="Behavior Settings",
|
|
100
|
+
),
|
|
101
|
+
"settings.tool_ignore": KeyDescription(
|
|
102
|
+
name="tool_ignore",
|
|
103
|
+
description="List of tools TunaCode should not use",
|
|
104
|
+
example=["read_file", "write_file"],
|
|
105
|
+
help_text="Useful for restricting what TunaCode can do. Empty list means all tools are available.",
|
|
106
|
+
category="Tool Configuration",
|
|
107
|
+
),
|
|
108
|
+
"settings.guide_file": KeyDescription(
|
|
109
|
+
name="guide_file",
|
|
110
|
+
description="Name of your project guide file",
|
|
111
|
+
example="TUNACODE.md",
|
|
112
|
+
help_text="TunaCode looks for this file to understand your project. Usually TUNACODE.md or README.md.",
|
|
113
|
+
category="Project Settings",
|
|
114
|
+
),
|
|
115
|
+
"settings.fallback_response": KeyDescription(
|
|
116
|
+
name="fallback_response",
|
|
117
|
+
description="Whether to provide a response when tools fail",
|
|
118
|
+
example=True,
|
|
119
|
+
help_text="When true, TunaCode will try to help even if some tools don't work properly.",
|
|
120
|
+
category="Behavior Settings",
|
|
121
|
+
),
|
|
122
|
+
"settings.fallback_verbosity": KeyDescription(
|
|
123
|
+
name="fallback_verbosity",
|
|
124
|
+
description="How detailed fallback responses should be",
|
|
125
|
+
example="normal",
|
|
126
|
+
help_text="Options: minimal, normal, detailed. Controls how much TunaCode explains when things go wrong.",
|
|
127
|
+
category="Behavior Settings",
|
|
128
|
+
),
|
|
129
|
+
"settings.context_window_size": KeyDescription(
|
|
130
|
+
name="context_window_size",
|
|
131
|
+
description="Maximum tokens TunaCode can use in one conversation",
|
|
132
|
+
example=200000,
|
|
133
|
+
help_text="Larger values = TunaCode remembers more context, but costs more. Adjust based on your model's limits.",
|
|
134
|
+
category="Performance Settings",
|
|
135
|
+
),
|
|
136
|
+
"settings.enable_streaming": KeyDescription(
|
|
137
|
+
name="enable_streaming",
|
|
138
|
+
description="Show AI responses as they're generated",
|
|
139
|
+
example=True,
|
|
140
|
+
help_text="When true, you see responses appear word-by-word. When false, you wait for complete responses.",
|
|
141
|
+
category="User Experience",
|
|
142
|
+
),
|
|
143
|
+
# Ripgrep settings
|
|
144
|
+
"settings.ripgrep.use_bundled": KeyDescription(
|
|
145
|
+
name="ripgrep.use_bundled",
|
|
146
|
+
description="Use TunaCode's built-in ripgrep instead of system version",
|
|
147
|
+
example=False,
|
|
148
|
+
help_text="Usually false is better - uses your system's ripgrep which may be newer/faster.",
|
|
149
|
+
category="Search Settings",
|
|
150
|
+
),
|
|
151
|
+
"settings.ripgrep.timeout": KeyDescription(
|
|
152
|
+
name="ripgrep.timeout",
|
|
153
|
+
description="How long to wait for search results (seconds)",
|
|
154
|
+
example=10,
|
|
155
|
+
help_text="Prevents searches from hanging. Increase for very large codebases.",
|
|
156
|
+
category="Search Settings",
|
|
157
|
+
),
|
|
158
|
+
"settings.ripgrep.max_buffer_size": KeyDescription(
|
|
159
|
+
name="ripgrep.max_buffer_size",
|
|
160
|
+
description="Maximum size of search results (bytes)",
|
|
161
|
+
example=1048576,
|
|
162
|
+
help_text="1MB by default. Prevents memory issues with huge search results.",
|
|
163
|
+
category="Search Settings",
|
|
164
|
+
),
|
|
165
|
+
"settings.ripgrep.max_results": KeyDescription(
|
|
166
|
+
name="ripgrep.max_results",
|
|
167
|
+
description="Maximum number of search results to return",
|
|
168
|
+
example=100,
|
|
169
|
+
help_text="Prevents overwhelming output. Increase if you need more comprehensive search results.",
|
|
170
|
+
category="Search Settings",
|
|
171
|
+
),
|
|
172
|
+
"settings.ripgrep.enable_metrics": KeyDescription(
|
|
173
|
+
name="ripgrep.enable_metrics",
|
|
174
|
+
description="Collect performance data about searches",
|
|
175
|
+
example=False,
|
|
176
|
+
help_text="Enable for debugging search performance. Usually not needed.",
|
|
177
|
+
category="Search Settings",
|
|
178
|
+
),
|
|
179
|
+
"settings.ripgrep.debug": KeyDescription(
|
|
180
|
+
name="ripgrep.debug",
|
|
181
|
+
description="Show detailed search debugging information",
|
|
182
|
+
example=False,
|
|
183
|
+
help_text="Enable for troubleshooting search issues. Creates verbose output.",
|
|
184
|
+
category="Search Settings",
|
|
185
|
+
),
|
|
186
|
+
# Tutorial/onboarding settings
|
|
187
|
+
"settings.enable_tutorial": KeyDescription(
|
|
188
|
+
name="enable_tutorial",
|
|
189
|
+
description="Show tutorial prompts for new users",
|
|
190
|
+
example=True,
|
|
191
|
+
help_text="Helps new users learn TunaCode. Disable once you're comfortable with the tool.",
|
|
192
|
+
category="User Experience",
|
|
193
|
+
),
|
|
194
|
+
"settings.first_installation_date": KeyDescription(
|
|
195
|
+
name="first_installation_date",
|
|
196
|
+
description="When TunaCode was first installed",
|
|
197
|
+
example="2025-09-11T11:50:40.167105",
|
|
198
|
+
help_text="Automatically set. Used for tracking usage patterns and showing relevant tips.",
|
|
199
|
+
category="System Information",
|
|
200
|
+
),
|
|
201
|
+
"settings.tutorial_declined": KeyDescription(
|
|
202
|
+
name="tutorial_declined",
|
|
203
|
+
description="Whether user declined the tutorial",
|
|
204
|
+
example=True,
|
|
205
|
+
help_text="Automatically set when you skip the tutorial. Prevents repeated tutorial prompts.",
|
|
206
|
+
category="User Experience",
|
|
207
|
+
),
|
|
208
|
+
# MCP Servers
|
|
209
|
+
"mcpServers": KeyDescription(
|
|
210
|
+
name="mcpServers",
|
|
211
|
+
description="Model Context Protocol server configurations",
|
|
212
|
+
example={},
|
|
213
|
+
help_text="Advanced feature for connecting external tools and services. Usually empty for basic usage.",
|
|
214
|
+
category="Advanced Features",
|
|
215
|
+
),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_key_description(key_path: str) -> Optional[KeyDescription]:
|
|
220
|
+
"""Get description for a configuration key by its path."""
|
|
221
|
+
return CONFIG_KEY_DESCRIPTIONS.get(key_path)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_service_type_for_api_key(key_name: str) -> Optional[str]:
|
|
225
|
+
"""Determine the service type for an API key."""
|
|
226
|
+
service_mapping = {
|
|
227
|
+
"OPENAI_API_KEY": "openai",
|
|
228
|
+
"ANTHROPIC_API_KEY": "anthropic",
|
|
229
|
+
"OPENROUTER_API_KEY": "openrouter",
|
|
230
|
+
"GEMINI_API_KEY": "google",
|
|
231
|
+
}
|
|
232
|
+
return service_mapping.get(key_name)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_categories() -> Dict[str, list[KeyDescription]]:
|
|
236
|
+
"""Get all configuration keys organized by category."""
|
|
237
|
+
categories: Dict[str, list[KeyDescription]] = {}
|
|
238
|
+
|
|
239
|
+
for desc in CONFIG_KEY_DESCRIPTIONS.values():
|
|
240
|
+
if desc.category not in categories:
|
|
241
|
+
categories[desc.category] = []
|
|
242
|
+
categories[desc.category].append(desc)
|
|
243
|
+
|
|
244
|
+
return categories
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_configuration_glossary() -> str:
|
|
248
|
+
"""Generate a glossary of configuration terms for the help section."""
|
|
249
|
+
glossary = """
|
|
250
|
+
[bold]Configuration Key Glossary[/bold]
|
|
251
|
+
|
|
252
|
+
[cyan]What are configuration keys?[/cyan]
|
|
253
|
+
Configuration keys are setting names (like 'default_model', 'max_retries') that control how TunaCode behaves.
|
|
254
|
+
Think of them like preferences in any app - they let you customize TunaCode to work the way you want.
|
|
255
|
+
|
|
256
|
+
[cyan]Key Categories:[/cyan]
|
|
257
|
+
• [yellow]AI Models[/yellow]: Which AI to use (GPT-4, Claude, etc.)
|
|
258
|
+
• [yellow]API Keys[/yellow]: Your credentials for AI services
|
|
259
|
+
• [yellow]Behavior Settings[/yellow]: How TunaCode acts (retries, iterations, etc.)
|
|
260
|
+
• [yellow]Tool Configuration[/yellow]: Which tools TunaCode can use
|
|
261
|
+
• [yellow]Performance Settings[/yellow]: Memory and speed optimizations
|
|
262
|
+
• [yellow]User Experience[/yellow]: Interface and tutorial preferences
|
|
263
|
+
|
|
264
|
+
[cyan]Common Examples:[/cyan]
|
|
265
|
+
• default_model → Which AI model to use by default
|
|
266
|
+
• max_retries → How many times to retry failed requests
|
|
267
|
+
• OPENAI_API_KEY → Your OpenAI account credentials
|
|
268
|
+
• tool_ignore → List of tools TunaCode shouldn't use
|
|
269
|
+
• context_window_size → How much conversation history to remember
|
|
270
|
+
|
|
271
|
+
[cyan]Default vs Custom:[/cyan]
|
|
272
|
+
• 📋 Default: TunaCode's built-in settings (work for most people)
|
|
273
|
+
• 🔧 Custom: Settings you've changed to fit your needs
|
|
274
|
+
"""
|
|
275
|
+
return glossary.strip()
|
tunacode/constants.py
CHANGED
|
@@ -209,11 +209,32 @@ async def _process_node(
|
|
|
209
209
|
# Stream content to callback if provided
|
|
210
210
|
# Use this as fallback when true token streaming is not available
|
|
211
211
|
if streaming_callback and not STREAMING_AVAILABLE:
|
|
212
|
+
# Basic diagnostics for first-chunk behavior in fallback streaming
|
|
213
|
+
first_emitted = False
|
|
214
|
+
raw_accum = ""
|
|
212
215
|
for part in node.model_response.parts:
|
|
213
216
|
if hasattr(part, "content") and isinstance(part.content, str):
|
|
214
|
-
content = part.content
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
content = part.content
|
|
218
|
+
# Only check for empty content and JSON thoughts, don't strip whitespace
|
|
219
|
+
# as it may remove important leading spaces/characters
|
|
220
|
+
if content and not content.lstrip().startswith('{"thought"'):
|
|
221
|
+
if not first_emitted:
|
|
222
|
+
try:
|
|
223
|
+
import time as _t
|
|
224
|
+
|
|
225
|
+
ts_ns = _t.perf_counter_ns()
|
|
226
|
+
except Exception:
|
|
227
|
+
ts_ns = 0
|
|
228
|
+
# We cannot guarantee session access here; log via logger only
|
|
229
|
+
logger.debug(
|
|
230
|
+
"[src-fallback] first_chunk ts_ns=%s chunk_repr=%r len=%d",
|
|
231
|
+
ts_ns,
|
|
232
|
+
content[:5],
|
|
233
|
+
len(content),
|
|
234
|
+
)
|
|
235
|
+
first_emitted = True
|
|
236
|
+
raw_accum += content
|
|
237
|
+
# Stream the original content without stripping
|
|
217
238
|
if streaming_callback:
|
|
218
239
|
await streaming_callback(content)
|
|
219
240
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Streaming instrumentation and handling for agent model request nodes.
|
|
2
|
+
|
|
3
|
+
This module encapsulates verbose streaming + logging logic used during
|
|
4
|
+
token-level streaming from the LLM provider. It updates session debug fields
|
|
5
|
+
and streams deltas to the provided callback while being resilient to errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Awaitable, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from tunacode.core.logging.logger import get_logger
|
|
13
|
+
from tunacode.core.state import StateManager
|
|
14
|
+
|
|
15
|
+
# Import streaming types with fallback for older versions
|
|
16
|
+
try: # pragma: no cover - import guard for pydantic_ai streaming types
|
|
17
|
+
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta # type: ignore
|
|
18
|
+
|
|
19
|
+
STREAMING_AVAILABLE = True
|
|
20
|
+
except Exception: # pragma: no cover - fallback when streaming types unavailable
|
|
21
|
+
PartDeltaEvent = None # type: ignore
|
|
22
|
+
TextPartDelta = None # type: ignore
|
|
23
|
+
STREAMING_AVAILABLE = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def stream_model_request_node(
|
|
30
|
+
node,
|
|
31
|
+
agent_run_ctx,
|
|
32
|
+
state_manager: StateManager,
|
|
33
|
+
streaming_callback: Optional[Callable[[str], Awaitable[None]]],
|
|
34
|
+
request_id: str,
|
|
35
|
+
iteration_index: int,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Stream token deltas for a model request node with detailed instrumentation.
|
|
38
|
+
|
|
39
|
+
This function mirrors the prior inline logic in main.py but is extracted to
|
|
40
|
+
keep main.py lean. It performs up to one retry on streaming failure and then
|
|
41
|
+
degrades to non-streaming for that node.
|
|
42
|
+
"""
|
|
43
|
+
if not (STREAMING_AVAILABLE and streaming_callback):
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Gracefully handle streaming errors from LLM provider
|
|
47
|
+
for attempt in range(2): # simple retry once, then degrade gracefully
|
|
48
|
+
try:
|
|
49
|
+
async with node.stream(agent_run_ctx) as request_stream:
|
|
50
|
+
# Initialize per-node debug accumulators
|
|
51
|
+
state_manager.session._debug_raw_stream_accum = ""
|
|
52
|
+
state_manager.session._debug_events = []
|
|
53
|
+
first_delta_logged = False
|
|
54
|
+
debug_event_count = 0
|
|
55
|
+
first_delta_seen = False
|
|
56
|
+
seeded_prefix_sent = False
|
|
57
|
+
pre_first_delta_text: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
# Helper to extract text from a possible final-result object
|
|
60
|
+
def _extract_text(obj) -> Optional[str]:
|
|
61
|
+
try:
|
|
62
|
+
if obj is None:
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(obj, str):
|
|
65
|
+
return obj
|
|
66
|
+
# Common attributes that may hold text
|
|
67
|
+
for attr in ("output", "text", "content", "message"):
|
|
68
|
+
v = getattr(obj, attr, None)
|
|
69
|
+
if isinstance(v, str) and v:
|
|
70
|
+
return v
|
|
71
|
+
# Parts-based result
|
|
72
|
+
parts = getattr(obj, "parts", None)
|
|
73
|
+
if isinstance(parts, (list, tuple)) and parts:
|
|
74
|
+
texts: list[str] = []
|
|
75
|
+
for p in parts:
|
|
76
|
+
c = getattr(p, "content", None)
|
|
77
|
+
if isinstance(c, str) and c:
|
|
78
|
+
texts.append(c)
|
|
79
|
+
if texts:
|
|
80
|
+
return "".join(texts)
|
|
81
|
+
# Nested .result or .response
|
|
82
|
+
for attr in ("result", "response", "final"):
|
|
83
|
+
v = getattr(obj, attr, None)
|
|
84
|
+
t = _extract_text(v)
|
|
85
|
+
if t:
|
|
86
|
+
return t
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Mark stream open
|
|
92
|
+
try:
|
|
93
|
+
import time as _t
|
|
94
|
+
|
|
95
|
+
state_manager.session._debug_events.append(
|
|
96
|
+
f"[src] stream_opened ts_ns={_t.perf_counter_ns()}"
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
async for event in request_stream:
|
|
102
|
+
debug_event_count += 1
|
|
103
|
+
# Log first few raw event types for diagnosis
|
|
104
|
+
if debug_event_count <= 5:
|
|
105
|
+
try:
|
|
106
|
+
etype = type(event).__name__
|
|
107
|
+
d = getattr(event, "delta", None)
|
|
108
|
+
dtype = type(d).__name__ if d is not None else None
|
|
109
|
+
c = getattr(d, "content_delta", None) if d is not None else None
|
|
110
|
+
clen = len(c) if isinstance(c, str) else None
|
|
111
|
+
cpreview = repr(c[:5]) if isinstance(c, str) else None
|
|
112
|
+
# Probe common fields on non-delta events to see if they contain text
|
|
113
|
+
r = getattr(event, "result", None)
|
|
114
|
+
rtype = type(r).__name__ if r is not None else None
|
|
115
|
+
rpreview = None
|
|
116
|
+
rplen = None
|
|
117
|
+
# Also inspect event.part if present (e.g., PartStartEvent)
|
|
118
|
+
p = getattr(event, "part", None)
|
|
119
|
+
ptype = type(p).__name__ if p is not None else None
|
|
120
|
+
pkind = getattr(p, "part_kind", None)
|
|
121
|
+
pcontent = getattr(p, "content", None)
|
|
122
|
+
ppreview = repr(pcontent[:20]) if isinstance(pcontent, str) else None
|
|
123
|
+
pplen = len(pcontent) if isinstance(pcontent, str) else None
|
|
124
|
+
try:
|
|
125
|
+
if isinstance(r, str):
|
|
126
|
+
rpreview = repr(r[:20])
|
|
127
|
+
rplen = len(r)
|
|
128
|
+
elif r is not None:
|
|
129
|
+
# Try a few common shapes: .output, .text, .parts
|
|
130
|
+
r_output = getattr(r, "output", None)
|
|
131
|
+
r_text = getattr(r, "text", None)
|
|
132
|
+
r_parts = getattr(r, "parts", None)
|
|
133
|
+
if isinstance(r_output, str):
|
|
134
|
+
rpreview = repr(r_output[:20])
|
|
135
|
+
rplen = len(r_output)
|
|
136
|
+
elif isinstance(r_text, str):
|
|
137
|
+
rpreview = repr(r_text[:20])
|
|
138
|
+
rplen = len(r_text)
|
|
139
|
+
elif isinstance(r_parts, (list, tuple)) and r_parts:
|
|
140
|
+
# render a compact preview of first textual part
|
|
141
|
+
for _rp in r_parts:
|
|
142
|
+
rc = getattr(_rp, "content", None)
|
|
143
|
+
if isinstance(rc, str) and rc:
|
|
144
|
+
rpreview = repr(rc[:20])
|
|
145
|
+
rplen = len(rc)
|
|
146
|
+
break
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
state_manager.session._debug_events.append(
|
|
150
|
+
f"[src] event[{debug_event_count}] etype={etype} d={dtype} clen={clen} cprev={cpreview} rtype={rtype} rprev={rpreview} rlen={rplen} ptype={ptype} pkind={pkind} pprev={ppreview} plen={pplen}"
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# Attempt to capture pre-first-delta text from non-delta events
|
|
156
|
+
if not first_delta_seen:
|
|
157
|
+
try:
|
|
158
|
+
# event might be a PartStartEvent with .part.content
|
|
159
|
+
if hasattr(event, "part") and hasattr(event.part, "content"):
|
|
160
|
+
pc = event.part.content
|
|
161
|
+
if isinstance(pc, str) and pc and not pc.lstrip().startswith("\n"):
|
|
162
|
+
# capture a short potential prefix
|
|
163
|
+
pre_first_delta_text = pc[:100] if len(pc) > 100 else pc
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
# Handle delta events
|
|
168
|
+
if PartDeltaEvent and isinstance(event, PartDeltaEvent):
|
|
169
|
+
if isinstance(event.delta, TextPartDelta):
|
|
170
|
+
if event.delta.content_delta is not None and streaming_callback:
|
|
171
|
+
# Seed prefix logic before the first true delta
|
|
172
|
+
if not first_delta_seen:
|
|
173
|
+
first_delta_seen = True
|
|
174
|
+
try:
|
|
175
|
+
delta_text = event.delta.content_delta or ""
|
|
176
|
+
# Only seed when we have a short, safe candidate
|
|
177
|
+
if (
|
|
178
|
+
pre_first_delta_text
|
|
179
|
+
and len(pre_first_delta_text) <= 100
|
|
180
|
+
and not seeded_prefix_sent
|
|
181
|
+
):
|
|
182
|
+
# If delta contains the candidate, emit the prefix up to that point
|
|
183
|
+
probe = pre_first_delta_text[:20]
|
|
184
|
+
idx = pre_first_delta_text.find(probe)
|
|
185
|
+
if idx > 0:
|
|
186
|
+
prefix = pre_first_delta_text[:idx]
|
|
187
|
+
if prefix:
|
|
188
|
+
await streaming_callback(prefix)
|
|
189
|
+
seeded_prefix_sent = True
|
|
190
|
+
state_manager.session._debug_events.append(
|
|
191
|
+
f"[src] seeded_prefix idx={idx} len={len(prefix)} preview={repr(prefix)}"
|
|
192
|
+
)
|
|
193
|
+
elif idx == -1:
|
|
194
|
+
# Delta text does not appear in pre-text; emit the pre-text directly as a seed
|
|
195
|
+
# Safe for short pre-text (e.g., first word) to avoid duplication
|
|
196
|
+
if pre_first_delta_text.strip():
|
|
197
|
+
await streaming_callback(pre_first_delta_text)
|
|
198
|
+
seeded_prefix_sent = True
|
|
199
|
+
state_manager.session._debug_events.append(
|
|
200
|
+
f"[src] seeded_prefix_direct len={len(pre_first_delta_text)} preview={repr(pre_first_delta_text)}"
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
# idx == 0 means pre-text is already the start of delta; skip
|
|
204
|
+
state_manager.session._debug_events.append(
|
|
205
|
+
f"[src] seed_skip idx={idx} delta_len={len(delta_text)}"
|
|
206
|
+
)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
finally:
|
|
210
|
+
pre_first_delta_text = None
|
|
211
|
+
|
|
212
|
+
# Record first-delta instrumentation
|
|
213
|
+
if not first_delta_logged:
|
|
214
|
+
try:
|
|
215
|
+
import time as _t
|
|
216
|
+
|
|
217
|
+
ts_ns = _t.perf_counter_ns()
|
|
218
|
+
except Exception:
|
|
219
|
+
ts_ns = 0
|
|
220
|
+
# Store debug event summary for later display
|
|
221
|
+
state_manager.session._debug_events.append(
|
|
222
|
+
f"[src] first_delta_received ts_ns={ts_ns} chunk_repr={repr(event.delta.content_delta[:5] if event.delta.content_delta else '')} len={len(event.delta.content_delta or '')}"
|
|
223
|
+
)
|
|
224
|
+
first_delta_logged = True
|
|
225
|
+
|
|
226
|
+
# Accumulate full raw stream for comparison and forward delta
|
|
227
|
+
delta_text = event.delta.content_delta or ""
|
|
228
|
+
state_manager.session._debug_raw_stream_accum += delta_text
|
|
229
|
+
await streaming_callback(delta_text)
|
|
230
|
+
else:
|
|
231
|
+
# Log empty or non-text deltas encountered
|
|
232
|
+
state_manager.session._debug_events.append(
|
|
233
|
+
"[src] empty_or_nontext_delta_skipped"
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# Capture any final result text for diagnostics
|
|
237
|
+
try:
|
|
238
|
+
final_text = _extract_text(getattr(event, "result", None))
|
|
239
|
+
if final_text:
|
|
240
|
+
state_manager.session._debug_events.append(
|
|
241
|
+
f"[src] final_text_preview len={len(final_text)} preview={repr(final_text[:20])}"
|
|
242
|
+
)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
# Successful streaming; exit retry loop
|
|
246
|
+
break
|
|
247
|
+
except Exception as stream_err:
|
|
248
|
+
# Log with context and optionally notify UI, then retry once
|
|
249
|
+
logger.warning(
|
|
250
|
+
"Streaming error (attempt %s/2) req=%s iter=%s: %s",
|
|
251
|
+
attempt + 1,
|
|
252
|
+
request_id,
|
|
253
|
+
iteration_index,
|
|
254
|
+
stream_err,
|
|
255
|
+
exc_info=True,
|
|
256
|
+
)
|
|
257
|
+
if getattr(state_manager.session, "show_thoughts", False):
|
|
258
|
+
from tunacode.ui import console as ui
|
|
259
|
+
|
|
260
|
+
await ui.warning("Streaming failed; retrying once then falling back")
|
|
261
|
+
|
|
262
|
+
# On second failure, degrade gracefully (no streaming)
|
|
263
|
+
if attempt == 1:
|
|
264
|
+
if getattr(state_manager.session, "show_thoughts", False):
|
|
265
|
+
from tunacode.ui import console as ui
|
|
266
|
+
|
|
267
|
+
await ui.muted("Switching to non-streaming processing for this node")
|
|
268
|
+
break
|