tunacode-cli 0.0.75__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.

@@ -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
@@ -48,6 +48,7 @@ from .agent_components import (
48
48
  parse_json_tool_calls,
49
49
  patch_tool_messages,
50
50
  )
51
+ from .agent_components.streaming import stream_model_request_node
51
52
 
52
53
  # Import streaming types with fallback for older versions
53
54
  try:
@@ -133,6 +134,11 @@ async def process_request(
133
134
  import uuid
134
135
 
135
136
  request_id = str(uuid.uuid4())[:8]
137
+ # Attach request_id to session for downstream logging/context
138
+ try:
139
+ state_manager.session.request_id = request_id
140
+ except Exception:
141
+ pass
136
142
 
137
143
  # Reset state for new request
138
144
  state_manager.session.current_iteration = 0
@@ -169,14 +175,14 @@ async def process_request(
169
175
  # Handle token-level streaming for model request nodes
170
176
  Agent, _ = get_agent_tool()
171
177
  if streaming_callback and STREAMING_AVAILABLE and Agent.is_model_request_node(node):
172
- async with node.stream(agent_run.ctx) as request_stream:
173
- async for event in request_stream:
174
- if isinstance(event, PartDeltaEvent) and isinstance(
175
- event.delta, TextPartDelta
176
- ):
177
- # Stream individual token deltas
178
- if event.delta.content_delta and streaming_callback:
179
- await streaming_callback(event.delta.content_delta)
178
+ await stream_model_request_node(
179
+ node,
180
+ agent_run.ctx,
181
+ state_manager,
182
+ streaming_callback,
183
+ request_id,
184
+ i,
185
+ )
180
186
 
181
187
  empty_response, empty_reason = await _process_node(
182
188
  node,
@@ -208,7 +214,7 @@ async def process_request(
208
214
  from tunacode.ui import console as ui
209
215
 
210
216
  await ui.warning(
211
- "\n⚠️ EMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED"
217
+ "\nEMPTY RESPONSE FAILURE - AGGRESSIVE RETRY TRIGGERED"
212
218
  )
213
219
  await ui.muted(f" Reason: {empty_reason}")
214
220
  await ui.muted(
@@ -263,7 +269,7 @@ NO MORE DESCRIPTIONS. Take ACTION or mark COMPLETE."""
263
269
  from tunacode.ui import console as ui
264
270
 
265
271
  await ui.warning(
266
- f"⚠️ NO PROGRESS: {unproductive_iterations} iterations without tool usage"
272
+ f"NO PROGRESS: {unproductive_iterations} iterations without tool usage"
267
273
  )
268
274
 
269
275
  unproductive_iterations = 0
@@ -311,7 +317,7 @@ Otherwise, please provide specific guidance on what to do next."""
311
317
  from tunacode.ui import console as ui
312
318
 
313
319
  await ui.muted(
314
- "\n🤔 SEEKING CLARIFICATION: Asking user for guidance on task progress"
320
+ "\nSEEKING CLARIFICATION: Asking user for guidance on task progress"
315
321
  )
316
322
 
317
323
  response_state.awaiting_user_guidance = True
@@ -347,7 +353,7 @@ Please let me know how to proceed."""
347
353
  from tunacode.ui import console as ui
348
354
 
349
355
  await ui.muted(
350
- f"\n📊 ITERATION LIMIT: Asking user for guidance at {max_iterations} iterations"
356
+ f"\nITERATION LIMIT: Asking user for guidance at {max_iterations} iterations"
351
357
  )
352
358
 
353
359
  max_iterations += 5
@@ -374,7 +380,7 @@ Please let me know how to proceed."""
374
380
 
375
381
  await ui.muted("\n" + "=" * 60)
376
382
  await ui.muted(
377
- f"🚀 FINAL BATCH: Executing {len(buffered_tasks)} buffered read-only tools"
383
+ f"FINAL BATCH: Executing {len(buffered_tasks)} buffered read-only tools"
378
384
  )
379
385
  await ui.muted("=" * 60)
380
386
 
@@ -401,7 +407,7 @@ Please let me know how to proceed."""
401
407
  speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
402
408
 
403
409
  await ui.muted(
404
- f"Final batch completed in {elapsed_time:.0f}ms "
410
+ f"Final batch completed in {elapsed_time:.0f}ms "
405
411
  f"(~{speedup:.1f}x faster than sequential)\n"
406
412
  )
407
413
 
@@ -451,7 +457,16 @@ Please let me know how to proceed."""
451
457
  # Re-raise to be handled by caller
452
458
  raise
453
459
  except Exception as e:
454
- logger.error(f"Error in process_request: {e}", exc_info=True)
460
+ # Include request context to aid debugging
461
+ safe_iter = (
462
+ state_manager.session.current_iteration
463
+ if hasattr(state_manager.session, "current_iteration")
464
+ else "?"
465
+ )
466
+ logger.error(
467
+ f"Error in process_request [req={request_id} iter={safe_iter}]: {e}",
468
+ exc_info=True,
469
+ )
455
470
  # Patch orphaned tool messages with generic error
456
471
  patch_tool_messages(
457
472
  f"Request processing failed: {str(e)[:100]}...", state_manager=state_manager